app/components/satis/dropdown/component_controller.js in satis-1.0.75 vs app/components/satis/dropdown/component_controller.js in satis-2.0.7
- old
+ new
@@ -1,134 +1,189 @@
-import ApplicationController from "../../../../frontend/controllers/application_controller"
+import ApplicationController from "satis/controllers/application_controller"
-// FIXME: Is this full path really needed?
-import { debounce, popperSameWidth } from "../../../../frontend/utils"
+import { debounce, popperSameWidth } from "satis/utils"
import { createPopper } from "@popperjs/core"
-export default class extends ApplicationController {
- static targets = ["results", "items", "item", "searchInput", "resetButton", "toggleButton", "hiddenInput"]
+export default class DropdownComponentController extends ApplicationController {
+
+ static targets = [
+ "results",
+ "items",
+ "item",
+ "searchInput",
+ "resetButton",
+ "toggleButton",
+ "hiddenSelect",
+ "pills",
+ "pillTemplate",
+ "pill",
+ "selectedItemsTemplate"
+ ]
+
static values = {
chainTo: String,
freeText: Boolean,
needsExactMatch: Boolean,
pageSize: Number,
url: String,
urlParams: Object,
+ isMultiple: Boolean
}
connect() {
super.connect()
-
this.debouncedFetchResults = debounce(this.fetchResults.bind(this), 250)
this.debouncedLocalResults = debounce(this.localResults.bind(this), 250)
this.selectedIndex = -1
this.boundClickedOutside = this.clickedOutside.bind(this)
this.boundResetSearchInput = this.resetSearchInput.bind(this)
- this.boundHandleHiddenInputChange = this.handleHiddenInputChange.bind(this)
- this.boundBlur = this.handleBlur.bind(this)
+ this.boundBlur = this.blur.bind(this)
+ this.boundChainToChanged = this.chainToChanged.bind(this)
// To remember what the current page and last page were, we queried
this.currentPage = 1
this.lastPage = null
this.endPage = null
// To remember what the last search was we did
+ this.searchQueryValue = null
this.lastSearch = null
+ this.minSearchQueryLength = 2
- this.display()
+ // To remember what the last options were we got from the server to prevent unnecessary refreshes
+ // and unexpected events
+ this.lastServerRefreshOptions = new Set()
this.popperInstance = createPopper(this.element, this.resultsTarget, {
- offset: [-20, 2],
placement: "bottom-start",
+ strategy: "fixed",
modifiers: [
+ { name: "offset", options: { offset: [0, 1] } },
{
name: "flip",
- enabled: true,
options: {
- boundary: this.element.closest(".satis-card"),
+ fallbackPlacements: ["bottom"],
+ boundary: this.element.closest(".sts-card"),
},
},
{
name: "preventOverflow",
- enabled: true,
+ options: {
+ boundary: this.element.closest(".sts-card"),
+ },
},
popperSameWidth,
],
})
+ this.popperInstance.state.elements.popper.popperInstance = () => this.popperInstance
+ if (this.hasToggleButtonTarget) this.toggleButtonTarget.addEventListener("blur", this.boundBlur)
this.searchInputTarget.addEventListener("blur", this.boundBlur)
- this.toggleButtonTarget.addEventListener("blur", this.boundBlur)
this.resultsTarget.addEventListener("blur", this.boundBlur)
window.addEventListener("click", this.boundClickedOutside)
- this.hiddenInputTarget.addEventListener("change", this.boundHandleHiddenInputChange)
+ setTimeout(() => {
+ this.getScrollParent(this.element)?.addEventListener("scroll", this.boundBlur)
+ }, 500)
+
+ if (this.chainToValue) {
+ this.getChainToElement()?.addEventListener("change", this.boundChainToChanged)
+ }
+
+ if (this.hiddenSelectTarget.selectedOptions.length > 0 && this.hiddenSelectTarget.selectedOptions[0].value) {
+ this.refreshSelectionFromServer().then((changed) => {
+ this.filterResultsChainTo()
+ this.setHiddenSelect()
+
+ if (!this.hiddenSelectTarget.getAttribute("data-reflex")) {
+ let event = new Event("change")
+ event.detail = { src: "satis-dropdown" }
+ this.hiddenSelectTarget.dispatchEvent(event)
+ }
+ })
+ }
}
+ getScrollParent(node) {
+ if (node == null) {
+ return null
+ }
+
+ let isScrollable = false
+
+ if (node instanceof Element) {
+ const vScrollValue = window.getComputedStyle(node).getPropertyValue("overflow-y")
+
+ isScrollable = vScrollValue == "auto" || vScrollValue == "scroll"
+ }
+
+ if (isScrollable) {
+ return node
+ } else {
+ return node.parentNode == null ? node : this.getScrollParent(node.parentNode)
+ }
+ }
+
+ chainToChanged(event) {
+ // Ignore if we triggered this change event
+ if (event?.detail?.src == "satis-dropdown") {
+ return
+ }
+
+ this.reset(event)
+ }
+
disconnect() {
this.debouncedFetchResults = null
this.debouncedLocalResults = null
window.removeEventListener("click", this.boundClickedOutside)
+ this.getChainToElement()?.removeEventListener("change", this.boundChainToChanged)
+ if (this.hasToggleButtonTarget) this.toggleButtonTarget.removeEventListener("blur", this.boundBlur)
+ this.resultsTarget.removeEventListener("blur", this.boundBlur)
+ this.searchInputTarget.removeEventListener("blur", this.boundBlur)
}
focus(event) {
this.searchInputTarget.focus()
}
blur(event) {
- this.handleBlur(event)
- }
-
- handleBlur(event) {
- if (!this.element.contains(event.relatedTarget) && this.resultsShown) {
- this.hideResultsList()
- if (event.target == this.searchInputTarget) {
- this.boundResetSearchInput(event)
- }
+ let target = event.relatedTarget
+ if (target == null) {
+ target = document.target
}
- }
- handleHiddenInputChange(event) {
- if (event?.detail?.src == "satis-dropdown") {
- return
+ if (event.type != "scroll" && !this.element.contains(target)) {
+ if (this.resultsShown)
+ this.hideResultsList()
+ this.boundResetSearchInput(event)
}
-
- if (this.hiddenInputTarget.value == "") {
- this.searchInputTarget.value = null
- } else {
- this.resetSearchInput()
- }
}
// Called on connect
// FIXME: Has code duplication with select
display(event) {
// Ignore if we triggered this change event
if (event?.detail?.src == "satis-dropdown") {
return
}
- // Put current selection in search field
- if (this.hiddenInputTarget.value) {
- if (this.itemTargets.length == 0) {
- let ourUrl = this.normalizedUrl
- ourUrl.searchParams.append("id", this.hiddenInputTarget.value)
- ourUrl.searchParams.append("page", this.currentPage)
- ourUrl.searchParams.append("page_size", this.pageSizeValue)
+ this.refreshSelectionFromServer().then(() => {
+ // resolved
+ this.setHiddenSelect()
- this.fetchResultsWith(ourUrl).then(() => {
- this.setHiddenInput()
- })
- } else {
- this.setHiddenInput()
+ if (!this.searchInputTarget.value && this.freeTextValue && this.hiddenSelectTarget.options.length > 0) {
+ this.searchInputTarget.value = this.hiddenSelectTarget.options[0].value
}
- if (!this.searchInputTarget.value && this.freeTextValue) {
- this.searchInputTarget.value = this.hiddenInputTarget.value
- }
- }
+ if (!this.hiddenSelectTarget.getAttribute("data-reflex"))
+ this.hiddenSelectTarget.dispatchEvent(new CustomEvent("change", { detail: { src: "satis-dropdown" } }))
+
+ this.validateSearchQuery()
+ })
}
// Called when scrolling in the resultsTarget
scroll(event) {
if (this.elementScrolled(this.resultsTarget)) {
@@ -140,28 +195,33 @@
dispatch(event) {
if (event.target.closest('[data-controller="satis-dropdown"]') != this.element) {
return
}
- this.filterResultsChainTo()
-
switch (event.key) {
case "ArrowDown":
if (this.hasResults) {
- this.showResultsList(event)
-
+ if (!this.resultsShown)
+ this.showResultsList(event)
this.moveDown()
}
+ // prevent the cursor from jumping to the beginning of the input and scrolling in some cases
+ event.preventDefault()
break
case "ArrowUp":
if (this.hasResults) {
this.moveUp()
}
+ // prevent the cursor from jumping to the beginning of the input and scrolling in some cases
+ event.preventDefault()
break
case "Enter":
event.preventDefault()
this.select(event)
+ // expect the dropdown to hide when its a freetext value
+ if (this.selectedIndex === -1 && this.freeTextValue)
+ this.hideResultsList(event)
break
case "Escape":
if (this.resultsShown) {
this.hideResultsList(event)
@@ -177,280 +237,403 @@
return true
}
// User enters text in the search field
search(event) {
+ this.searchQueryValue = this.searchInputTarget.value
+
+ if(this.searchInputTarget.value.length === 0 && !this.isMultipleValue){
+ if(this.nrOfItems === 1) this.lowLightSelected();
+ this.hiddenSelectTarget.innerHTML = ""
+ this.hiddenSelectTarget.add(this.createOption())
+ }
+
if (this.hasUrlValue) {
this.debouncedFetchResults(event)
} else {
this.debouncedLocalResults(event)
}
- if (this.searchInputTarget.value) {
- this.searchInputTarget.closest(".bg-white").classList.add("warning")
- } else {
- this.searchInputTarget.closest(".bg-white").classList.remove("warning")
+ if (!this.isMultipleValue) {
+ // set the freetext value as the selected value
+ if (this.freeTextValue && this.searchInputTarget.value) {
+ this.hiddenSelectTarget.innerHTML = ""
+ var option = this.createOption({ text: this.searchInputTarget.value, value: this.searchInputTarget.value })
+ this.hiddenSelectTarget.add(option)
+ }
}
}
// User presses reset button
reset(event) {
- this.hiddenInputTarget.value = null
- this.hiddenInputTarget.dispatchEvent(new Event("change"))
- this.searchInputTarget.value = null
+ if (!this.isMultipleValue) {
+ this.hiddenSelectTarget.innerHTML = ""
+ this.lastServerRefreshOptions.clear()
+ this.selectedItemsTemplateTarget.innerHTML = ""
+ this.hiddenSelectTarget.options.add(this.createOption())
+ }
+
+ this.searchInputTarget.value = ""
+ this.searchQueryValue = null
+ this.currentPage = 1
this.lastSearch = null
this.lastPage = null
this.endPage = null
- if (this.selectedItem) {
- this.selectedItem.classList.remove("bg-primary-200")
- }
+ this.lowLightSelected();
this.selectedIndex = -1
+
if (this.hasUrlValue) {
this.itemsTarget.innerHTML = ""
}
- this.hideResultsList()
+
this.itemTargets.forEach((item) => {
item.classList.remove("hidden")
})
+ this.filterResultsChainTo()
+ // hide all results and reset
+ this.hideResultsList()
+
+ this.validateSearchQuery()
+
if (event) {
event.preventDefault()
}
- if (this.searchInputTarget.closest(".bg-white").classList.contains("warning")) {
- this.searchInputTarget.closest(".bg-white").classList.remove("warning")
- }
-
+ this.hiddenSelectTarget.dispatchEvent(new Event("change"))
return false
}
// User selects an item using mouse
select(event) {
let dataDiv = event.target.closest('[data-satis-dropdown-target="item"]')
if (dataDiv == null) {
dataDiv = this.selectedItem
}
+ if (dataDiv == null) return
- if (dataDiv == null) {
- return
- }
+ this.selectItem(dataDiv, true)
- this.selectItem(dataDiv)
-
event.preventDefault()
}
- selectItem(dataDiv) {
- this.hideResultsList()
+ selectItem(dataDiv, force = false) {
+ const selectedValue = dataDiv.getAttribute("data-satis-dropdown-item-value") || ""
+ const selectedValueText = dataDiv.getAttribute("data-satis-dropdown-item-text") || ""
+ this.copyItemAttributes(dataDiv, this.hiddenSelectTarget) // FIXME: we are now supporting multiple values; is this needed? We copy the attributes to options
- // Copy over data attributes on the item div to the hidden input
- Array.prototype.slice.call(dataDiv.attributes).forEach((attr) => {
- if (attr.name.startsWith("data") && !attr.name.startsWith("data-satis") && !attr.name.startsWith("data-action")) {
- this.hiddenInputTarget.setAttribute(attr.name, attr.value)
+ const option = this.createOption({ text: selectedValueText, value: selectedValue })
+ this.copyItemAttributes(dataDiv, option)
+ const optionExists = Array.from(this.hiddenSelectTarget.options).some(
+ (opt) => opt.value === option.value && this.dataAttributesAreEqual(opt, option)
+ )
+
+ // we dont select items that already have been selected, open list
+ if (!force) {
+ if (optionExists) {
+ if (!this.resultsShown) this.showResultsList()
+ return
}
- })
+ }
- this.searchInputTarget.value = dataDiv.getAttribute("data-satis-dropdown-item-text")
- this.hiddenInputTarget.value = dataDiv.getAttribute("data-satis-dropdown-item-value")
- this.lastSearch = this.searchInputTarget.value
+ // clear the search input if we are not in multi select mode
+ if (!this.isMultipleValue) {
+ this.lastServerRefreshOptions.clear()
+ this.hiddenSelectTarget.innerHTML = ""
+ this.selectedItemsTemplateTarget.innerHTML = ""
+ this.searchInputTarget.value = selectedValueText
+ } else
+ this.selectedItemsTemplateTarget.content.querySelector(`[data-satis-dropdown-item-value="${selectedValue}"]`)?.remove()
- this.hiddenInputTarget.dispatchEvent(new Event("change"))
+ this.hiddenSelectTarget.add(option)
+ this.lastServerRefreshOptions.add(selectedValue)
+ this.selectedItemsTemplateTarget.content.appendChild(dataDiv.cloneNode(true))
- if (this.searchInputTarget.closest(".bg-white").classList.contains("warning")) {
- this.searchInputTarget.closest(".bg-white").classList.remove("warning")
- }
+ this.hiddenSelectTarget.dispatchEvent(new Event("change"))
+ this.setSelectedItem(selectedValue)
+ this.hideResultsList()
+ this.validateSearchQuery()
}
- // --- Helpers
+ setHiddenSelect() {
+ if (this.hiddenSelectTarget.options.length === 0) {
+ this.searchInputTarget.value = ""
+ this.pillsTarget.innerHTML = ""
+ this.pillsTarget.classList.add("hidden")
+ return true
+ }
- setHiddenInput() {
- const currentItem = this.itemTargets.find((item) => {
- return this.hiddenInputTarget.value == item.getAttribute("data-satis-dropdown-item-value")
- })
- if (currentItem) {
- this.searchInputTarget.value = currentItem.getAttribute("data-satis-dropdown-item-text")
-
- Array.prototype.slice.call(currentItem.attributes).forEach((attr) => {
- if (
- attr.name.startsWith("data") &&
- !attr.name.startsWith("data-satis") &&
- !attr.name.startsWith("data-action")
- ) {
- this.hiddenInputTarget.setAttribute(attr.name, attr.value)
+ if (this.isMultipleValue) {
+ Array.from(this.hiddenSelectTarget.options).forEach((opt) => {
+ if (!opt.value) return
+ const pillExists = this.pillsTarget.querySelector(
+ `[data-satis-dropdown-target="pill"] > button[data-satis-dropdown-id-param="${opt.value}"]`
+ )
+ if (!pillExists) {
+ // Add pill to selection
+ const pillTemplate = this.pillTemplateTarget.content.firstElementChild.cloneNode(true)
+ pillTemplate.prepend(opt.text || opt.value)
+ pillTemplate.querySelector("button").setAttribute("data-satis-dropdown-id-param", opt.value)
+ this.pillsTarget.appendChild(pillTemplate)
}
})
- if (!this.hiddenInputTarget.getAttribute("data-reflex")) {
- this.hiddenInputTarget.dispatchEvent(new CustomEvent("change", { detail: { src: "satis-dropdown" } }))
- }
+
+ this.searchInputTarget.value = ""
+ this.pillsTarget.classList.remove("hidden")
+ } else if (this.hiddenSelectTarget.options.length == 1) {
+ const opt = this.hiddenSelectTarget.options[0]
+ this.searchInputTarget.value = opt.text
}
}
+ // --- Helpers
+
+ recordLastSearch() {
+ this.lastSearch = this.searchInputTarget.value ? this.searchInputTarget.value : ""
+ }
+
+ get searchQueryChanged() {
+ const searchQueryValue = this.filteredSearchQuery ? this.filteredSearchQuery : ""
+ const lastSearch = this.lastSearch ? this.lastSearch : ""
+ return searchQueryValue.length !== lastSearch.length ||
+ searchQueryValue.localeCompare(lastSearch, undefined, { sensitivity: "base" }) !== 0
+ }
+
+ removePill(event) {
+ event.preventDefault()
+
+ this.hiddenSelectTarget.removeChild(this.hiddenSelectTarget.querySelector(`option[value="${event.params.id}"]`))
+ this.lastServerRefreshOptions.delete(event.params.id)
+
+ this.pillTargets
+ .find((pill) => pill.querySelector("button").getAttribute("data-satis-dropdown-id-param") == event.params.id)
+ ?.remove()
+
+ //this.hiddenSelectTarget.dispatchEvent(new Event("change"))
+ }
+
toggleResultsList(event) {
if (this.resultsShown) {
this.hideResultsList(event)
// Not sure what the intent is, but this causes Safari not to open a ticket
// } else if (this.element.contains(document.activeElement)) {
} else {
this.filterResultsChainTo()
- if (this.hasResults) {
+
+ if(this.hasResults && !this.searchQueryChanged){
this.showResultsList(event)
- } else {
- this.fetchResults(event)
+ }else {
+ if (this.hasUrlValue)
+ this.fetchResults(event)
+ else
+ this.localResults(event)
}
}
-
- event.preventDefault()
return false
}
showResultsList(event) {
this.resultsTarget.classList.remove("hidden")
this.resultsTarget.setAttribute("data-show", "")
this.popperInstance.update()
- this.toggleButtonTarget.querySelector(".fa-chevron-up").classList.remove("hidden")
- this.toggleButtonTarget.querySelector(".fa-chevron-down").classList.add("hidden")
+ if (this.hasToggleButtonTarget) {
+ this.toggleButtonTarget.querySelector(".fa-chevron-up").classList.remove("hidden")
+ this.toggleButtonTarget.querySelector(".fa-chevron-down").classList.add("hidden")
+ }
}
hideResultsList(event) {
this.resultsTarget.classList.add("hidden")
this.resultsTarget.removeAttribute("data-show")
- this.toggleButtonTarget.querySelector(".fa-chevron-up").classList.add("hidden")
- this.toggleButtonTarget.querySelector(".fa-chevron-down").classList.remove("hidden")
+ if (this.hasToggleButtonTarget) {
+ this.toggleButtonTarget.querySelector(".fa-chevron-up").classList.add("hidden")
+ this.toggleButtonTarget.querySelector(".fa-chevron-down").classList.remove("hidden")
+ }
}
+ getChainToElement() {
+ return this.hiddenSelectTarget?.form?.querySelector(`[name="${this.chainToValue}"]`)
+ }
+
filterResultsChainTo() {
if (!this.chainToValue) {
return
}
let chainToValue
- let chainTo = this.hiddenInputTarget.form.querySelector(`[name="${this.chainToValue}"]`)
+ let chainTo = this.getChainToElement()
if (chainTo) {
chainToValue = chainTo.value
}
+ let listItems = 0
this.itemTargets.forEach((item) => {
let itemChainToValue = item.getAttribute("data-chain")
let chainMatch = true
if (this.chainToValue || itemChainToValue) {
chainMatch = chainToValue == itemChainToValue
}
if (chainMatch) {
+ listItems += 1
item.classList.remove("hidden")
} else {
item.classList.add("hidden")
+ item.classList.remove("highlighted")
}
})
+ if (listItems == 1) {
+ this.selectItem(this.itemTargets.filter((item) => {
+ return item.classList != 'hidden'
+ })[0])
+ }
}
localResults(event) {
- if (this.searchInputTarget.value == this.lastSearch) {
+ if (!this.searchQueryChanged) {
+ if(!this.resultsShown) {
+ if (this.hasResults)
+ this.showResultsList(event)
+ else this.showSelectedItem()
+ }
+ this.validateSearchQuery()
return
}
- if (this.searchInputTarget.value.length < 2) {
- return
- }
+ this.recordLastSearch()
- this.lastSearch = this.searchInputTarget.value
-
+ // show all items again and count those that were already visible (previously matched)
+ let previouslyVisibleItemsCount = 0
this.itemTargets.forEach((item) => {
- item.classList.remove("hidden")
- })
+ if (item.classList.contains('hidden')) {
+ item.classList.remove('hidden')
+ } else {
+ previouslyVisibleItemsCount++
+ }
+ });
this.filterResultsChainTo()
+ // hide all items that don't match the search query
+ const searchValue = this.searchQueryValue
let matches = []
this.itemTargets.forEach((item) => {
- let text = item.getAttribute("data-satis-dropdown-item-text").toLowerCase()
- let value = item.getAttribute("data-satis-dropdown-item-value").toLowerCase()
+ const text = item.getAttribute("data-satis-dropdown-item-text")
+ const matched = this.needsExactMatchValue ?
+ searchValue.localeCompare(text, undefined, {sensitivity: 'base'}) === 0:
+ new RegExp(searchValue, "i").test(text)
- if (!item.classList.contains("hidden")) {
- if (this.needsExactMatchValue && text === this.searchInputTarget.value.toLowerCase()) {
- matches = matches.concat(item)
- } else if (!this.needsExactMatchValue && text.indexOf(this.searchInputTarget.value.toLowerCase()) >= 0) {
- matches = matches.concat(item)
+ const isHidden = item.classList.contains("hidden")
+ if (!isHidden) {
+ if (matched) {
+ matches.push(item)
} else {
- item.classList.add("hidden")
+ item.classList.toggle("hidden", true)
}
}
})
- if (this.freeTextValue && matches.length != 1) {
- this.hiddenInputTarget.value = this.lastSearch
+ // don't show results
+ if (matches.length > 0) {
+ this.showResultsList(event)
+ } else {
+ if (!this.showSelectedItem())
+ this.hideResultsList(event)
}
- if (
- matches.length == 1 &&
- matches[0].getAttribute("data-satis-dropdown-item-text").toLowerCase().indexOf(this.lastSearch.toLowerCase()) >= 0
- ) {
- this.selectItem(matches[0].closest('[data-satis-dropdown-target="item"]'))
- } else if (matches.length > 1) {
- this.showResultsList(event)
+ // auto select if there is only one match and we are not in freetext mode
+ if(!this.freeTextValue) {
+ if (matches.length === 1) {
+ if (this.filteredSearchQuery.length >= this.minSearchQueryLength &&
+ matches[0].getAttribute("data-satis-dropdown-item-text").toLowerCase().indexOf(this.lastSearch.toLowerCase()) >= 0) {
+ const dataDiv = matches[0].closest('[data-satis-dropdown-target="item"]')
+ this.selectItem(dataDiv)
+ this.setSelectedItem(dataDiv.getAttribute("data-satis-dropdown-item-value"))
+ this.searchQueryValue = ""
+ } else {
+ this.showSelectedItem()
+ }
+ // the selected item if there was only 1 item visible before
+ } else if(previouslyVisibleItemsCount === 1 && matches.length > 1) {
+ this.setSelectedItem()
+ }
}
+
+ this.validateSearchQuery()
}
// Remote search
fetchResults(event) {
const promise = new Promise((resolve, reject) => {
if (
- (this.searchInputTarget.value == this.lastSearch &&
+ (!this.searchQueryChanged &&
(this.currentPage == this.lastPage || this.currentPage == this.endPage)) ||
!this.hasUrlValue
) {
+ if(!this.resultsShown) {
+ if (this.hasResults)
+ this.showResultsList(event)
+ else this.showSelectedItem()
+ }
return
}
- if (this.searchInputTarget.value != this.lastSearch) {
+ if (this.searchQueryChanged) {
this.currentPage = 1
this.endPage = null
+ this.recordLastSearch()
}
- this.lastSearch = this.searchInputTarget.value
this.lastPage = this.currentPage
- let ourUrl = this.normalizedUrl
+ let ourUrl = this.normalizedUrl()
let pageSize = this.pageSizeValue
- if (this.searchInputTarget.value.length >= 2) {
- ourUrl.searchParams.append("term", this.searchInputTarget.value)
+
+ if (event != null && (this.filteredSearchQuery >= 2 || this.lastSearch)) {
+ ourUrl.searchParams.append("term", this.searchQueryValue)
}
+
+
ourUrl.searchParams.append("page", this.currentPage)
ourUrl.searchParams.append("page_size", pageSize)
if (this.needsExactMatchValue) {
ourUrl.searchParams.append("needs_exact_match", this.needsExactMatchValue)
}
this.fetchResultsWith(ourUrl).then((itemCount) => {
if (this.hasResults) {
this.filterResultsChainTo()
- this.highLightSelected()
- this.showResultsList()
- if (
- this.nrOfItems == 1 &&
- this.itemTargets[0]
- .getAttribute("data-satis-dropdown-item-text")
- .toLowerCase()
- .indexOf(this.searchInputTarget.value.toLowerCase()) >= 0
- ) {
- this.selectItem(this.itemTargets[0].closest('[data-satis-dropdown-target="item"]'))
- } else if (this.nrOfItems == 1) {
- this.moveDown()
+ if (!this.resultsShown && !this.chainToValue) {
+ this.showResultsList()
}
+ // auto select when there is only 1 value
+ if (this.filteredSearchQuery.length >= this.minSearchQueryLength && this.nrOfItems === 1 && !this.freeTextValue) {
+ const dataDiv = this.itemTargets[0].closest('[data-satis-dropdown-target="item"]')
+ this.selectItem(dataDiv)
+ this.setSelectedItem(dataDiv.getAttribute("data-satis-dropdown-item-value"))
+ this.searchQueryValue = ""
+ }
+
if (itemCount > 0) {
this.currentPage += 1
}
- if (itemCount < pageSize) {
+
+ // when the count < page_size we assume we reached the end of the list (count can be 0)
+ if (itemCount != pageSize) {
this.endPage = this.currentPage
}
resolve()
+ } else {
+ this.showSelectedItem()
}
+
+ this.validateSearchQuery()
})
})
return promise
}
@@ -480,11 +663,99 @@
})
})
return promise
}
- get normalizedUrl() {
+ get filteredSearchQuery(){
+ if(this.searchQueryValue < this.minSearchQueryLength) return ""
+ return this.searchQueryValue
+ }
+
+ get selectionChangedSinceLastRefresh() {
+ return (
+ this.hiddenSelectTarget.options.length !== this.lastServerRefreshOptions.size ||
+ !Array.from(this.hiddenSelectTarget.options).every((option) => this.lastServerRefreshOptions.has(option.value))
+ )
+ }
+
+ refreshSelectionFromServer() {
+ if (!this.selectionChangedSinceLastRefresh) return Promise.resolve()
+
+ let updated = 0
+ Array.from(this.hiddenSelectTarget.options).forEach((opt) => {
+ // try to find the items locally
+ let item = this.itemsTarget.querySelector('[data-satis-dropdown-item-value="' + opt.value + '"]')
+ if (item) {
+ opt.text = item.getAttribute("data-satis-dropdown-item-text")
+
+ // Copy over data attributes on the item div to the option
+ this.copyItemAttributes(item, opt)
+ this.selectedItemsTemplateTarget.content.appendChild(item.cloneNode(true))
+ updated++
+ }
+
+ this.lastServerRefreshOptions.add(opt.value)
+ })
+
+ if (!this.hasUrlValue || this.hiddenSelectTarget.options.length === updated) return Promise.resolve()
+
+ const promise = new Promise((resolve, reject) => {
+ if (!this.hasUrlValue) return
+
+ const ourUrl = this.normalizedUrl()
+
+ let selectedIds = Array.from(this.hiddenSelectTarget.options).map((opt) => opt.value)
+
+ // make sure we get all selected items
+ //ourUrl.searchParams.append("page", 1)
+ //ourUrl.searchParams.append("page_size", selectedIds.length)
+ // parameters with [] will be converted to an array
+ if (selectedIds.length > 0)
+ selectedIds.forEach((id) => ourUrl.searchParams.append(selectedIds.length === 1 ? "id" : "id[]", id))
+
+ fetch(ourUrl.href, {}).then((response) => {
+ if (response.ok)
+ response.text().then((data) => {
+ let tmpDiv = document.createElement("div")
+ tmpDiv.innerHTML = data
+
+ for (let i = 0; i < this.hiddenSelectTarget.options.length; i++) {
+ let opt = this.hiddenSelectTarget.options[i]
+ let item = tmpDiv.querySelector('[data-satis-dropdown-item-value="' + opt.value + '"]')
+ if (!item && !this.freeTextValue) {
+ this.selectedItemsTemplateTarget.content.querySelector(`[data-satis-dropdown-item-value="${opt.value}"]`)?.remove()
+ opt.remove()
+ this.lastServerRefreshOptions.delete(opt.value)
+ } else {
+ let text = item.getAttribute("data-satis-dropdown-item-text")
+
+ if (opt.text != text) {
+ if (text === "") opt.text = opt.id
+ else opt.text = text
+ }
+
+ // Copy over data attributes on the item div to the option
+ this.copyItemAttributes(item, opt)
+ this.selectedItemsTemplateTarget.content.appendChild(item.cloneNode(true))
+ }
+ }
+
+ // blank option
+ if (this.hiddenSelectTarget.options.length === 0) {
+ let option = this.createOption()
+ this.hiddenSelectTarget.options.add(option)
+ this.lastServerRefreshOptions.add(option.value)
+ }
+
+ resolve()
+ })
+ })
+ })
+ return promise
+ }
+
+ normalizedUrl() {
let ourUrl
try {
ourUrl = new URL(this.urlValue)
} catch (error) {
ourUrl = new URL(this.urlValue, window.location.href)
@@ -492,17 +763,26 @@
// Add searchParams based on url_params
const form = this.element.closest("form")
Object.entries(this.urlParamsValue).forEach((item) => {
let elm = form.querySelector(`[name='${item[1]}']`)
+
if (elm) {
ourUrl.searchParams.append(item[0], elm.value)
} else {
ourUrl.searchParams.append(item[0], item[1])
}
})
+ let chainTo = this.getChainToElement()
+ if (chainTo) {
+ let chainToParam = chainTo
+ .getAttribute("name")
+ .substring(chainTo.getAttribute("name").lastIndexOf("[") + 1, chainTo.getAttribute("name").lastIndexOf("]"))
+ ourUrl.searchParams.append(chainToParam, chainTo.value)
+ }
+
return ourUrl
}
get resultsShown() {
return this.resultsTarget.hasAttribute("data-show")
@@ -531,28 +811,46 @@
this.selectedIndex = 0
}
}
get selectedItem() {
+ if (this.selectedIndex === -1) return
return this.itemTargets.filter((item) => {
return !item.classList.contains("hidden")
})[this.selectedIndex]
}
lowLightSelected() {
- if (this.selectedItem) {
- this.selectedItem.classList.remove("bg-primary-200")
- }
+ this.itemsTarget.querySelectorAll('.highlighted[data-satis-dropdown-target="item"]').forEach((item) => {
+ item.classList.toggle("highlighted")
+ })
}
highLightSelected() {
- if (this.selectedItem) {
- this.selectedItem.classList.add("bg-primary-200")
- this.selectedItem.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "start" })
+ const selectedItem = this.selectedItem
+ if (selectedItem) {
+ selectedItem.classList.toggle("highlighted", true)
+ selectedItem.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "start" })
}
}
+ /*
+ * Set the selected item base on an the items 'data-satis-dropdown-item-value' attribute
+ * @param {string} value - The value to match the item against
+ */
+ setSelectedItem(value) {
+ this.lowLightSelected()
+ if (!value) {
+ this.selectedIndex = -1
+ return
+ }
+ const itemTargets = this.itemTargets;
+ const visibleItems = itemTargets.filter(item => !item.classList.contains("hidden"));
+ this.selectedIndex = visibleItems.findIndex(item => item.getAttribute("data-satis-dropdown-item-value") === value);
+ this.highLightSelected()
+ }
+
moveDown() {
this.lowLightSelected()
this.increaseSelectedIndex()
this.highLightSelected()
}
@@ -560,13 +858,37 @@
moveUp() {
this.lowLightSelected()
this.decreaseSelectedIndex()
this.highLightSelected()
}
+ validateSearchQuery() {
+ const trimmedValue = this.searchInputTarget.value.trim();
+ const elements = this.selectedItemsTemplateTarget.content.querySelectorAll(`[data-satis-dropdown-item-text*="${trimmedValue}"]`);
+ const selected = Array.from(elements).find(element => element.getAttribute('data-satis-dropdown-item-text').trim() === trimmedValue);
+ if (!selected && this.searchInputTarget.value.length > 0 && !this.freeTextValue) {
+ this.searchInputTarget.closest(".bg-white").classList.toggle("warning", true)
+ } else {
+ this.searchInputTarget.closest(".bg-white").classList.toggle("warning", false)
+ }
+ }
+ // clear search input and hide results
resetSearchInput(event) {
- this.setHiddenInput()
+ if (this.multiSelectValue) {
+ this.searchInputTarget.value = ""
+ } else {
+ if (this.hiddenSelectTarget.options.length > 0) {
+ const option = this.hiddenSelectTarget.options[0]
+ this.searchInputTarget.value = option.text
+ }
+ }
+
+ if (this.resultsShown) {
+ this.hideResultsList(event)
+ }
+
+ this.validateSearchQuery()
}
clickedOutside(event) {
if (event.target.tagName == "svg" || event.target.tagName == "path") {
return
@@ -574,7 +896,81 @@
if (!this.element.contains(event.target)) {
if (this.resultsShown) {
this.hideResultsList()
}
}
+ }
+
+ copyItemAttributes(item, dest) {
+ Array.prototype.slice.call(item.attributes).forEach((attr) => {
+ if (attr.name.startsWith("data") && !attr.name.startsWith("data-satis") && !attr.name.startsWith("data-action")) {
+ dest.setAttribute(attr.name, attr.value)
+ }
+ })
+ }
+
+ createOption(options) {
+ options = Object.assign({ text: "", value: "", selected: true }, options)
+
+ let option = document.createElement("option")
+ option.text = options.text
+ option.value = options.value
+ option.setAttribute("selected", options.selected)
+ return option
+ }
+
+ dataAttributesAreEqual(el1, el2) {
+ const keys1 = Object.keys(el1.dataset)
+ const keys2 = Object.keys(el2.dataset)
+ if (keys1.length !== keys2.length) return false
+
+ for (const key of keys1) {
+ if (el1.dataset[key] !== el2.dataset[key]) {
+ return false
+ }
+ }
+ return true
+ }
+
+ get hasFocus() {
+ const activeElement = document.activeElement;
+ if (activeElement === this.element ||
+ this.element.contains(activeElement) ||
+ this.element.querySelector(':focus') !== null) {
+ return true;
+ }
+ return false;
+ }
+
+ // Selected items are being cached in selectItemsTemplate. Sometimes we want to show the selected item in the results list
+ // As the selected item may not be in the results list, we cache the item in the template and re-add it when needed.
+ showSelectedItem() {
+ if (this.isMultipleValue
+ || this.freeTextValue
+ || this.hiddenSelectTarget.options.length === 0
+ || !this.hasFocus
+ ) return false;
+
+ const option = this.hiddenSelectTarget.options[0]
+ let item = this.itemsTarget.querySelector(`[data-satis-dropdown-item-value="${option.value}"]`)
+ if (item) {
+ item.classList.remove("hidden")
+ } else {
+ item = this.selectedItemsTemplateTarget.content.querySelector(`[data-satis-dropdown-item-value="${option.value}"]`)
+ if (item) {
+ item = item.cloneNode(true)
+ item.classList.remove("hidden")
+ item.setAttribute("data-satis-dropdown-target", "item")
+ item.setAttribute("data-action", "click->satis-dropdown#select")
+ this.itemsTarget.appendChild(item)
+ }
+ }
+
+ if(item) {
+ if (!this.resultsShown)
+ this.showResultsList()
+ this.setSelectedItem(option.value)
+ }
+
+ return item != null;
}
}