/**
*
* 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
* - 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 a string, or an object like:
* <br>
* {value: "France"} or {label: "France", value: "FR"} or {label: "France", value: "FR", color: "#00aaee"}.
* @param {function} [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.label]
* @param {string} [config.fieldWidth]
* @param {string} [config.labelWidth]
* @param {string} [config.labelPosition] - left | right | top | bottom
* @param {string} [config.labelAlign] - left | right
* @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]
* @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.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
// 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 {
this.options = config.options || []
this.options = this.options.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()
}
})
}
// 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"],
[this.label?.style]
],
[
["fieldWidth=width", "minWidth"],
[this.optionsWrapper.style]
],
[
["maxHeight"],
[this.optionsList.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 = (event) => {
this.fieldInput.value = ""
this._hideOptions()
}
// 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 internally
*
* @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
}
/**
* 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() {
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
}
/**
* 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 == true) {
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
}
})
// Delete the content of the options wrapper
this.optionsList.deepDelete(false)
// Then re-generate the options
this._createOptions()
// 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.multiple = state
}
/**
* Allow/forbid to click on field values to delete them
*
* @param {boolean} state
*/
setClickToDelete(state = true) {
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()
}
this.fieldInput.onkeyup = (event) => {
event.stop()
let enteredValue = event.target.value
// 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)) {
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
let newValue = (event.key == "Enter") ? enteredValue : enteredValue.slice(0, enteredValue.length - 1)
if (newValue != "") {
if (!this.allowValuesNotInList) {
let checkedValue = this._findValue(newValue)
if (!checkedValue) {
this._resetInputField()
return
}
this.addValue(checkedValue)
} else {
this.addValue(newValue)
}
}
}
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
}
/**
* Create the list of options:
* - each option generates a div which is inserted into the "optionsWrapper" parent div
* - the first option is highlighted by default, so that the user can press ENTER to validate it, or navigate within the options
*
* Each option should be composed of:
* - a value
* - a label: optional, only when we want to display a different value than the "stored value"
* - a color: optional, will default to a neutral gray when selected
*
* By default, options are rendered as simple divs, but we can provide any other renderer using the 'optionRenderer' config
*
* @private
* @ignore
*/
async _createOptions() {
for (let index = 0, length = this.options.length; index < length; index++) {
let option = this.options[index]
let optionElement = document.createElement("div")
optionElement.className = "field-option"
optionElement.setAttribute("value", option.value)
optionElement.setAttribute("index", index)
if (option.label) optionElement.setAttribute("label", option.label)
if (option.color) optionElement.setAttribute("style", "border-color:" + 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
}
this.optionsList.append(optionElement)
}
// By default, every options are displayed
this.displayedOptions = Array.from(this.optionsList.children)
}
/**
* 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
*/
_showOptions(enteredValue) {
// Create the list of options when it's opened for the 1st time
if (this.optionsList.children.length == 0) this._createOptions()
// Show the options
this.optionsWrapper.style.position = "fixed"
setTimeout(() => {
this.optionsWrapper.style.display = "block"
if (this.fieldInput) {
this.fieldInput.placeholder = ""
this.fieldInput.focus()
}
this._filterOptions(enteredValue || "")
this._adjustSizeAndPosition()
}, 100)
this.optionsList.onmousewheel = (event) => {
event.preventDefault()
const direction = event.deltaY < 0 ? -1 : 1;
this.optionsList.scrollTop += direction * 50;
}
}
/**
* Re-compute the size and position of the options wrapper
*
* @private
* @ignore
*/
_adjustSizeAndPosition() {
if (kiss.screen.isMobile) {
this.optionsWrapper.style.top = "10px"
this.optionsWrapper.style.left = "10px"
this.optionsWrapper.style.width = "calc(100% - 20px)"
this.optionsWrapper.style.height = "calc(100% - 20px)"
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.config.maxHeight) {
this.optionsWrapper.style.maxHeight = Math.min(this.config.maxHeight, kiss.screen.current.height - 20) + "px"
this.optionsList.style.maxHeight = Math.min(this.config.maxHeight - 55, kiss.screen.current.height - 55) + "px"
}
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()
}
/**
* 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) {
this.displayedOptions = []
let searchExpression = enteredValue.toLowerCase()
// Check wether we have to search within the value OR the label
let propertyToSearch = (this.optionsList.firstChild && this.optionsList.firstChild.getAttribute("label")) ? "label" : "value"
Array.from(this.optionsList.children).forEach(option => {
// Remove styling
option.classList.remove("field-option-selected")
option.classList.remove("field-option-highlight")
// Hide options that doesn't match the entered value
if (!option.getAttribute(propertyToSearch).toLowerCase().includes(searchExpression)) {
option.classList.add("field-option-hidden")
return
}
// Show other options
option.classList.remove("field-option-hidden")
// Show the active values as *selected*
const optionValue = option.getAttribute("value")
if (this.value && ((this.multiple && this.value.includes(optionValue)) || (this.value == optionValue))) option.classList.add("field-option-selected")
// Keep track of all the remaining displayed options
this.displayedOptions.push(option)
})
// Highlight the 1st available option of the new filtered list.
// This allows to validate this option just by pressing <Enter> key
this.selectedOption = null
if (this.displayedOptions.length != 0) {
this.selectedOption = this.displayedOptions[0]
this._highlightOption(this.displayedOptions[0], true)
}
}
/**
* 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) {
let currentSelectedOption = this.selectedOption
let index = Array.from(this.displayedOptions).findIndex(node => node == currentSelectedOption)
let nextIndex = (direction == "down") ? Math.min((index + 1), this.displayedOptions.length) : Math.max((index - 1), 0)
let newSelectedOption = this.displayedOptions[nextIndex]
if (newSelectedOption) this._highlightOption(newSelectedOption, true)
}
/**
* 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) {
// Remove styling on previously selected option
if (this.selectedOption) this.selectedOption.classList.remove("field-option-highlight")
// Add styling to the newly selected option
selectedOption.classList.add("field-option-highlight")
// Store the new selected option and scroll to it
this.selectedOption = selectedOption
// TODO: should scroll, but creates strange behavior when encapsulated in iframe: fix that!
//if (scroll == true) this.selectedOption.scrollIntoView()
}
/**
* 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")
if (!optionIndex) return
const option = this.options[optionIndex]
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 value
*
* @private
* @ignore
* @param {string} valueToDelete
*/
_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)
;
Source