Source

client/ui/fields/color.js

/**
 * 
 * The color field allows to pick a color and display its hexa color code.
 * 
 * @param {object} config
 * @param {string} [config.palette] - Use "default" to use the default HTML5 color palette, otherwise it will use KissJS custom palette.
 * @param {string} [config.value] - Initial color value
 * @param {boolean} [config.hideCode] - Set to true to hide the hexa color code
 * @param {string} [config.display]
 * @param {number|string} [config.width] - A number (in pixels) or any valid CSS value
 * @param {*} [config.minWidth] - A number (in pixels) or any valid CSS value
 * @param {*} [config.labelWidth] - A number (in pixels) or any valid CSS value
 * @param {string} [config.labelFontSize] - Any valid CSS value
 * @param {string} [config.labelFontWeight] - Any valid CSS value
 * @param {string} [config.labelColor] - Any valid CSS value
 * @param {*} [config.height] - A number (in pixels) or any valid CSS value
 * @param {*} [config.fieldWidth] - A number (in pixels) or any valid CSS value
 * @param {*} [config.fieldHeight] - A number (in pixels) or any valid CSS value
 * @param {*} [config.colorWidth] - Color selector width
 * @param {*} [config.colorHeight] - Color selector height
 * @param {string} [config.padding]
 * @param {string} [config.margin]
 * @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 {boolean} [config.readOnly]
 * @param {object} [config.record] - The record to bind the field to. This will automatically update the record when the field value changes, and the field will listen to database changes on the record.
 * @returns this
 * 
 * ## Generated markup
 * ```
 * <a-color class="a-color">
 *  <label class="field-label"></label>
 *  <div class="field-color-container">
 *      <div class="field-color-palette"></div>
 *      <div class="field-color-input"></div>
 *  </div>
 * </a-color
 * ```
 */
kiss.ui.Color = class Color extends kiss.ui.Component {
	/**
	 * 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 myColorField = document.createElement("a-color").init(config)
	 * ```
	 * 
	 * Or use the shorthand for it:
	 * ```
	 * const myColorField = createColorField({
	 *  value: "#00aaee",
	 *  height: "32px"
	 * })
	 * 
	 * myColorField.render()
	 * myColorField.getValue() // #00aaee
	 * myColorField.setValue("#ff0000")
	 * myColorField.getValue() // #ff0000
	 * ```
	 * 
	 * Or directly declare the config inside a container component:
	 * ```
	 * const myPanel = createPanel({
	 *   title: "My panel",
	 *   items: [
	 *       {
	 *           type: "color",
	 *           value: "#00aaee",
	 *           width: "32px"
	 *           height: "32px",
	 *           hideCode: true
	 *       }
	 *   ]
	 * })
	 * myPanel.render()
	 * ```
	 */
	constructor() {
		super()
	}

	/**
	 *
	 * @param config
	 */
	init(config) {
		super.init(config)

		// Overwrite default value if the field is binded to a record
		// (default value must not override record's value)
		if (config.record && config.record[this.id]) config.value = config.record[this.id]

		const id = this.id
		this.value = config.value || ""
		this.required = !!config.required
		this.readOnly = !!config.readOnly

		// Template
		this.innerHTML = /*html*/ `
            ${ (config.label) ? `<label id="field-label-${this.id}" for="${this.id}" class="field-label">
                ${ (this.isLocked()) ? this.locker : "" }
                ${ config.label || "" }
                 ${ (this.isRequired()) ? this.asterisk : "" }
            </label>` : "" }

            <div class="field-color-container ${(config.palette != "default") ? " field-color-container-custom " : " field-color-container-standard "} ${(config.readOnly) ? "field-input-read-only" : ""}">
                ${(config.palette != "default")
		? `<div class="field-color-palette field-color-palette-custom" style="background-color: ${this.value.trim()}"></div>`
		: `<input class="field-color-palette field-color-palette-standard" type="color" value=${this.value}>`
}
                ${(!config.hideCode) ? `<input type="text" autocomplete="off" ${(this.readOnly) ? " readonly " : ""} class="field-color-input" value="${this.value}"></input>` : ""}
            </div>
            `.removeExtraSpaces()

		this.label = this.querySelector(".field-label")
		this.fieldContainer = this.querySelector(".field-color-container")
		this.color = this.querySelector(".field-color-palette")
		this.field = this.querySelector(".field-color-input")

		// The width and height of the color zone is adjusted if the color code is displayed or not
		if (config.hideCode == true) this.color.style.width = this.color.style.height = "100%"

		// Set properties
		this._setProperties(config, [
			[
				["draggable"],
				[this]
			],
			[
				["width", "height", "flex", "display", "margin", "padding"],
				[this.style]
			],
			[
				["fieldWidth=width", "fieldHeight=height", "fieldFlex=flex", "border", "borderStyle", "borderWidth", "borderColor", "borderRadius", "boxShadow"],
				[this.fieldContainer.style]
			],
			[
				["labelWidth=width", "labelAlign=textAlign", "labelFlex=flex", "labelFontSize=fontSize", "labelFontWeight=fontWeight", "labelColor=color"],
				[this.label?.style]
			],
			[
				["borderRadius", "colorWidth=minWidth", "colorHeight=minHeight"],
				[this.color.style]
			]
		])

		// Set the default display mode that will be restored by the show() method
		this.displayMode = "flex"

		// Manage label and field layout according to label position
		this.style.flexFlow = "row"

		if (config.label) {
			// Label width
			if (config.labelWidth) this.setLabelWidth(config.labelWidth)

			// Label position
			this.config.labelPosition = config.labelPosition || "left"
			this.setLabelPosition(config.labelPosition)
		}

		// Listen to click events if the field is *not* disabled or *readOnly* or *computed* or a default HTML5 palette (which listens to click by default)
		if (!this.readOnly && !config.disabled && !config.computed && config.palette != "default") {
			this.onclick = (event) => {
				const clickedElement = event.target.closest(".field-color-container")
				if (!clickedElement) return
                
				const picker = createPanel({
					modal: true,
					backdropFilter: true,
					header: false,
					width: "70.5rem",
					align: "center",
					verticalAlign: "center",
					zIndex: 1000,
					items: [{
						type: "colorPicker",
						value: $(id).getValue(),
						palette: config.palette || kiss.global.palette,
						selectorBorderRadius: "3.2rem",
						height: "10rem",
						events: {
							change: function () {
								let color = this.getValue()
								$(id).setValue(color)
								picker.close()
							}
						}
					}]
				}).render()
			}
		}

		// Update color when we directly enter a color code inside the input field
		if (this.field) {
			this.field.onchange = (event) => {
				let color = event.target.value
				$(id).setValue(color)
			}
		}
        
		if (config.palette == "default") {
			// Lock default behavior and prevent the palette from diplaying
			if (this.readOnly) this.color.onclick = (event) => {event.stop()}

			// Update color when we exit the default color palette
			this.color.onchange = (event) => {
				let color = event.target.value
				$(id).setValue(color)
			}
		}

		// Bind the field to a record, if any
		if (config.record) this._bindRecord(config.record)

		// Render default value
		this._renderValues()

		return this
	}

	/**
	 * Bind the field to a record
	 * (this subscribes the field to react to database changes)
	 * 
	 * @private
	 * @ignore
	 * @param {object} record
	 * @returns this
	 */
	_bindRecord(record) {
		this.record = record
		this.modelId = record.model.id
		this.recordId = record.id

		if (record[this.id]) {
			this.value = this.initialValue = record[this.id]
		}

		// React to changes on a single record of the binded model
		this.subscriptions.push(
			subscribe("EVT_DB_UPDATE:" + this.modelId.toUpperCase(), (msgData) => {
				if ((msgData.modelId == this.modelId) && (msgData.id == this.recordId)) {
					const updates = msgData.data
					this._updateField(updates)
				}
			})
		)

		// React to changes on multiple records of the binded Model
		this.subscriptions.push(
			subscribe("EVT_DB_UPDATE_BULK", (msgData) => {
				const operations = msgData.data
				operations.forEach(operation => {
					if ((operation.modelId == this.modelId) && (operation.recordId == this.recordId)) {
						const updates = operation.updates
						this._updateField(updates)
					}
				})
			})
		)

		return this
	}

	/**
	 * Updates the field value internally
	 * 
	 * @private
	 * @ignore
	 * @param {*} updates 
	 */
	_updateField(updates) {
		if (this.id in updates) {
			this.value = updates[this.id]
			this._renderValues()
		}
	}    

	/**
	 * Set the fieldl value
	 * 
	 * @param {string} color - Hex color like #aa00ee
	 * @param {boolean} [rawUpdate] - If true, it doesn't update the associated record and doesn't trigger "change" event 
	 * @returns this
	 */
	setValue(color, rawUpdate) {
		if (color == this.getValue()) return

		if (rawUpdate) return this._updateValue(color, rawUpdate)

		if (this.record) {
			// If the field is connected to a record, we update the database
			this.record.updateFieldDeep(this.id, color).then(success => {
				if (success) {
					this._updateValue(color)
					this.initialValue = color
				}
				else {
					// Rollback the initial value if the update failed (ACL)
					this._updateValue(this.initialValue)
				}
			})
		}
		else {
			this._updateValue(color)
		}

		this.validate()

		if (this.config.palette == "default") {
			this.color.value = color
		}

		return this
	}

	/**
	 * Clear the field value
	 * 
	 * @returns this
	 */
	clearValue() {
		this.setValue("")
		return this
	}      

	/**
	 * Update the field's value internally
	 * 
	 * @private
	 * @ignore
	 * @param {string} color
	 * @param {boolean} [rawUpdate]
	 * @returns this
	 */
	_updateValue(color, rawUpdate) {
		// const updateValue = (color) => {
		//     this.value = color
		//     this._renderValues()
		//     this.dispatchEvent(new Event("change"))
		// }

		this.value = color
		this._renderValues()
		if (!rawUpdate) this.dispatchEvent(new Event("change"))
		return this
	}

	/**
	 * Validate the field value against validation rules
	 * 
	 * @returns {boolean}
	 */
	validate() {
		if (this.isHidden()) return true
        
		const isValid = kiss.tools.validateValue(this.type, this.config, this.value)
		if (isValid) {
			this.setValid()
		}
		else {
			this.setInvalid()
		}
		return isValid
	}

	/**
	 * Remove the invalid style
	 * 
	 * @returns this
	 */
	setValid() {
		this.isValid = true
		this.fieldContainer.classList.remove("field-input-invalid")
		return this
	}

	/**
	 * Change the style when the field is invalid
	 * 
	 * @returns this
	 */
	setInvalid() {
		log("kiss.ui - field.setInvalid - Invalid value for the field: " + this.config.label, 4)

		this.isValid = false
		this.fieldContainer.classList.add("field-input-invalid")
		return this
	}

	/**
	 * Set the color
	 * 
	 * @private
	 * @ignore
	 * @param {string} color - Hex color
	 */
	_renderValues() {
		const color = this.value || ""
		if (this.field) this.field.value = color
		this.color.style.background = (color == "#TRANSPARENT") ? "transparent" : color
	}

	/**
	 * Get the field value
	 * 
	 * @returns {string} The field hex color value. Ex: #00aaee
	 */
	getValue() {
		return this.value
	}

	/**
	 * Set the field label
	 * 
	 * @param {string} newLabel
	 * @returns this
	 */
	setLabel(newLabel) {
		if (!this.label) return

		this.config.label = newLabel
		this.label.innerText = newLabel
		return this
	}

	/**
	 * Get the field label
	 * 
	 * @returns {string}
	 */
	getLabel() {
		return this?.label?.innerText || ""
	}

	/**
	 * Set the field width
	 * 
	 * @param {*} width
	 * @returns this
	 */
	setWidth(width) {
		this.config.width = width
		this.style.width = this._computeSize("width", width)
		return this
	}

	/**
	 * Set the color selector field width
	 * 
	 * @param {*} width
	 * @returns this
	 */
	setFieldWidth(width) {
		this.config.fieldWidth = width
		this.fieldContainer.style.width = this._computeSize("fieldWidth", width)
		return this
	}

	/**
	 * Set the label width
	 * 
	 * @param {*} width
	 * @returns this
	 */
	setLabelWidth(width) {
		this.config.labelWidth = width
		this.label.style.minWidth = this.label.style.maxWidth = this._computeSize("labelWidth", width)
		return this
	}

	/**
	 * Get the label position
	 * 
	 * @returns {string} "left" | "right" | "top"
	 */
	getLabelPosition() {
		return this.config.labelPosition
	}

	/**
	 * Set label position
	 * 
	 * @param {string} position - "left" (default) | "right" | "top" | "bottom"
	 * @returns this
	 */
	setLabelPosition(position) {
		this.config.labelPosition = position

		switch (position) {
		case "top":
			this.style.flexFlow = "column"
			this.fieldContainer.style.order = 1
			break
		case "bottom":
			this.style.flexFlow = "column"
			this.fieldContainer.style.order = -1
			break
		case "right":
			this.style.flexFlow = "row"
			this.fieldContainer.style.order = -1
			break
		default:
			this.style.flexFlow = "row"
			this.fieldContainer.style.order = 1
		}
		return this
	}
}

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

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