Source

client/ui/fields/select.js

/**
 * 
 * The Select derives from [Component](kiss.ui.Component.html).
 * 
 * It's a multi-purpose select field, also known as "dropdown" or "combobox".
 * It can have a single or multiple values.
 * When multiple values, the field value returns an Array.
 * 
 * ## Features:
 * - auto-generation of options with templates like "time" | "weekday" | "month"...
 * - possible to use labels, to display something different than the field values
 * - possible to declare labels/values with a pipe "|". Example: "France|FR"
 * - single or multiple values
 * - auto-complete
 * - possible to disable some options (they are hidden, but it could be easily extended to be visible, using a different class name)
 * - possible to update the list of options afterward
 * - keyboard navigation up and down within options
 * - selection with mouse or Enter
 * - can delete existing entries with Backspace
 * - can sort values asc or desc
 * - option to add values which are not in list
 * - option to prevent duplicates
 * - option to add entries using a separator, comma by default (useful for email-like inputs)
 * - option to have a custom renderer for options
 * - option to have a custom renderer for values
 * - option to delete values by clicking on them
 * - option to switch a value on/off by clicking on it in the dropdown list
 * - option to hide or show the input (search) field
 * - option to position the input (search) field after the values (default) or before
 * - option to display values stacked one on another
 * 
 * ## To do
 * - option to sort the list of options
 * - option to reorder the field values with drag & drop
 * - possibility to navigate the current values / delete them hitting the del key
 * - other templates like range, weekday, month
 * 
 * ## Usage
 * 
 * To define the list of options, you can use a simple array of strings:
 * ```
 * const listOfOptions = ["France", "Great Britain"]
 * ```
 * Or an array of objects:
 * ```
 * const listOfOptions = [
 *  {value: "France"},
 *  {value: "Great Britain"}
 * ]
 * ```
 * Or a function that returns the list of options:
 * ```
 * const listOfOptions = () => {
 *  let options = []
 *  for (let i = 0; i < 10; i++) options.push({label: "Option " + i, value: i})
 *  return options
 * }
 * ```
 * If you used a function to generate the options, you can also combine it with a filtering function:
 * ```
 * const optionsFilter = (optionItem) => optionItem.value > 5
 * 
 * createSelect({
 *  label: "Select field with generated options",
 *  multiple: true,
 *  options: listOfOptions,
 *  optionsFilter // Will keep only the option items which value is > 5
 * })
 * ```
 * You can use labels that are different from the values:
 * ```
 * const listOfOptions = [
 *  {value: "FR", label: "France"},
 *  {value: "GB", label: "Great Britain"}
 * ]
 * ```
 * You can also set a color per option:
 * ```
 * const listOfOptions = [
 *  {value: "FR", color: "#00aaee"},
 *  {value: "GB", color: "#a1ed00"}
 * ]
 * ```
 * You can disable some options:
 * ```
 * const listOfOptions = [
 *  {value: "FR", color: "#00aaee"},
 *  {value: "GB", color: "#a1ed00"},
 *  {value: "USA", color: "#ff0000", disabled: true}
 * ]
 * ```
 * You can select one or multiple values thanks to the "multiple" parameter:
 * ```
 * createSelect({
 *  label: "Countries",
 *  multiple: true,
 *  options: listOfOptions
 * })
 * ```
 * You can define a custom renderer to display the field values. The default renderer is a function like this:
 * ```
 * // Default renderer for field values
 * const valueRenderer = (option) =>
 *  `<div class="field-select-value" value="${option.value}" ${(option.color || this.optionsColor) ? `style="background: ${option.color || this.optionsColor}"` : ""}>
 *      ${option.label || option.value}
 *      ${(this.allowClickToDelete == true) ? `<span class="field-select-value-delete fas fa-times"></span>` : ""}
 *  </div>`
 * ```
 * You can define a custom renderer for each option of the list. The default renderer is a function like this:
 * ```
 * // Default renderer for the list of options
 * const optionRenderer = (option) => option.label || option.value
 * 
 * // It could be like:
 * const optionRenderer = (option) => "Label: " + option.label + " - Value: " + option.value
 * ```
 * 
 * @param {object} config
 * @param {object} config.template - "time" - TODO: other template like "range" | "weekday" | "month" | ...
 * @param {string[]|object[]|function} config.options - List of options or function that returns a list of options, where each option must be an object like:
 *  <br>
 *  {value: "France"} or {label: "France", value: "FR"} or {label: "France", value: "FR", color: "#00aaee"}.
 *  <br>
 *  <br>
 *  A shorthand for the options is to provide an array of strings, like:
 *  <br>
 *  ["France", "Great Britain"] or ["France|FR", "Great Britain|GB"] if you need to separate the label from the value.
 * 
 * @param {function} [config.optionsFilter] - When the options are defined by a function, you can provide a filtering function that will be executed at runtime to filter only a specific set of options, depending on the context
 * @param {boolean} [config.multiple] - True to enable multi-select
 * @param {string|string[]} [config.value] - Default value
 * @param {string} [config.optionsColor] - Default color for all options
 * @param {string} [config.valueSeparator] - Character used to display multiple values
 * @param {string} [config.inputSeparator] - Character used to input multiple values
 * @param {boolean} [config.stackValues] - True to render the values one on another
 * @param {boolean} [config.hideInput] - true (default) to automatically hide the input field after a completed search
 * @param {boolean} [config.allowValuesNotInList] - Allow to input a value which is not in the list of options
 * @param {boolean} [config.allowDuplicates] - Allow to input duplicate values. Default to false.
 * @param {boolean} [config.allowClickToDelete] - Add a "cross" icon over the values to delete them. Default to false.
 * @param {boolean} [config.allowSwitchOnOff] - Allow to click on a value to switch it on/off
 * @param {function} [config.optionRenderer] - Custom function to render each option in the list of options
 * @param {function} [config.valueRenderer] - Custom function to render the actual field values
 * @param {string} [config.fieldWidth]
 * @param {string} [config.label]
 * @param {string} [config.labelWidth]
 * @param {string} [config.labelPosition] - left | right | top | bottom
 * @param {string} [config.labelAlign] - left | right
 * @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 {boolean} [config.autocomplete] - Set "off" to disable
 * @param {boolean} [config.readOnly]
 * @param {boolean} [config.disabled]
 * @param {boolean} [config.required]
 * @param {function} [config.validationFunction] - Async function that must return true if the value is valid, false otherwise
 * @param {string} [config.margin]
 * @param {string} [config.padding]
 * @param {string} [config.display] - flex | inline flex
 * @param {string|number} [config.width]
 * @param {string|number} [config.height]
 * @param {string|number} [config.maxHeight] - Max height of the options list. Default 30rem.
 * @param {number|string} [config.optionHeight] - Fixed height in rem for each option (virtual scrolling). Default 3.2rem
 * @param {number} [config.optionsOverscan] - Number of extra options rendered above/below the viewport
 * @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
 * The field is a composition of various elements:
 * - a div for the field label
 * - a div that renders the existing values (as chips, by default)
 * - an input field, used to filter options
 * - a div to display and select the options
 * 
 * ```
 * <a-select class="a-select">
 *  <label class="field-label"></label>
 * 
 *  <div class="field-select">
 *      <span class="field-select-values"></span>
 *  </div>
 * 
 *  <div class="field-select-options">
 *      <span class="field-select-input"></span>
 * 
 *      <!-- For each option -->
 *      <div class="field-option"></div>
 *  </div>
 * 
 * </a-select>
 * ```
 */
kiss.ui.Select = class Select 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 mySelect = document.createElement("a-select").init(config)
	 * ```
	 * 
	 * Or use the shorthand for it:
	 * ```
	 * const mySelect = createSelect({
	 *   label: "Countries",
	 *   options: [
	 *       {value: "France"},
	 *       {value: "Great Britain"}
	 *   ]
	 * })
	 * 
	 * mySelect.render()
	 * ```
	 * 
	 * Or directly declare the config inside a container component:
	 * ```
	 * const myPanel = createPanel({
	 *   title: "My panel",
	 *   items: [
	 *       {
	 *           type: "select",
	 *           options: [
	 *               {value: "France"},
	 *               {value: "Great Britain"}
	 *           ]
	 *       }
	 *   ]
	 * })
	 * myPanel.render()
	 * ```
	 */
	constructor() {
		super()
	}

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

		// Setup all the field options
		const isMobile = kiss.screen.isMobile
		this.optionsColor = config.optionsColor
		this.readOnly = !!config.readOnly || !!config.computed
		this.disabled = !!config.disabled
		this.required = !!config.required
		this.placeholder = config.placeholder || ""
		this.autocomplete = (config.autocomplete != "off")
		this.hideInput = (config.hideInput !== false)
		this.multiple = !!config.multiple
		this.inputSeparator = config.inputSeparator || ","
		this.valueSeparator = config.valueSeparator || ","
		this.stackValues = !!config.stackValues
		this.maxHeight = config.maxHeight || "30rem"
		this.allowValuesNotInList = !!config.allowValuesNotInList
		this.allowDuplicates = !!config.allowDuplicates
		this.allowClickToDelete = !!config.allowClickToDelete && (this.readOnly !== true)
		this.allowSwitchOnOff = !!config.allowSwitchOnOff
		if (isMobile) this.allowSwitchOnOff = true
		this.displayedOptions = []
		this.selectedOption = null
		this.optionRenderer = config.optionRenderer || null
		this.valueRenderer = config.valueRenderer || null
		this.optionHeight = (config.optionHeight !== undefined && config.optionHeight !== null) ? config.optionHeight : 3.2
		this.optionsOverscan = (config.optionsOverscan && parseInt(config.optionsOverscan, 10)) || 5
		this._filteredOptions = []
		this._selectedFilteredIndex = null
		this._optionsHasLabel = false
		this._optionsListReady = false

		// 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]

		// De-reference the initial value to avoid side effects
		this.value = (Array.isArray(config.value)) ? config.value.map(val => val) : (config.value)

		// Cast value to Array if "multiple" option is enabled
		if (this.multiple && (!Array.isArray(this.value))) this.value = [].concat(this.value)

		// The list of options can vary depending on some pre-defined field templates
		switch (config.template) {
			case "time":
				// Special template for "Time" field
				this.options = this._generateTimes(config.min || 0, config.max || 24, config.interval || 60, true)
				break

			// case "gmt":
			// case "countries":
			// case "... other templates to come":

			default:
				// Other
				// Options can be passed as an array of strings, or an array of objects, or a function.
				if (config.options && typeof config.options == "function") {
					this.options = config.options(config.optionsFilter)
				} else {

					// Check if there are available translations
					let sourceOptions = config.options || []
					if (config.optionsTranslations && kiss.language.currentDynamic && config.optionsTranslations[kiss.language.currentDynamic]) {
						const translatedOptions = config.optionsTranslations[kiss.language.currentDynamic]
						const translatedOptionsById = {}
						const hasSourceOptionIds = sourceOptions.some(option => typeof option == "object" && !!option.id)
						const hasTranslationIds = Array.isArray(translatedOptions)
							? translatedOptions.some((translatedOption) => translatedOption && typeof translatedOption == "object" && !!translatedOption.id)
							: (!!translatedOptions && typeof translatedOptions == "object")
						const useLegacyIndexFallback = !hasSourceOptionIds && !hasTranslationIds

						if (!Array.isArray(translatedOptions) && translatedOptions && typeof translatedOptions == "object") {
							Object.keys(translatedOptions).forEach((optionId) => {
								const translatedOption = translatedOptions[optionId]
								translatedOptionsById[optionId] = (translatedOption && typeof translatedOption == "object") ? translatedOption.value : translatedOption
							})
						} else if (Array.isArray(translatedOptions)) {
							translatedOptions.forEach((translatedOption) => {
								if (translatedOption && translatedOption.id) translatedOptionsById[translatedOption.id] = translatedOption.value
							})
						}

						sourceOptions = sourceOptions.map((option, index) => {
							const translatedByIndex = Array.isArray(translatedOptions) ? translatedOptions[index] : null
							const translatedByIndexValue = useLegacyIndexFallback ? ((translatedByIndex && typeof translatedByIndex == "object") ? translatedByIndex.value : translatedByIndex) : undefined

							if (typeof option == "object") {
								const translatedById = (option.id && Object.prototype.hasOwnProperty.call(translatedOptionsById, option.id)) ? translatedOptionsById[option.id] : undefined
								const translatedValue = (translatedById !== undefined && translatedById !== null) ? translatedById : translatedByIndexValue
								if (translatedValue === undefined || translatedValue === null) return option

								let finalOption = {
									label: translatedValue,
									value: option.value
								}

								if (option.id) finalOption.id = option.id
								if (option.color) finalOption.color = option.color
								return finalOption
							} else {
								if (translatedByIndexValue === undefined || translatedByIndexValue === null) {
									return {
										value: option
									}
								}

								return {
									label: translatedByIndexValue,
									value: option
								}
							}
						})
					}

					this.options = sourceOptions.map(option => {
						if (typeof option == "object") return option
						return {
							value: option
						}
					})
				}

				// Options which value contains a pipe "|" auto-generate a label/value option
				this.options.forEach(option => {
					if (option.value && typeof option.value === "string" && option.value.includes("|")) {
						const optionConfig = option.value.split("|")
						option.label = optionConfig[0].trim()
						option.value = optionConfig[1].trim()
					}
				})
		}

		// Prepare internal data for fast filtering and virtualization
		this._prepareOptionsData()

		// Will keep track of the last value typed into the input field
		this.lastEnteredValue = ""

		// Template
		this.innerHTML =
			`${ (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-select ${(config.readOnly) ? "field-input-read-only" : ""}">
                <div class="field-select-values"></div>
            </div>
            
            <div class="field-select-options">
                ${(!isMobile) ? "" :
		`<span class="a-select-mobile-close-container">
                        <span class="a-select-mobile-close fas fa-chevron-left"></span>
                        <span class="field-label">${config.label}</span>
                    </span>`
}
                <input class="field-select-input" type="text" ${(this.autocomplete && !isMobile) ? "" : "style=\"display: none\""}>
                <div class="field-select-options-list"></div>
            </div>
            `.removeExtraSpaces()

		this.label = this.querySelector(".field-label")
		this.field = this.querySelector(".field-select")
		this.fieldValues = this.querySelector(".field-select-values")
		this.fieldInput = this.querySelector(".field-select-input")
		this.optionsWrapper = this.querySelector(".field-select-options")
		this.optionsList = this.querySelector(".field-select-options-list")

		// Set properties
		this._setProperties(config, [
			[
				["draggable"],
				[this]
			],
			[
				["width", "height", "display", "margin", "padding", "flex"],
				[this.style]
			],
			[
				["value", "maxLength", "min", "max", "placeholder", "readOnly", "disabled", "required"],
				[this.field]
			],
			[
				["fieldWidth=width", "minWidth", "fieldHeight=height", "fieldFlex=flex", "boxShadow"],
				[this.field.style]
			],
			[
				["fieldLabelWidth=width", "labelAlign=textAlign", "labelFlex=flex", "labelFontSize=fontSize", "labelFontWeight=fontWeight", "labelColor=color"],
				[this.label?.style]
			],
			[
				["fieldWidth=width", "minWidth"],
				[this.optionsWrapper.style]
			],
			[
				["placeholder"],
				[this.fieldInput]
			]
		])

		// 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 = "column"

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

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

		// Override mousedown event which can't be customized for a select field (too many edge cases)
		if (!this.readOnly && !this.disabled) {
			this.onmousedown = function (event) {
				event.stop()

				let classes = event.target.classList
				if (classes.contains("field-select-value-delete")) return this._deleteValueByClick(event)
				if (classes.contains("field-select-value")) return this._showOptions()
				if (classes.contains("field-select-values")) return this._showOptions()
				if (classes.contains("field-select-placeholder")) return this._showOptions()
				if (classes.contains("field-select")) return this._showOptions()
				if (classes.contains("field-option")) return this._selectOption(event)

				if (!isMobile) return
				const closeHeader = event.target.closest(".a-select-mobile-close-container")
				if (closeHeader) return this._hideOptions()
			}
		}

		// Add field base class
		this.classList.add("a-field")

		// Close option list when exiting the select field
		this.field.onmouseleave = (event) => {
			// We only close it if the mouse is outside the dropdown list
			if (!kiss.tools.isEventInElement(event, this.optionsWrapper, 10)) this._hideOptions()
		}

		// Close option list when leaving the dropdown area
		this.optionsWrapper.onmouseleave = (event) => {
			if (document.activeElement.classList.contains("field-select-input")) {
				if (this.fieldInput.value == "") {
					this._hideOptions()
				}
			} else {
				this._hideOptions()
			}
		}

		// Close option list when leaving the search field
		this.fieldInput.onblur = () => this._handleInputBlur()

		// Keyboard management
		if (this.autocomplete != "off") {
			this._manageKeyboard()
		}

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

		// Render default values
		this._renderValues()

		return this
	}

	/**
	 * Render the current value(s) of the widget.
	 * By default, values are rendered as "chips", but we can provide any other renderer using the 'valueRenderer' config.
	 * 
	 * @private
	 * @ignore
	 */
	_renderValues() {
		// Check if the field is empty
		let isEmpty = false

		if (this.multiple) {
			if (this.value && Array.isArray(this.value) && this.value.length == 0) isEmpty = true
		} else {
			if (this.value === undefined || this.value === "") isEmpty = true
		}

		if (isEmpty) {
			this.fieldValues.innerHTML = `<span class="field-select-placeholder">${this.placeholder}</span>`
			this._adjustSizeAndPosition()
			return
		}

		// Transform the array of options to a Map, which is faster to retrieve a keyed value
		const mapOptions = new Map(this.options.map(option => [option.value, option]))

		// Separate values by <br> if the option "stackValues" is true
		const htmlSeparator = (this.stackValues) ? "<br>" : ""

		this.fieldValues.innerHTML = []
			.concat(this.value)
			.filter(value => value !== "" && value !== undefined && value !== null)
			.map(value => {
				let option = mapOptions.get(value)

				// If the value is not part of the configurated options, we generate a default config
				if (!option) {
					option = {
						value,
						color: this.defaultColor
					}
				}

				// Render!
				if (!this.valueRenderer) {
					// Default renderer
					return `<div class="field-select-value" value="${option.value}" ${(option.color || this.optionsColor) ? `style="background: ${option.color || this.optionsColor}"` : ""}>
                            ${option.label || option.value}
                            ${(this.allowClickToDelete == true) ? "<span class=\"field-select-value-delete fas fa-times\"></span>" : ""}
                        </div>`.removeExtraSpaces()
				} else {
					// Custom renderer
					return this.valueRenderer(option, this.record)
				}
			})
			.join(htmlSeparator)

		// Adjust the size of the options wrapper depending on the field content
		this._adjustSizeAndPosition()
	}

	/**
	 * 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.multiple) ? [].concat(record[this.id]) : record[this.id]
			this.initialValue = this.value
		}

		// 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 when the record is updated
	 * (data binding)
	 * 
	 * @private
	 * @ignore
	 * @param {*} updates 
	 */
	_updateField(updates) {
		if (this.id in updates) {
			const newValue = updates[this.id]
			if (newValue || (newValue === 0) || (newValue === "")) {
				this.value = newValue
				this._renderValues()
			}
		}
	}

	/**
	 * Set the field value
	 * 
	 * @param {string|string[]} newValue - The new field value
	 * @param {boolean} [rawUpdate] - If true, it doesn't update the associated record and doesn't trigger "change" event 
	 * @returns this
	 */
	setValue(newValue, rawUpdate) {
		if (rawUpdate) return this._updateValue(newValue, rawUpdate)

		this._updateValue(newValue)

		if (this.record) {
			// If the field is connected to a record, we update the database
			this.record.updateFieldDeep(this.id, this.value).then(success => {

				// Rollback the initial value if the update failed (ACL)
				if (!success) {
					this._updateValue(this.initialValue)
				} else {
					this.initialValue = newValue
				}
			})
		}

		this.validate()
		return this
	}

	/**
	 * Update the field's value internally
	 * 
	 * @private
	 * @ignore
	 * @param {string|string[]} newValue
	 * @param {boolean} [rawUpdate]
	 * @returns this
	 */
	_updateValue(newValue, rawUpdate) {
		this.value = newValue

		// Cast value to Array if it's a multiple select
		if (this.multiple) {
			const values = [].concat([...newValue])
			this.value = values.filter(value => value != "" && value != undefined && value != null)
		}

		this._renderValues()

		if (!rawUpdate) this.dispatchEvent(new Event("change"))
		return this
	}

	/**
	 * Clear the field value
	 * 
	 * @returns this
	 */
	clearValue() {
		if (this.multiple) {
			this.setValue([])
		} else {
			this.setValue("")
		}
		return this
	}

	/**
	 * Add a value to the Select field. This method does a few things:
	 * - check for duplicate entries (and remove if not allowed)
	 * - check for multiple entries (and exit if not allowed)
	 * - update the field value
	 * - reset the input field then give it focus
	 * 
	 * @param {string} newValue - Value to add
	 */
	async addValue(newValue) {

		// Excludes HTML
		if (("" + newValue).containsHTML()) {
			return
		}

		let nextValue

		if (this.multiple) {
			nextValue = [].concat(this.value)

			// Check for duplicate entries
			// If it's a duplicate, we check the option "allowSwitchOnOff" to know if we switch off the value or just exit
			let switchOff = false
			if ((this.allowDuplicates != true) && (this._isDuplicate(newValue) == true)) {
				if (!this.allowSwitchOnOff) {
					this._resetInputField()
					return
				}
				switchOff = true
			}

			if (switchOff) {
				nextValue.remove(newValue)
			} else {
				nextValue.push(newValue)
			}
		} else {
			nextValue = newValue
		}

		this.setValue(nextValue)

		// Reset input field
		this._resetInputField()

		// Reset selected option
		this.selectedOption = null

		// Hide the list of options
		this._hideOptions()
	}

	/**
	 * Validate the field value against validation rules
	 * 
	 * @returns {boolean}
	 */
	validate() {
		if (this.isHidden()) return true

		this.setValid()

		// Exit if field is readOnly
		if (this.config.readOnly) return true

		// Required
		if (this.required && this.isEmpty()) this.setInvalid()
		return this.isValid
	}

	/**
	 * Remove the invalid style
	 * 
	 * @returns this
	 */
	setValid() {
		this.isValid = true
		this.field.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.field.classList.add("field-input-invalid")
		return this
	}

	/**
	 * Disable the button.
	 * 
	 * - The button is grayed out
	 * - The action will not be triggered when clicked
	 */
	disable() {
		this.disabledAction = this.onmousedown
		this.onmousedown = null
	}

	/**
	 * Enable the button.
	 * 
	 * - The button is not grayed out anymore
	 * - The action will be triggered when clicked
	 */
	enable() {
		if (!this.disabledAction) return
		this.onmousedown = this.disabledAction
	}

	/**
	 * Check if the field is empty
	 * 
	 * @returns {boolean}
	 */
	isEmpty() {
		if (this.multiple && this.getValue().length == 0) return true
		if (this.getValue() == "") return true
		return false
	}

	/**
	 * Sort values
	 * 
	 * @param {string} order - "asc" or "desc"
	 * @returns this
	 */
	sort(order = "asc") {
		if (order == "desc") this.value.sort().reverse()
		else this.value.sort()

		this._renderValues()
		return this
	}

	/**
	 * Sort values according to their corresponding label
	 * 
	 * @param {string} order - "asc" or "desc"
	 * @returns this
	 */
	sortByLabel(order = "asc") {
		const coef = (order == "asc") ? 1 : -1
		this.value.sort((a, b) => {
			const optionA = this.options.find(option => option.value == a)
			const optionB = this.options.find(option => option.value == b)
			if (optionA && optionA.label && optionB && optionB.label) return optionA.label.localeCompare(optionB.label) * coef
			return -1
		})

		this._renderValues()
		return this
	}

	/**
	 * Get the field value
	 * 
	 * @returns {string|string[]} - Returns an array of strings if the "multiple" option is true. Returns a string otherwise.
	 */
	getValue() {
		if (this.multiple) {
			const values = [].concat(this.value)
			return values.filter(value => value != "" && value != undefined && value != null)
		} else {
			const value = (Array.isArray(this.value)) ? this.value[0] : this.value
			return (value == undefined) ? "" : value
		}
	}

	/**
	 * Get the selected option(s)
	 * 
	 * @returns {object|object[]} A single option or an array of options if multiple values are selected
	 */
	getSelection() {
		const currentValue = this.getValue()
		if (Array.isArray(currentValue)) {
			return this.options.filter(option => currentValue.includes(option.value))
		} else {
			return this.options.find(option => option.value == currentValue)
		}
	}

	/**
	 * Reset the field value
	 * 
	 * @returns this
	 */
	resetValue() {
		this.value = (this.multiple) ? [] : ""
		this._renderValues()
		return this
	}

	/**
	 * Update the list of options
	 * 
	 * @param {array} newOptions - An array of object defining the field options
	 * @example
	 * mySelectField.updateOptions([
	 *      {
	 *          value: "firstName",
	 *          disabled: false,
	 *          color: "#00aaee"
	 *      },
	 *      {...}
	 * ])
	 */
	updateOptions(newOptions) {
		if (!newOptions) return
		this.options = newOptions

		// Options can be passed as an array of strings or an array of objects
		// We cast to an array of objects
		this.options = this.options.map(option => {
			if (typeof option == "object") return option
			return {
				value: option
			}
		})

		this._prepareOptionsData()

		// Delete the content of the options wrapper
		if (this.optionsList) this.optionsList.deepDelete(false)
		this._optionsListReady = false

		// If the dropdown is open, re-create and re-filter immediately
		if (this.optionsWrapper && this.optionsWrapper.style.display == "block") {
			this._createOptions()
			const currentFilter = (this.fieldInput && this.fieldInput.value) ? this.fieldInput.value : ""
			this._filterOptions(currentFilter)
		}

		// Update the field values according to the new configuration
		// + filters out the field values that are not anymore in the possible options
		let currentValues = this.getValue()

		if (Array.isArray(currentValues)) {
			let currentOptionValues = this.options.map(option => option.value)
			let newValue = currentValues.filter(value => currentOptionValues.indexOf(value) != -1)

			this.value = newValue
		}
		this._renderValues()
	}

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

	/**
	 * 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
	}

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

	/**
	 * Set the input field width
	 * 
	 * @param {*} width
	 * @returns this
	 */
	setFieldWidth(width) {
		this.config.fieldWidth = width
		this.field.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.field.style.order = 1
				break
			case "bottom":
				this.style.flexFlow = "column"
				this.field.style.order = -1
				break
			case "right":
				this.style.flexFlow = "row"
				this.field.style.order = -1
				break
			default:
				this.style.flexFlow = "row"
				this.field.style.order = 1
		}
		return this
	}

	/**
	 * Allow/forbid to select multiple values
	 * 
	 * @param {boolean} state 
	 */
	setMultiple(state = true) {
		this.config.multiple = state
		this.multiple = state
	}

	/**
	 * Allow/forbid to click on field values to delete them
	 * 
	 * @param {boolean} state 
	 */
	setClickToDelete(state = true) {
		this.config.allowClickToDelete = state
		this.allowClickToDelete = state
	}

	/**
	 * Give focus to the input field
	 * 
	 * @returns this
	 */
	focus() {
		this.fieldInput.focus()
		return this
	}

	/**
	 * Manage keyboard interactions:
	 * - arrow up/down to navigate within the select options
	 * - either ENTER or the "inputSeparator" key, to validate an option
	 * - backspace to delete a value
	 * 
	 * @private
	 * @ignore
	 */
	_manageKeyboard() {
		this.fieldInput.onkeydown = (event) => {
			if (event.key == "Enter") {
				event.preventDefault()
				// Trigger value add on keydown to avoid panel handlers stealing keyup
				this.fieldInput.onkeyup(event)
			}
		}

		this.fieldInput.onkeyup = (event) => {
			event.stop()

			let enteredValue = event.target.value
			const nonValueKeys = [
				"Shift", "Control", "Alt", "Meta",
				"CapsLock", "Tab", "Home", "End",
				"PageUp", "PageDown", "Escape"
			]

			// ARROW DOWN (navigate down the list of options)
			if (event.which == 40) {
				this._navigateOptions("down")
				return
			}

			// ARROW UP (navigate down the list of options)
			if (event.which == 38) {
				this._navigateOptions("up")
				return
			}

			// BACKSPACE (delete the last value)
			if (event.key == "Backspace") {
				if ((this.lastEnteredValue == "") && this.value && (this.value.length > 0)) {
					this.lastEnteredValue = enteredValue
					let lastValue = this.value[this.value.length - 1]
					this.deleteValue(lastValue)
					return
				}
			}

			// ENTER or the SEPARATOR character (comma by default) add the selected value
			if ((event.key == "Enter") || (event.key == this.inputSeparator)) {
				let newValue = (event.key == "Enter") ? enteredValue : enteredValue.slice(0, enteredValue.length - 1)

				// If values not in list are allowed, prioritize raw input when it doesn't match any option
				if (this.allowValuesNotInList && newValue != "") {
					const checkedValue = this._findValue(newValue)
					if (!checkedValue) {
						this.addValue(newValue)
						return
					}
				}

				if (this.selectedOption != null) {
					// An option was selected in the list
					this._addValueFromOption(this.selectedOption)
				} else {
					// No options was selected, we use the input field
					if (newValue != "") {
						if (!this.allowValuesNotInList) {
							let checkedValue = this._findValue(newValue)
							if (!checkedValue) {
								this._resetInputField()
								return
							}
							this.addValue(checkedValue)
						} else {
							this.addValue(newValue)
						}
					}
				}
				return
			}

			// Ignore keyups that do not alter the input value (selection/navigation shortcuts)
			if (enteredValue == this.lastEnteredValue && (nonValueKeys.includes(event.key) || event.ctrlKey || event.altKey || event.metaKey)) {
				return
			}

			this._showOptions(enteredValue)
			this.lastEnteredValue = enteredValue
		}

		// Prevent from jumping to the beginning of the input field when hitting "up" key
		/*
        this.field.onkeydown = (event) => {
            if (event.which == 38) {
                event.stop()
                return
            }
        }*/
	}

	/**
	 * Checks if a value exists within the options.
	 * Search is case insensitive.
	 * Returns the found option or null if not found.
	 * 
	 * @private
	 * @ignore
	 * @param {string} value - Value to search within the values
	 * @returns {boolean}
	 */
	_findValue(value) {
		let option = this.options.find(option => option.value.toLowerCase() == value.toLowerCase())
		return (option) ? option.value : null
	}

	/**
	 * Prepare options for fast filtering and virtualization.
	 * 
	 * @private
	 * @ignore
	 */
	_prepareOptionsData() {
		if (!this.options) this.options = []
		this._optionsHasLabel = this.options.some(option => option && option.label !== undefined)

		this.options.forEach((option, index) => {
			if (!option || typeof option != "object") return
			option.__index = index
			const labelValue = (option.label !== undefined && option.label !== null) ? String(option.label) : ""
			const rawValue = (option.value !== undefined && option.value !== null) ? String(option.value) : ""
			option.__searchLabel = labelValue.toLowerCase()
			option.__searchValue = rawValue.toLowerCase()
		})
	}

	/**
	 * Create the list of options using virtual scrolling.
	 * 
	 * @private
	 * @ignore
	 */
	_createOptions() {
		this.optionsList.deepDelete(false)

		this._optionsSpacer = document.createElement("div")
		this._optionsSpacer.className = "field-select-options-spacer"
		this._optionsSpacer.style.height = "0px"

		this._optionsContainer = document.createElement("div")
		this._optionsContainer.className = "field-select-options-container"
		this._optionsContainer.style.position = "absolute"
		this._optionsContainer.style.top = "0"
		this._optionsContainer.style.left = "0"
		this._optionsContainer.style.right = "0"

		this.optionsList.style.position = "relative"
		this.optionsList.append(this._optionsSpacer)
		this.optionsList.append(this._optionsContainer)
		this._optionsListReady = true

		this._filteredOptions = this.options.filter(option => !(option && option.disabled === true))
		this._selectedFilteredIndex = null
		this._renderVirtualOptions()
		
		this.optionsList.onscroll = () => {
			this._renderVirtualOptions()
		}
	}

	/**
	 * Render only the visible options based on the scroll position.
	 * 
	 * @private
	 * @ignore
	 */
	_renderVirtualOptions() {
		if (!this._optionsContainer || !this._optionsSpacer) return

		const optionHeightPx = this._getOptionHeightPx()
		const totalOptions = this._filteredOptions.length
		this._optionsSpacer.style.height = (totalOptions * optionHeightPx) + "px"

		const viewportHeight = this.optionsList.clientHeight || 0
		if (viewportHeight <= 0) return

		const scrollTop = this.optionsList.scrollTop
		const visibleCount = Math.ceil(viewportHeight / optionHeightPx)
		let startIndex = Math.floor(scrollTop / optionHeightPx) - this.optionsOverscan
		if (startIndex < 0) startIndex = 0
		let endIndex = Math.min(startIndex + visibleCount + (this.optionsOverscan * 2), totalOptions)

		if (totalOptions > 0 && this._selectedFilteredIndex === null) {
			this._selectedFilteredIndex = startIndex
		}
		if (this._selectedFilteredIndex !== null && (this._selectedFilteredIndex < startIndex || this._selectedFilteredIndex >= endIndex)) {
			this._selectedFilteredIndex = startIndex
		}

		this._optionsContainer.deepDelete(false)

		const fragment = document.createDocumentFragment()
		this.displayedOptions = []
		this.selectedOption = null

		for (let index = startIndex; index < endIndex; index++) {
			const option = this._filteredOptions[index]
			if (!option) continue

			const optionElement = document.createElement("div")
			optionElement.className = "field-option"
			optionElement.setAttribute("value", option.value)
			optionElement.setAttribute("index", option.__index)
			if (option.label) optionElement.setAttribute("label", option.label)

			optionElement.style.position = "absolute"
			optionElement.style.top = (index * optionHeightPx) + "px"
			optionElement.style.left = "0"
			optionElement.style.right = "0"
			optionElement.style.height = optionHeightPx + "px"
			optionElement.style.boxSizing = "border-box"

			if (option.color) optionElement.style.borderColor = option.color

			// Hide disabled options
			if (option.disabled == true) optionElement.classList.add("field-option-disabled")

			// Render the option with the default renderer or a custom one
			if (this.optionRenderer) {
				optionElement.innerHTML = this.optionRenderer(option)
			} else {
				optionElement.textContent = option.label || option.value
			}

			// Show the active values as *selected*
			const optionValue = option.value
			if (this.value && ((this.multiple && this.value.includes(optionValue)) || (this.value == optionValue))) {
				optionElement.classList.add("field-option-selected")
			}

			// Highlight the selected option
			if (this._selectedFilteredIndex !== null && index === this._selectedFilteredIndex) {
				optionElement.classList.add("field-option-highlight")
				this.selectedOption = optionElement
			}

			fragment.append(optionElement)
		}

		this._optionsContainer.append(fragment)
		this.displayedOptions = Array.from(this._optionsContainer.children)
	}

	/**
	 * Ensure the selected option is visible.
	 * 
	 * @private
	 * @ignore
	 * @param {number} index
	 */
	_scrollToIndex(index) {
		const optionHeightPx = this._getOptionHeightPx()
		const itemTop = index * optionHeightPx
		const itemBottom = itemTop + optionHeightPx
		const viewTop = this.optionsList.scrollTop
		const viewBottom = viewTop + this.optionsList.clientHeight

		if (itemTop < viewTop) {
			this.optionsList.scrollTop = itemTop
		} else if (itemBottom > viewBottom) {
			this.optionsList.scrollTop = itemBottom - this.optionsList.clientHeight
		}
	}

	/**
	 * Convert the option height config (rem or number) to px.
	 * 
	 * @private
	 * @ignore
	 * @returns {number}
	 */
	_getOptionHeightPx() {
		let optionHeight = this.optionHeight
		if (typeof optionHeight == "string") {
			const parsed = parseFloat(optionHeight.replace("rem", "").trim())
			optionHeight = Number.isNaN(parsed) ? 3.2 : parsed
		}
		const rootFontSize = parseFloat(getComputedStyle(document.documentElement).fontSize) || 10
		return optionHeight * rootFontSize
	}

	/**
	 * Show the list of options and filter them
	 * - the list of options is created the first time this method is called
	 * - subsequent calls just change the visibility of the list of options
	 * - displaying the list of options also give focus to the input field so the user can start filtering the options (or navigate withing them)
	 * - showing the list also trigger a re-filtering according to the last entered value, if any
	 * 
	 * @private
	 * @ignore
	 * @param {string} enteredValue 
	 */
	async _showOptions(enteredValue) {
		const shouldShowLoading = (
			this.config?.showLoadingOnOpen === true ||
			this.type == "selectViewColumn"
		)
		const loadingId = shouldShowLoading ? kiss.loadingSpinner.show() : null
		try {
			if (this.optionsWrapper && this.optionsWrapper.style.display == "block") {
				this._filterOptions(enteredValue || "")
				return
			}

			// Let the browser paint the spinner before heavy work.
			await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))

			// Create the list of options when it's opened for the 1st time
			if (!this._optionsListReady) await this._createOptions()

			// Show the options
			this.optionsWrapper.style.position = "fixed"
			this.optionsWrapper.style.display = "block"

			if (this.fieldInput) {
				this.fieldInput.placeholder = ""
				if (this.preloadData === false) {
					this.fieldInput.placeholder = txtTitleCase("search") + "..."
				}
				this.fieldInput.focus()
			}

			this._adjustSizeAndPosition()
			this._filterOptions(enteredValue || "")

			this.optionsList.onmousewheel = (event) => {
				event.preventDefault()
				const direction = event.deltaY < 0 ? -1 : 1
				this.optionsList.scrollTop += direction * 50
			}
		} finally {
			if (loadingId) kiss.loadingSpinner.hide(loadingId)
		}
	}

	/**
	 * Re-compute the size and position of the options wrapper
	 * 
	 * @private
	 * @ignore
	 */
	_adjustSizeAndPosition() {
		if (kiss.screen.isMobile) {
			this.optionsWrapper.style.top = "1rem"
			this.optionsWrapper.style.left = "1rem"
			this.optionsWrapper.style.width = "calc(100% - 2rem)"
			this.optionsWrapper.style.height = "calc(100% - 2rem)"
			return
		}

		// Align top to search field if it's visible. Otherwise align to widget
		this.optionsWrapper.style.top = (this.field.getBoundingClientRect().top + this.field.clientHeight) + 4 + "px"
		this.optionsWrapper.style.left = this.field.getBoundingClientRect().left + "px"
		this.optionsWrapper.style.width = this.field.getBoundingClientRect().width + "px"

		// Adjust max height
		if (this.maxHeight) {
			const hasUnit = (typeof this.maxHeight == "string") && /[a-z%]/i.test(this.maxHeight)
			const cssMaxHeight = hasUnit ? this.maxHeight : `${this.maxHeight}px`
			this.optionsWrapper.style.maxHeight = `min(${cssMaxHeight}, calc(100vh - 20px))`
			this.optionsList.style.maxHeight = `min(calc(${cssMaxHeight} - 55px), calc(100vh - 55px))`
		} else {
			this.optionsWrapper.style.maxHeight = kiss.screen.current.height - 20 + "px"
			this.optionsList.style.maxHeight = kiss.screen.current.height - 55 + "px"
		}

		// Ensure the dropdown is 100% visible inside the viewport
		kiss.tools.moveToViewport(this.optionsWrapper)
	}

	/**
	 * Hide the list of options
	 * 
	 * @private
	 * @ignore
	 */
	_hideOptions() {
		this.optionsWrapper.style.display = "none"
	}

	/**
	 * Reset the input field
	 * 
	 * @private
	 * @ignore
	 */
	_resetInputField() {
		this.fieldInput.value = ""
		this.fieldInput.focus()
	}

	/**
	 * Handle input blur without clearing while focus stays inside the component.
	 * 
	 * @private
	 * @ignore
	 */
	_handleInputBlur() {
		setTimeout(() => {
			const activeElement = document.activeElement
			if (activeElement && (activeElement === this.fieldInput || this.contains(activeElement))) return
			this.fieldInput.value = ""
			this._hideOptions()
		}, 0)
	}

	/**
	 * Filter the list of options according to the value entered in the input field of the widget.
	 * Note: if options have labels, we search within the labels, otherwise, we search within the values
	 * 
	 * @private
	 * @ignore
	 * @param {string} enteredValue
	 */
	_filterOptions(enteredValue) {
		const searchExpression = (enteredValue || "").toLowerCase()
		const useLabel = this._optionsHasLabel

		if (!searchExpression) {
			this._filteredOptions = this.options.filter(option => !(option && option.disabled === true))
		} else {
			this._filteredOptions = this.options.filter(option => {
				if (!option) return false
				if (option.disabled === true) return false
				const searchValue = useLabel ? option.__searchLabel : option.__searchValue
				return searchValue && searchValue.includes(searchExpression)
			})
		}

		this.optionsList.scrollTop = 0
		this._selectedFilteredIndex = (this._filteredOptions.length > 0) ? 0 : null
		this._renderVirtualOptions()
	}

	/**
	 * Select the next option (above or below) in the list of options
	 * 
	 * @private
	 * @ignore
	 * @param {string} direction - "up" or "down": tells in which direction the user navigated the list
	 */
	_navigateOptions(direction) {
		if (!this._filteredOptions || this._filteredOptions.length == 0) return

		let currentIndex = (this._selectedFilteredIndex === null) ? 0 : this._selectedFilteredIndex
		let nextIndex = (direction == "down") ?
			Math.min((currentIndex + 1), this._filteredOptions.length - 1) :
			Math.max((currentIndex - 1), 0)

		this._selectedFilteredIndex = nextIndex
		this._scrollToIndex(nextIndex)
		this._renderVirtualOptions()
	}

	/**
	 * Highlight an option in the list of options
	 * 
	 * @private
	 * @ignore
	 * @param {HTMLElement} selectedOption - Node representing the selected option
	 * @param {boolean} scroll - true to scroll to the option
	 */
	_highlightOption(selectedOption, scroll) {
		if (typeof selectedOption == "number") {
			this._selectedFilteredIndex = selectedOption
		} else if (selectedOption && selectedOption.getAttribute) {
			const optionIndex = selectedOption.getAttribute("index")
			const filteredIndex = this._filteredOptions.findIndex(option => String(option.__index) === String(optionIndex))
			if (filteredIndex != -1) this._selectedFilteredIndex = filteredIndex
		}

		if (this._selectedFilteredIndex !== null && scroll) {
			this._scrollToIndex(this._selectedFilteredIndex)
		}

		this._renderVirtualOptions()
	}

	/**
	 * Select an option of the list when the user click on it
	 * 
	 * @private
	 * @ignore
	 * @param {Event} event - Click event
	 * @param {boolean} scroll - true to scroll to the option
	 */
	_selectOption(event, scroll) {
		let newSelectedOption = event.target.closest("div")
		//this._highlightOption(newSelectedOption, scroll)
		this.selectedOption = newSelectedOption
		this._addValueFromOption(newSelectedOption)
	}

	/**
	 * Add a value to the field when the user clicked an option from the list
	 * 
	 * @private
	 * @ignore
	 * @param {HTMLElement} selectedOption - Clicked node
	 */
	_addValueFromOption(selectedOption) {
		const optionIndex = selectedOption.getAttribute("index")
		let option = null

		if (optionIndex !== null) {
			option = this.options[optionIndex]
		}

		// Fallback when index isn't available (eg. options loaded after init)
		if (!option) {
			const optionValue = selectedOption.getAttribute("value")
			if (optionValue !== null && optionValue !== undefined) {
				option = this.options.find(opt => String(opt.value) === String(optionValue))
			}
		}

		if (!option) return
		this.addValue(option.value)
	}

	/**
	 * Check if a value is a duplicate of an existing value
	 * 
	 * @private
	 * @ignore
	 * @param {object} newValue 
	 * @returns {boolean}
	 */
	_isDuplicate(newValue) {
		return this.value.includes(newValue)
	}

	/**
	 * Delete a field value if the user clicked on the "cross" icon.
	 * The methods also hides the list of options automatically.
	 * 
	 * @private
	 * @ignore
	 * @param {Event} event - The click event that triggered the deletion
	 */
	_deleteValueByClick(event) {
		this._hideOptions()

		let fieldValueElement = event.target.closest("div")
		let clickedValue = fieldValueElement.getAttribute("value")
		this.deleteValue(clickedValue)

		event.stop()
	}

	/**
	 * Delete a given value
	 * 
	 * @param {string} valueToDelete - Value to delete
	 */
	deleteValue(valueToDelete) {
		let newValue

		if (Array.isArray(this.value)) {
			newValue = this.value.filter(value => value.replace(/\s+/g, " ") != valueToDelete)
		} else newValue = ""

		this.setValue(newValue)
	}

	/**
	 * Generate a list of times with daytime colors, for Time fields
	 * (ex: blue = afternoon, dark blue = night...)
	 * 
	 * @private
	 * @ignore
	 * @param {number} from - Start time, as decimal. 16 means 16:00, 16.5 means 16:30, 16.25 means 16:15, 16.75 means 16:45
	 * @param {number} to - End time, as decimal.
	 * @param {number} step - Step in minutes (default = 60)
	 * @param {boolean} colored 
	 * @returns {*} Array of times directly usable as options for a <Select> field
	 * 
	 * @example
	 * this._generateTimes(16, 18, 30) // => ['16:00', '16:30', '17:00', '17:30', '18:00']
	 * this_.generateTimes(16.5, 18.5, 30) // => ['16:30', '17:00', '17:30', '18:00', '18:30']
	 * this_.generateTimes(16.25, 16.75, 5) // => ['16:15', '16:20', '16:25', '16:30', '16:35', '16:40', '16:45']
	 * 
	 * // Step is not necessary a multiple of 60:
	 * this_.generateTimes(12, 14, 13) // => ['12:00', '12:13', '12:26', '12:39', '12:52', '13:05', '13:18', '13:31', '13:44', '13:57']
	 * 
	 * // When the colored parameter is true, the time is given with a color to illustrate day light:
	 * this_.generateTimes(10, 16, 60, true)
	 * 
	 * // Returns...
	 * [
	 *   {
	 *       "value": "10:00",
	 *       "color": "#0075FF"
	 *   },
	 *   {
	 *       "value": "11:00",
	 *       "color": "#0075FF"
	 *   },
	 *   {
	 *       "value": "12:00",
	 *       "color": "#FFAA00"
	 *   },
	 *   {
	 *       "value": "13:00",
	 *       "color": "#FFAA00"
	 *   },
	 *   {
	 *       "value": "14:00",
	 *       "color": "#87BFFF"
	 *   },
	 *   {
	 *       "value": "15:00",
	 *       "color": "#87BFFF"
	 *   },
	 *   {
	 *       "value": "16:00",
	 *       "color": "#87BFFF"
	 *   }
	 * ]
	 */
	_generateTimes(from = 0, to = 24, step = 60, colored) {

		// Add some basic controls
		from = (from < 0 || from > 23) ? 0 : from
		to = (to <= 0 || to > 24) ? 24 : to
		step = (step <= 0 || step > 60) ? 60 : step
		if (to <= from) {
			from = 0
			to = 24
			step = 60
		}

		const colors = {
			night: "#555555",
			morning: "#0075FF",
			midday: "#FFAA00",
			afternoon: "#87BFFF",
			dawn: "#8833EE"
		}

		const times = []
		for (let h = from * 60; h <= to * 60 && h < 1440; h += step) {
			let value
			const hourValue = Math.floor(h / 60)
			const hour = ("0" + hourValue).slice(-2)
			const minutes = ("0" + Math.round(h % 60)).slice(-2)

			if (colored) {
				let color
				if (hourValue < 5) color = colors.night
				else if (hourValue < 12) color = colors.morning
				else if (hourValue < 14) color = colors.midday
				else if (hourValue < 18) color = colors.afternoon
				else if (hourValue < 20) color = colors.dawn
				else color = colors.night

				value = {
					value: hour + ":" + minutes,
					color
				}
			} else {
				value = hour + ":" + minutes
			}

			times.push(value)
		}
		return times
	}
}

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

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