Source

client/ui/abstract/component.js

/**
 * 
 * The **Component** is the base class for all KissJS UI components.
 * 
 * KissJS Component derives from HTMLElement, and therefore inherits all native DOM operations.
 * 
 * Most UI frameworks are encapsulating DOM elements with classes.
 * Instead of that, KissJS is directly attaching new properties and new methods to DOM elements.
 * 
 * Let's imagine a Panel component built with KissJS.
 * Because a KissJS Component is a pure DOM element, you can get your Panel using native DOM operations.
 * Then, once you have your panel, you can directly call its methods like this:
 * 
 * ```
 * const myPanel = document.getElementById("my-panel")
 * myPanel.expand()
 * myPanel.setAnimation("shakeX")
 * ```
 * 
 * This way, it avoids the overhead of encapsulation (= no additional layers to cross).
 * It's also easier to keep the memory clean: when you destroy your DOM element, everything attached to it (states, events...) is flushed and can be garbage collected.
 * 
 * KissJS components are partly using the Custom Web Components API.
 * They are "half" Custom Web Components in the sense they're not using shadow DOM.
 * 
 * They are recognizable and easy to lookup in the DOM because their tag name always starts with "a-", like:
 * "a-field", "a-button", "a-panel", "a-menu", and so on...
 * 
 * The Component base class does a few useful things for us:
 * - id generation (all KissJS components must have an id)
 * - automatically inserted at a specific location in the DOM, using the optional "target" property
 * - keep the component config attached to the component, to be able to serialize it / save it / rebuild it later
 * - automatically adds a base class depending on the component type (example: "a-field", "a-button", ...)
 * - we can bind custom methods, using the "methods" config
 * - we can bind W3C events, using the "events" config
 * - we can "subscribe" the component to one ore more PubSub channels, using the "subscriptions" config
 * - a "render" method is automatically attached to manage the component rendering lifecycle
 * - a "load" method **can** be attached to manage the component's loading / re-loading (typically when the component generation relies on external data)
 * - we can bind the component to one or more data collections, using the "collections" config: it will automatically reload the component when one of the binded collections changes
 * - a few helper methods are attached automatically (show, hide, toggle, animation, showLoading, hideLoading...)
 * - we can hook custom behavior in the lifecycle by using private methods like: _afterConnected, _afterRender, _afterShow, _afterHide, _afterDisconnected
 * 
 * _Schema overview of the instanciation and rendering_:
 * 
 * <img src="../../resources/doc/KissJS - Component.png">
 * 
 * @param {object} config
 * @param {string} [config.id] - id of the component. Will be auto-generated if not set
 * @param {boolean} [config.hidden] - true if the component is hidden when rendered for the 1st time
 * @param {string|HTMLElement} [config.target] - DOM target insertion point. Either a DOM element or a CSS selector.
 * @param {object} [config.methods] - Custom methods of the component
 * @param {object} [config.events] - W3C events handled by the component
 * @param {object} [config.subscriptions] - Array of functions registered in the PubSub
 * @param {Collection[]} [config.collections] - List of collections bound to the compoent. Component will reload if a mutation occurs in one of its bound collections.
 * @param {string|object} [config.tip] - hover help message
 * @param {string|object} [config.animation] - Animation to perform when rendering for the 1st time
 * @param {boolean} [config.autoSize] - If true, the component will trigger its "updateLayout" method when its parent container is resized
 * @returns this
 */
kiss.ui.Component = class Component extends HTMLElement {
	constructor() {
		super()
	}

	/**
	 * Generates a Component from a JSON config
	 * 
	 * @ignore
	 * @param {object} config - JSON config
	 * @returns {HTMLElement}
	 */
	init(config) {
		if (!config) return null

		// Set a flag to define the HTMLElement as a KissJS component
		this.isComponent = true

		// Basic properties
		this.type = config.type || this.constructor.name.toLowerCase()
		this.id = config.id || "cmp-" + (kiss.global.componentCount++).toString()
		this.target = config.target || null // Insertion point in the DOM, or document.body otherwise
		this.config = config // Allows to trace back to the initial config, serialize it, save it, rebuild it

		// Short delay (in ms) while the component stays invisible while its size and position is calculated properly
		// It's not a perfect solution but it stabilizes the rendering behavior
		this.renderDelay = 25

		// Default CSS class is: a-{component-type}
		// Examples: a-field, a-checkbox, a-select, a-button, a-panel...
		this.classList.add("a-" + this.type.toLowerCase())
		if (kiss.screen.isMobile) this.classList.add("a-" + this.type.toLowerCase() + "-mobile")

		// Manage component visibility
		if (config.display) this.displayMode = config.display
		if (config.hidden == true) this.style.display = "none"

		// Setup the component tip/help text (message displayed when the component is hovered)
		if (config.tip) this.attachTip(config.tip)

		// Manage animations
		if (config.animation) this.setAnimation(config.animation)

		// Bind inline methods, defined by the "methods" property of the component
		const methods = config.methods
		if (methods) {
			for (let method in methods) {
				this[method] = methods[method]
			}
		}

		// Bind external methods, defined by calling kiss.views.addViewController(viewId, controllerObject)
		let viewControllers = kiss.views.viewControllers[this.id]

		// Handle the case where a mobile view doesn't have a mobile renderer but has a mobile controller
		if (kiss.screen.isMobile) {
			if (!this.id.includes("mobile")) {
				viewControllers = kiss.views.viewControllers["mobile-" + this.id] || kiss.views.viewControllers[this.id]
			}
		}

		if (viewControllers) {
			for (let method in viewControllers) {
				this[method] = viewControllers[method]
			}
		}

		// Bind custom events
		this._bindEvents(this.config.events)

		// Hold the component's subscriptions to the PubSub
		this.subscriptions = []

		if (this.config.subscriptions) {
			Object.keys(this.config.subscriptions).forEach(pubSubEventName => {
				this.subscriptions.push(
					subscribe(pubSubEventName, this.config.subscriptions[pubSubEventName].bind(this))
				)
			})
		}

		// Observe when the parent container is resized in order to trigger an updateLayout
		if (config.autoSize) {
			this.resizeCount = 0
			this.subscriptions.push(
				subscribe("EVT_CONTAINERS_RESIZED", (containerIds) => {
					if (this.parentNode == document.body || (this.parentNode && this.parentNode.id && containerIds.indexOf(this.parentNode.id) != -1)) {
						// if (this.resizeCount != 0)
						this.updateLayout("EVT_CONTAINERS_RESIZED")
						this.resizeCount++
					}
				})
			)
		}

		//
		// Bind collections
		// It subscribes the component's *load* method to the database updates for the relative models
		//
		if (config.collections && config.collections.length > 0 && config.collections[0]) {
			this.collections = config.collections

			if (this.load) {
				// Get the events to observe
				const observedEvents = []

				this.collections.forEach(collection => {
					let model = collection.model
					let modelId = model.id
					let events = ["EVT_DB_INSERT:", "EVT_DB_UPDATE:", "EVT_DB_DELETE:"]

					events.forEach(EVT => {
						let eventName = EVT + modelId.toUpperCase()
						observedEvents.push(eventName)
					})
				})

				// Subscribe the component to observe CUD mutations of its bound models
				observedEvents.forEach(eventName => {
					//log(`kiss.ui.Component - Binding view <${this.id}> to event <${eventName}>`, 1)

					this.subscriptions.push(
						subscribe(eventName, (msgData) => {
							// Will load or reload the component only if it's connected to the DOM
							if (this.isConnected) {
								log("kiss.ui - React to " + eventName + " - Component loading " + this.id, 2)
								this.load(msgData)
							}
						})
					)
				})

				// Subscribe the component to observe bulk updates too
				// (bulk updates can target multiple and different collections)
				const observedModels = this.collections.map(collection => collection.model.id)

				this.subscriptions.push(
					subscribe("EVT_DB_UPDATE_BULK", (msgData) => {
						// Will load or reload the component only if:
						// - it's connected to the DOM
						// - the bulk update contains an update related to the component's bound models
						if (this.isConnected) {
							let shouldLoad = false

							// Check if one of the updates is related to a component's observed model
							const bulkUpdates = msgData.data
							bulkUpdates.forEach(update => {
								if (observedModels.indexOf(update.modelId) != -1) shouldLoad = true
							})

							if (shouldLoad) {
								log("EVT_DB_UPDATE_BULK - Component loading " + this.id, 2)
								this.load(msgData)
							}
						}
					})
				)
			}
		}

		this._translate(config)

		return this
	}
    
	/**
	 * Translate the localized elements of the component, if any.
	 * - Field labels
	 * - Section titles
	 * - View names
	 * - Model names
	 * 
	 * @private
	 * @ignore
	 * @param {object} config - The configuration object passed to the Component init method
	 */
	_translate(config) {
		// Field labels
		if (config.label) {
			config.label = kiss.language.translateProperty(config, "label")
		}
		// Section titles
		else if (config.title) {
			config.title = kiss.language.translateProperty(config, "title")
		}
		// View names or model names
		else if (config.name) {
			config.name = kiss.language.translateProperty(config, "name")

			if (config.namePlural) {
				config.namePlural = kiss.language.translateProperty(config, "namePlural")
			}
		}
	}

	/**
	 * Observe the connected-ness of the component and trigger the configured callback
	 * 
	 * @ignore
	 */
	connectedCallback() {
		if (this._afterConnected) this._afterConnected()
	}

	/**
	 *
	 */
	disconnectedCallback() {
		if (this.loadingId) this.hideLoading() // Try to hide the loading mask (if any)

		if (this._afterDisconnected) this._afterDisconnected()
	}

	/**
	 * Render an Element at a specified DOM location
	 * The rendering is optimized to render only the element that are detached from the DOM.
	 * 
	 * The render() method is chainable with other Component's methods.
	 * For example:
	 * ```
	 * myElement.render().showAt(100, 100).setAnimation("shakeX")
	 * ```
	 * 
	 * @param {*} [target] - optional DOM target insertion point
	 * @param {boolean} load - true (default) to execute the component's load method after DOM insertion
	 * @returns this
	 */
	render(target, load = true) {
		// Hide the component while it's being rendered and sizes are computed
		this.style.visibility = "hidden"

		//log("Render: " + this.type + " - " + this.id + " on target " + target, 1)

		if (!this.isConnected) {
			// Add custom classes
			if (this.config && this.config.classes) this._dispatchClasses(this.config.classes)
            
			if (this.config && this.config.class) {
				if (this.config.class.includes(" ")) {
					this.config.class.split(" ").forEach(c => this.classList.add(c))
				}
				else {
					this.classList.add(this.config.class)
				}
			}

			// Add custom styles
			if (this.config && this.config.styles) this._dispatchStyles(this.config.styles)
			if (this.config && this.config.style) this.style.cssText += this.config.style
			if (this.config && this.config.hidden) this.style.display = "none"

			// Insert the component at a specfic DOM location
			this._insertIntoDOM(target, this.config && this.config.targetIndex)
		}

		// Render container's children, if any
		if ((this.items) && (this.items.length > 0)) this.items.forEach(item => item.render(item.target || target))

		// If the component has a load method, we call it
		if (load && (this.load)) {

			if (this.isComponent) {
				// KissJS components have a more complex loading process
				// because a component might rely on data that should be loaded before
				this._load()
			} else {
				// Standard HTMLElement
				this.load()
			}
		} else {
			// If the component has a sizing a method, we call it now
			if (this.updateLayout) this.updateLayout("Component.render")

			// If the component has an afterRender method, we execute it
			if (this._afterRender) this._afterRender()

			// Wait a short delay before displaying the component so that all sizes are already calculated
			setTimeout(() => this.style.visibility = "visible", this.renderDelay)
		}

		return this
	}

	/**
	 * Update a component with a new config
	 * 
	 * - Internally, destroys the component and re-render it from its config.
	 * - If the component was inside a parent container, it re-render it at the same position
	 * - Attention: if the component received extra properties/methods/events outside it's default config, they will be lost
	 * 
	 * @param {object} newConfig
	 * @returns {object} The new KissJS component
	 */
	update(newConfig) {
		let component
		const config = this.config
		if (newConfig) Object.assign(config, newConfig)

		config.target = this.parentNode.id
		config.targetIndex = Array.from(this.parentNode.children).indexOf(this)

		// Remove the existing component
		this.deepDelete()

		// Build a new one and insert it in the DOM
		if (this.type) {
			if (["text", "textarea", "number", "date", "password", "lookup", "summary"].includes(this.type)) {
				// Input fields and textarea
				component = document.createElement("a-field").init(config)
			} else {
				// Other fields and elements
				component = document.createElement("a-" + this.type.toLowerCase()).init(config)
			}
		} else {
			// Block
			component = document.createElement("a-block").init(config)
		}

		return component.render()
	}

	/**
	 * Hide the component
	 * 
	 * @returns this
	 */
	hide() {
		// Keep the current display mode in cache for future restore
		let currentDisplayMode = window.getComputedStyle(this, "")["display"]
		if (currentDisplayMode != "" && currentDisplayMode != "none") this.displayMode = currentDisplayMode

		this.style.display = "none"
		this.hidden = true

		if (this._afterHide) this._afterHide()

		return this
	}

	/**
	 * Display the component
	 * 
	 * @param {string} [mode] - Force a display mode. Ex: block, flex, inline, inline-block, inline-flex
	 * @returns this
	 */
	show(mode) {
		if (this.style.display != "none") return this

		this.style.display = mode || this.displayMode || (this.config && this.config.display) || "block"
		this.hidden = false

		if (this._afterShow) this._afterShow()

		return this
	}

	/**
	 * Show the component at a specified (x, y) position on the screen.
	 * If the component leaks outside the viewport, it's re-centered to fit in.
	 * 
	 * @param {number} x - Coord x in pixels
	 * @param {number} y - Coord y in pixels 
	 * @param {number} [animationTimeInSeconds] - Optional parameter to animate the translation of the Element
	 * @returns this
	 * 
	 * @example
	 * // It wil take 2 seconds to translate to position 500,500:
	 * myElement.showAt(500, 500, 2)
	 */
	showAt(x, y, animationTimeInSeconds) {
		if (animationTimeInSeconds) this.style.transition = animationTimeInSeconds + "s"
		this.style.left = x + "px"
		this.style.top = y + "px"
		this.show().moveToViewport()
		return this
	}

	/**
	 * Move the component inside the visible viewport.
	 * This is useful for example to re-center a component so that it's entirely visible.
	 * 
	 * @returns this
	 */
	moveToViewport() {
		kiss.tools.moveToViewport(this)
		return this
	}

	/**
	 * Special method to manage the "locked" properties of fields components
	 * 
	 * @ignore
	 */
	isLocked() {
		this.locker = "<span class=\"field-label-read-only fas fa-lock\"></span> "
		return (this.config && this.config.label && this.config.locked === true) 
	}

	/**
	 * Special method to manage the "required" properties of fields components
	 * 
	 * @ignore
	 */
	isRequired() {
		this.asterisk = " <span class=\"field-label-required\"><sup>*</sup></span>"
		return (this.config && this.config.label && this.config.required === true && this.config.readOnly !== true && this.config.disabled !== true && !this.isLocked())
	}

	/**
	 * Test if the component is hidden
	 * 
	 * @returns {boolean}
	 */
	isHidden() {
		return (this.style.display == "none") || (this.hidden == true)
	}

	/**
	 * Test if the component is visible
	 * 
	 * @returns {boolean}
	 */
	isVisible() {
		return !this.isHidden()
	}

	/**
	 * Show / hide alternatively the Component
	 * 
	 * @returns this
	 */
	toggle() {
		if (this.isHidden()) {
			this.show()
		} else {
			this.hide()
		}
		return this
	}

	/**
	 * Show a loading spinner over the Component.
	 * By default, the overlay has the size of the element.
	 * 
	 * @param {object} config
	 * @param {boolean} config.fullscreen - If true, the loading mask cover the full screen
	 * @param {boolean} config.mask - Set to false to hide the background overlay
	 * @param {number|string} config.spinnerSize - Size of the spinning symbol. If a number, it's in pixels. If a string, it's a CSS size.
	 * @returns this
	 * 
	 * @example
	 * myPanel.showLoading({spinnerSize: 32})
	 */
	showLoading(config = {}) {
		// Exit if the component is already in loading state
		if (this.isLoading) return

		const box = this.getBoundingClientRect()

		// Create an overlay
		const mask = document.createElement("div")
		mask.classList.add("component-loader-mask")
		mask.id = "mask-" + kiss.tools.shortUid()
		mask.style.top = (config.fullscreen == true) ? 0 : box.y + "px"
		mask.style.left = (config.fullscreen == true) ? 0 : box.x + "px"
		mask.style.width = (config.fullscreen == true) ? "100vw" : box.width + "px"
		mask.style.height = (config.fullscreen == true) ? "100vh" : box.height + "px"

		if (this.type == "panel") mask.style.borderRadius = "var(--panel-border-radius)"
		if (config.mask !== false) {
			mask.style.background = "var(--background-overlay)"
			mask.style.zIndex = (this.style.zIndex || 0) + 1
		}

		// Create the loading spinner
		const spinner = document.createElement("div")
		spinner.classList.add("component-loader")
		spinner.id = "spinner-" + this.id

		// Set the spinner size
		if (config.spinnerSize) {
			if (typeof config.spinnerSize == "number") {
				spinner.style.width = config.spinnerSize + "px"
				spinner.style.height = config.spinnerSize + "px"
			} else {
				spinner.style.width = config.spinnerSize
				spinner.style.height = config.spinnerSize
			}
		} else {
			spinner.style.width = "3.2rem"
			spinner.style.height = "3.2rem"
		}

		// Attach the spinner id to the element so that we can remove it later
		this.loadingId = mask.id

		// Attach overlay & spinner to the component
		const maskNode = document.body.appendChild(mask)
		maskNode.appendChild(spinner)

		this.isLoading = true
		return this
	}

	/**
	 * Hide the loading spinner of the Component
	 * 
	 * @returns this
	 */
	hideLoading() {
		try {
			$(this.loadingId).remove()
			delete this.loadingId
			this.isLoading = false
		} catch (err) {
			// log("<Component>.hideLoading() - Could not find element to hide:" + this.id)
		}
		return this
	}

	/**
	 * Attach a tip text to the component
	 * 
	 * TODO: At the moment, attaching a tip prevents from having other "onmouseenter" events. Don't overwrite onmouseenter event
	 * 
	 * @param {object|text} tipConfig - Config object {text: ..., deltaX: ..., deltaY: ...}, or a simple string
	 * @param {string} tipConfig.text - Tip text
	 * @param {string} [tipConfig.textAlign] - Tip text alignment: "center", "right". Default "left"
	 * @param {number} [tipConfig.x] - Optional static x
	 * @param {number} [tipConfig.Y] - Optional static y
	 * @param {number} [tipConfig.deltaX] - Shift the tip on X coordinate
	 * @param {number} [tipConfig.deltaY] - Shift the tip on Y coordinate
	 * @returns this
	 * 
	 * @example
	 * // Using a configuration object
	 * myField.attachTip({
	 *  text: "Please enter your name",
	 *  deltaX: 20,
	 *  deltaY: 20
	 * })
	 * 
	 * // Using a simple text
	 * myField.attachTip("Please enter your name")
	 */
	attachTip(tipConfig) {
		if (kiss.screen.isMobile) return
		if (kiss.screen.isTouch()) return
		if (this.tip) return

		if (typeof tipConfig === "object") {
			this.tip = createTip({
				target: this,
				text: tipConfig.text,
				textAlign: tipConfig.textAlign,
				x: tipConfig.x,
				y: tipConfig.y,
				deltaX: tipConfig.deltaX,
				deltaY: tipConfig.deltaY,
				minWidth: tipConfig.minWidth,
				maxWidth: tipConfig.maxWidth
			})
		} else {
			this.tip = createTip({
				target: this,
				text: tipConfig
			})
		}

		// Wait for the DOM before attaching the event
		setTimeout(() => {
			if (this.config && !this.isConnected) {
				// If the component is not initialized yet, we just modify its configuration
				if (!this.config.events) this.config.events = {}
				this.config.events.onmouseenter = () => this.tip.render()
			} else {
				// Otherwise, we override its onmouseenter event
				this.onmouseenter = () => this.tip.render()
			}
		}, 0)
		return this
	}

	/**
	 * Detach the tip from the component (if any)
	 * 
	 * @returns this
	 */
	detachTip() {
		if (!this.tip) return this
		this.tip.detach()
		delete this.tip
		return this
	}

	/**
	 * Get the component's width
	 * 
	 * @returns {number} The width in pixels
	 */
	getWidth() {
		return this.clientWidth
	}

	/**
	 * Get the component's height
	 * 
	 * @returns {number} The height in pixels
	 */
	getHeight() {
		return this.clientHeight
	}

	/**
	 * Set the component's size
	 * 
	 * @param {object} [config.width] - Any CSS valid size, or a number (will be converted to pixels)
	 * @param {object} [config.height] - Any CSS valid size, or a number (will be converted to pixels)
	 * @param config
	 * @returns this
	 * 
	 * @example
	 * myComponent.setSize({width: "10vw"})
	 * myComponent.setSize({height: "100px"})
	 * myComponent.setSize({width: 300, height: "20%"})
	 */
	setSize(config) {
		if (config.width) {
			this.config.width = config.width
			this._setWidth()
		}
		if (config.height) {
			this.config.height = config.height
			this._setHeight()
		}
		return this
	}

	/**
	 * Set the component's left position
	 * 
	 * @param {string|function} newLeft - Any CSS valid size, or a function returning a size
	 * @returns this
	 */
	setLeft(newLeft) {
		this.config.left = newLeft
		this._setLeft()
		return this
	}

	/**
	 * Set the component's top position
	 * 
	 * @param {string|function} newTop - Any CSS valid size, or a function returning a size
	 * @returns this
	 */
	setTop(newTop) {
		this.config.top = newTop
		this._setTop()
		return this
	}    

	/**
	 * Insert the component at a specfic DOM location
	 * 
	 * @private
	 * @ignore
	 * @param {string|HTMLElement} [target] - Target id or DOM element
	 */
	_insertIntoDOM(target, index) {
		// Define insertion point in the DOM
		let domTarget = target || this.target

		if (domTarget) {
			// If a dom target is specified, the component is appended here
			if (typeof domTarget == "string") {
                
				// The target is a CSS selector
				if (index) {
					$(domTarget).insertBefore(this, $(domTarget).children[index])
				} else {
					$(domTarget).appendChild(this)
				}
			} else {

				// The target is a DOM element
				if (index) {
					domTarget.insertBefore(this, domTarget.children[index])
				} else {
					domTarget.appendChild(this)
				}
			}
		} else {
			// ... else it's rendered to the document body
			document.body.appendChild(this)
		}
	}

	/**
	 * Load component's data
	 * 
	 * @private
	 * @ignore
	 */
	async _load() {
		try {
			// Load the records of the bound collections
			if (this.collections) {
				for (let collection of this.collections) {
					await collection.find()
				}
			}

			// Call the "load" method of the component
			if (this.load) await this.load()

			// Once loaded, recompute the size and position if it has a "updateLayout" method
			if (this.updateLayout) this.updateLayout("Component._load")

			// If the component has an afterRender method, we execute it
			if (this._afterRender) this._afterRender()

			// Wait a short delay while all sizes are calculated
			setTimeout(() => this.style.visibility = "visible", this.renderDelay)

		} catch (err) {
			log("kiss.ui - Component - Loading error: " + this.id, 4)
			log(err)
		}
	}

	/**
	 * Bind events to the Component
	 * 
	 * @private
	 * @ignore
	 * @param {object} events - Object containing the functions to bind to DOM events. Ex: {"click": event => {...}, "mouseover": event => {...} }
	 * @param {HTMLElement} [target] - Optional DOM Element to which the event should be bound. Defaults to "this" (= the Component itself)
	 * @returns this
	 * 
	 * @example
	 * Event names can follow various conventions as they will be automatically normalized to the W3C event:
	 * - change => OK
	 * - onchange => OK
	 * - onChange => OK
	 */
	_bindEvents(events, target) {
		let targetElement = target || this

		if (events) {
			for (let event in events) {
				const eventName = (event.slice(0, 2).toLowerCase() == "on") ? event : "on" + event
				targetElement[eventName.toLowerCase()] = events[event]
			}
		}
		return this
	}

	/**
	 * Apply multiple style properties on multiple targets.
	 * 
	 * The property array supports property aliases, in case the config object can't match the exact targeted property name.
	 * See the example below with an aliased property (config.headerBackgroundColor will set panelHeader.style.backgroundColor)
	 * 
	 * @private
	 * @ignore
	 * @param {object} config - The configuration object passed to the Component init method
	 * @param {array[][][]} rules - 3 dimensions array of rules that defines which properties should be applied to which targets.
	 * @returns this
	 * 
	 * @example
	 * // Here, the headerBackgroundColor config will set the backgroundColor property of the panel header style:
	 * this._setProperties(config, [
	 *      [["headerBackgroundColor=backgroundColor"], [panelHeader.style]],
	 *      [["backgroundColor"], [panel.style]],
	 *      [["padding"], [panelContent.style]],
	 *      [["overflow", "overflowX", "overflowY"], [this.style, panel.style, panelContent.style]]
	 * ])
	 */
	_setProperties(config, rules) {
		rules.forEach(rule => {
			let properties = rule[0]
			let targets = rule[1]

			properties.forEach(property => {
				let [configProperty, targetProperty] = property.split("=")

				if (config[configProperty] != null) {

					let value = config[configProperty]

					// Every property involving a dimension goes through "_computeSize" process
					if ([
						"padding",
						"margin",
						"top",
						"right",
						"bottom",
						"left",
						"width",
						"minWidth",
						"maxWidth",
						"height",
						"minHeight",
						"maxHeight",
						"fontSize",
						"iconSize",
						"borderWidth",
						"borderRadius",
						"fieldWidth",
						"fieldHeight",
						"labelWidth",
						"headerHeight",
						"colorWidth",
						"colorHeight"
					].includes(configProperty)) {
						value = this._computeSize(configProperty)
					}

					targets.forEach(target => {
						if (target) {
							if (!targetProperty) {
								target[configProperty] = value
							} else {
								target[targetProperty] = value
							}
						}
					})
				}
			})
		})
		return this
	}

	/**
	 * Compute the element size. It can handle various use cases:
	 * - the size is a static string, like "300px" or "5vw" or "80em" => it's applied "as this"
	 * - the size is static number, like 300 => it's converted to pixels: "300px"
	 * - the size is a function => it's computed before being applied
	 * 
	 * @private
	 * @ignore
	 * @param {string} type - Example: "width", "labelWidth", "height", "top", "fontSize"
	 * @returns {string} The computed size
	 */
	_computeSize(type) {
		let newSize = this.config[type]

		// Size is a function
		if (typeof newSize == "function") {
			try {
				newSize = newSize()
			} catch (err) {
				//log("Couldn't compute the size of an element: " + this.id)
				newSize = 0
			}
		}

		// Size if a number => convert it to pixels
		if (typeof newSize == "number") newSize = newSize.toString() + "px"

		return newSize
	}

	/**
	 * Manage the component size & position
	 * 
	 * @private
	 * @ignore
	 */
	_setTop() {
		setTimeout(() => this.style.top = this._computeSize("top"), 0)
	}

	/**
	 *
	 */
	_setLeft() {
		setTimeout(() => this.style.left = this._computeSize("left"), 0)
	}

	/**
	 *
	 */
	_setBottom() {
		setTimeout(() => this.style.bottom = this._computeSize("bottom"), 0)
	}

	/**
	 *
	 */
	_setRight() {
		setTimeout(() => this.style.right = this._computeSize("right"), 0)
	}

	/**
	 *
	 */
	_setWidth() {
		setTimeout(() => this.style.width = this._computeSize("width"), 0)
	}

	/**
	 *
	 */
	_setMinWidth() {
		setTimeout(() => this.style.minWidth = this._computeSize("minWidth"), 0)
	}

	/**
	 *
	 */
	_setMaxWidth() {
		setTimeout(() => this.style.maxWidth = this._computeSize("maxWidth"), 0)
	}

	/**
	 *
	 */
	_setHeight() {
		setTimeout(() => this.style.height = this._computeSize("height"), 0)
	}

	/**
	 *
	 */
	_setMinHeight() {
		setTimeout(() => this.style.minHeight = this._computeSize("minHeight"), 0)
	}

	/**
	 *
	 */
	_setMaxHeight() {
		setTimeout(() => this.style.maxHeight = this._computeSize("maxHeight"), 0)
	}

	/**
	 * Dispatch multiple CSS classes on a list of targeted classes elements
	 * 
	 * @private
	 * @ignore
	 * @param {object} cssClasses - Configuration should be passed as shown in the example below
	 * @returns this
	 * 
	 * @example
	 * this._dispatchClasses({
	 *  "window-header": "myCSS1 myCSS2 myCSS3",
	 *  "window-content": "myCSS4 myCSS5 myCSS6"
	 * })
	 */
	_dispatchClasses(cssClasses) {
		Object.keys(cssClasses).forEach(cssClass => {
			let arrayOfClasses = cssClasses[cssClass].split(/\s+/)

			try {
				// Add classes to the root element
				if (cssClass == "this") {
					this.classList.add(...arrayOfClasses)
				}
				// Add classes to children nodes
				else {
					let targetElement = this.querySelector("." + cssClass)
					targetElement.classList.add(...arrayOfClasses)
				}
			} catch (err) {
				log(`Component._dispatchClasses: the class selector <${cssClass}> is not valid for the component <${this.id}>`, 2)
			}
		})
		return this
	}

	/**
	 * Dispatch multiple styles on a list of targeted classes elements
	 * 
	 * @private
	 * @ignore
	 * @param {object} styles - Configuration should be passed as shown in the example below
	 * @returns this
	 * 
	 * @example
	 * this._dispatchStyles({
	 *      "window-header": "background-color: #000000",
	 *      "window-content": "background-color: #ffffff"
	 * })
	 */
	_dispatchStyles(styles) {
		if (styles) {
			Object.keys(styles).forEach(cssClass => {
				try {
					// Add styles to the root element
					if (cssClass == "this") {
						let currentStyles = this.style.cssText
						this.style = currentStyles + ";" + styles[cssClass]
					}
					// Add styles to children nodes
					else {
						let currentStyles = this.querySelector("." + cssClass).style.cssText
						this.querySelector("." + cssClass).style = currentStyles + ";" + styles[cssClass]
					}
				} catch (err) {
					log(`Component._dispatchStyles: the class selector <${cssClass}> is not valid for the component <${this.id}>`, 2)
				}
			})
		}
		return this
	}

	/**
	 * Toggle one or more CSS classes of a single HTMLElement
	 * 
	 * @private
	 * @ignore
	 * @param {string} cssClasses - String containing the names of the classes to toggle, separated with spaces. Ex: "panel panel-body"
	 * @returns this
	 */
	_toggleClass(cssClasses) {
		if (cssClasses) cssClasses.split(/\s+/).forEach(cssClass => this.classList.toggle(cssClass))
		return this
	}

	/**
	 * Animate an HTMLElement.
	 * 
	 * - The animation must be set *before* rendering the component
	 * - It's chainable, so it can be combined with render() and showAt()
	 * 
	 * Animation speed can be modified with the param "speed":
	 * - slower
	 * - slow
	 * - fast
	 * - faster
	 * 
	 * Animation repetition can be adjusted with the param "repeat":
	 * - repeat-1
	 * - repeat-2
	 * - repeat-3
	 * - infinite
	 * 
	 * Available animation names are:
	 * - bounce
	 * - flash
	 * - pulse
	 * - rubberBand
	 * - shakeX
	 * - shakeY
	 * - headShake
	 * - swing
	 * - tada
	 * - wobble
	 * - jello
	 * - heartBeat
	 * - hinge
	 * - jackInTheBox
	 * - rollIn
	 * - rollOut
	 * - flipInX
	 * - flipInY
	 * - flipOutX
	 * - flipOutY
	 * - backInDown
	 * - backInLeft
	 * - backInRight
	 * - backInUp
	 * - backOutDown
	 * - backOutLeft
	 * - backOutRight
	 * - backOutUp
	 * - bounceIn
	 * - bounceInDown
	 * - bounceInLeft
	 * - bounceInRight
	 * - bounceInUp
	 * - bounceOut
	 * - bounceOutDown
	 * - bounceOutLeft
	 * - bounceOutRight
	 * - bounceOutUp
	 * - fadeIn
	 * - fadeInDown
	 * - fadeInDownBig
	 * - fadeInLeft
	 * - fadeInLeftBig
	 * - fadeInRight
	 * - fadeInRightBig
	 * - fadeInUp
	 * - fadeInUpBig
	 * - fadeInTopLeft
	 * - fadeInTopRight
	 * - fadeInBottomLeft
	 * - fadeInBottomRight
	 * - fadeOut
	 * - fadeOutDown
	 * - fadeOutDownBig
	 * - fadeOutLeft
	 * - fadeOutLeftBig
	 * - fadeOutRight
	 * - fadeOutRightBig
	 * - fadeOutUp
	 * - fadeOutUpBig
	 * - fadeOutTopLeft
	 * - fadeOutTopRight
	 * - fadeOutBottomLeft
	 * - fadeOutBottomRight
	 * - lightSpeedInRight
	 * - lightSpeedInLeft
	 * - lightSpeedOutRight
	 * - lightSpeedOutLeft
	 * - rotateIn
	 * - rotateInDownLeft
	 * - rotateInDownRight
	 * - rotateInUpLeft
	 * - rotateInUpRight
	 * - rotateOut
	 * - rotateOutDownLeft
	 * - rotateOutDownRight
	 * - rotateOutUpLeft
	 * - rotateOutUpRight
	 * - zoomIn
	 * - zoomInDown
	 * - zoomInLeft
	 * - zoomInRight
	 * - zoomInUp
	 * - zoomOut
	 * - zoomOutDown
	 * - zoomOutLeft
	 * - zoomOutRight
	 * - zoomOutUp
	 * - slideInDown
	 * - slideInLeft
	 * - slideInRight
	 * - slideInUp
	 * - slideOutDown
	 * - slideOutLeft
	 * - slideOutRight
	 * - slideOutUp
	 * 
	 * @param {string|object} config - If the param is a string, it must be the animation name. Otherwise, it's a config like: {name: "zoomIn", speed: "fast", repeat: "repeat-3", callback: function() {...}}. Set the animation to false to remove the animation.
	 * @param {string} config.name - Animation name
	 * @param {string} [config.speed] - "slower" | "slow" | "fast" | "faster"
	 * @param {string} [config.repeat] - "repeat-1" | "repeat-2" | "repeat-3" | "infinite"
	 * @param {function} [config.callback] - Function to execute when the animation ends
	 * @returns this - The component
	 * 
	 * @example
	 * // Using only the animation name:
	 * myComponent.setAnimation("fadeIn").render().showAt(100,100)
	 * 
	 * // Using a config object:
	 * myComponent.setAnimation({
	 *      name: "tada",
	 *      speed: "fast",
	 *      repeat: "repeat-1",
	 *      callback: function() {
	 *          this.hide()
	 *      }
	 * })
	 * 
	 * // Remove the animation
	 * myComponent.setAnimation(false)
	 */
	setAnimation(config) {
		// Remove all animation classes
		if (config === false) {
			Array.from(this.classList).forEach(className => {
				if (className.startsWith("animate__")) this.classList.remove(className)
			})
			return this
		}

		// Add animation classes
		let animationName, animationSpeed, animationRepeat

		if (typeof config === "string") {
			animationName = "animate__" + config
			animationSpeed = "animate__fast"
			animationRepeat = "animate__repeat-1"
		} else {
			animationName = (config.name) ? "animate__" + config.name : "animate__fadeIn"
			animationSpeed = (config.speed) ? "animate__" + config.speed : "animate__fast"
			animationRepeat = (config.repeat) ? "animate__" + config.repeat : "animate__repeat-1"
			this.animationCallback = config.callback
		}

		// Wait for the next frame before adding animation classes
		setTimeout(() => this.classList.add("animate__animated", animationName, animationSpeed, animationRepeat), 10)

		this.handleAnimationEnd = function (event) {
			event.stopPropagation()
			this.classList.remove("animate__animated", animationName, animationSpeed, animationRepeat)
			this.removeEventListener("animationend", this.handleAnimationEnd)

			if (typeof this.animationCallback === "function") this.animationCallback()
		}

		this.addEventListener("animationend", this.handleAnimationEnd, {
			once: true
		})

		return this
	}
}

/**
 * Find the Component encapsulating a DOM Element.
 * This is useful when you need to access the Component from an inner Element composing the Component.
 * 
 * @note
 * All the Components have their classname beginning with "a-", like "a-panel", "a-field", "a-button"...
 * So, we're simply looking for "a-" in the classname of the ancestors' hierarchy.
 * 
 * @param {HTMLElement} element - The element which we want to get the outer Component
 * @returns {HTMLElement} The DOM element found, or null
 */
HTMLElement.prototype.getComponent = function () {
	/**
	 *
	 * @param node
	 */
	function getParent(node) {
		let parent = node.parentNode
		if (!parent) return null
		if (!parent.classList) return null
		if (parent.constructor.name == "HTMLDocument") return null
		return parent
	}

	let parentNode = this.parentNode
	while (getParent(parentNode)) {
		if (parentNode.classList.value != "") {
			if (parentNode.classList[0].slice(0, 2) == "a-") return parentNode
		}
		parentNode = parentNode.parentNode
	}

	return null
}

// Allow any HTMLElement to be processed in views like kiss Components
HTMLElement.prototype.render = kiss.ui.Component.prototype.render
HTMLElement.prototype._insertIntoDOM = kiss.ui.Component.prototype._insertIntoDOM
HTMLElement.prototype.show = kiss.ui.Component.prototype.show
HTMLElement.prototype.hide = kiss.ui.Component.prototype.hide
HTMLElement.prototype.showLoading = kiss.ui.Component.prototype.showLoading
HTMLElement.prototype.hideLoading = kiss.ui.Component.prototype.hideLoading
HTMLElement.prototype.attachTip = kiss.ui.Component.prototype.attachTip
HTMLElement.prototype.detachTip = kiss.ui.Component.prototype.detachTip
HTMLElement.prototype.setAnimation = kiss.ui.Component.prototype.setAnimation