/**
*
* 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 also does a few things for us:
* - id generation (all KissJS components must have ids)
* - 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 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} [config.target] - DOM target insertion point
* @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_CONTAINER_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)
}
}
})
)
}
}
return this
}
/**
* Observe the connected-ness of the component and trigger the configured callback
*
* @ignore
*/
connectedCallback() {
if (this._afterConnected) this._afterConnected()
// TODO: before deploying on-the-fly translation: handle external values merged into the translated strings
// this.translate()
}
disconnectedCallback() {
if (this.loadingId) this.hideLoading() // Try to hide the loading mask (if any)
if (this._afterDisconnected) this._afterDisconnected()
}
/**
* Translate the localized elements of a Component on the fly
* TODO: handle external values merged into the translated strings
*/
translate() {
Array.from(this.querySelectorAll(".translation")).forEach(element => {
const textKey = kiss.language.hash[element.id]
const newText = kiss.language.translate(textKey)
element.innerHTML = newText
})
}
/**
* 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) 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()
}
/**
* Insert the component at a specfic DOM location
*
* @private
* @ignore
* @param {string} [target]
*/
_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") {
if (index) {
$(domTarget).insertBefore(this, $(domTarget).children[index])
} else {
$(domTarget).appendChild(this)
}
} else {
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)
}
}
/**
* 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
}
/**
* 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} config.spinnerSize - Size of the spinning symbol, in pixels
* @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)"
// Create the loading spinner
const spinner = document.createElement("div")
spinner.classList.add("component-loader")
spinner.id = "spinner-" + this.id
spinner.style.width = (config.spinnerSize || 32) + "px"
spinner.style.height = (config.spinnerSize || 32) + "px"
// 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
}
/**
* 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)
* @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"
].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
}
/**
* 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)
* @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
}
/**
* 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
}
/**
* 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.isLocked())
}
/**
* 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 () {
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
;
Source