import { LightningElement, api } from "lwc";
|
|
const MINIMAL_SEARCH_TERM_LENGTH = 2; // Min number of chars required to search
|
const SEARCH_DELAY = 300; // Wait 300 ms after user stops typing then, peform search
|
|
export default class Lookup extends LightningElement {
|
@api label;
|
@api required;
|
@api placeholder = "";
|
@api isMultiEntry = false;
|
@api errors = [];
|
@api scrollAfterNItems;
|
|
searchTerm = "";
|
searchResults = [];
|
hasFocus = false;
|
loading = false;
|
isDirty = false;
|
|
cleanSearchTerm;
|
blurTimeout;
|
searchThrottlingTimeout;
|
curSelection = [];
|
|
// EXPOSED FUNCTIONS
|
@api
|
set selection(initialSelection) {
|
this.curSelection = Array.isArray(initialSelection)
|
? initialSelection
|
: [initialSelection];
|
}
|
get selection() {
|
return this.curSelection;
|
}
|
|
@api
|
setSearchResults(results) {
|
// Reset the spinner
|
this.loading = false;
|
// Clone results before modifying them to avoid Locker restriction
|
const resultsLocal = JSON.parse(JSON.stringify(results));
|
// Format results
|
this.searchResults = resultsLocal.map(result => {
|
// Clone and complete search result if icon is missing
|
if (this.searchTerm.length > 0) {
|
const regex = new RegExp(`(${this.searchTerm})`, "gi");
|
result.titleFormatted = result.title
|
? result.title.replace(regex, "<strong>$1</strong>")
|
: result.title;
|
result.subtitleFormatted = result.subtitle
|
? result.subtitle.replace(regex, "<strong>$1</strong>")
|
: result.subtitle;
|
}
|
if (typeof result.icon === "undefined") {
|
const { id, sObjectType, title, subtitle } = result;
|
return {
|
id,
|
sObjectType,
|
icon: "standard:default",
|
title,
|
subtitle
|
};
|
}
|
return result;
|
});
|
}
|
|
@api
|
getSelection() {
|
return this.curSelection;
|
}
|
|
// INTERNAL FUNCTIONS
|
|
updateSearchTerm(newSearchTerm) {
|
this.searchTerm = newSearchTerm;
|
|
// Compare clean new search term with current one and abort if identical
|
const newCleanSearchTerm = newSearchTerm
|
.trim()
|
.replace(/\*/g, "")
|
.toLowerCase();
|
if (this.cleanSearchTerm === newCleanSearchTerm) {
|
return;
|
}
|
|
// Save clean search term
|
this.cleanSearchTerm = newCleanSearchTerm;
|
|
// Ignore search terms that are too small
|
if (newCleanSearchTerm.length < MINIMAL_SEARCH_TERM_LENGTH) {
|
this.searchResults = [];
|
return;
|
}
|
|
// Apply search throttling (prevents search if user is still typing)
|
if (this.searchThrottlingTimeout) {
|
clearTimeout(this.searchThrottlingTimeout);
|
}
|
// eslint-disable-next-line @lwc/lwc/no-async-operation
|
this.searchThrottlingTimeout = setTimeout(() => {
|
// Send search event if search term is long enough
|
if (this.cleanSearchTerm.length >= MINIMAL_SEARCH_TERM_LENGTH) {
|
// Display spinner until results are returned
|
this.loading = true;
|
|
const searchEvent = new CustomEvent("search", {
|
detail: {
|
searchTerm: this.cleanSearchTerm,
|
selectedIds: this.curSelection.map(element => element.id)
|
}
|
});
|
this.dispatchEvent(searchEvent);
|
}
|
this.searchThrottlingTimeout = null;
|
}, SEARCH_DELAY);
|
}
|
|
isSelectionAllowed() {
|
if (this.isMultiEntry) {
|
return true;
|
}
|
return !this.hasSelection();
|
}
|
|
hasResults() {
|
return this.searchResults.length > 0;
|
}
|
|
hasSelection() {
|
return this.curSelection.length > 0;
|
}
|
|
// EVENT HANDLING
|
|
handleInput(event) {
|
// Prevent action if selection is not allowed
|
if (!this.isSelectionAllowed()) {
|
return;
|
}
|
this.updateSearchTerm(event.target.value);
|
}
|
|
handleResultClick(event) {
|
const recordId = event.currentTarget.dataset.recordid;
|
|
// Save selection
|
let selectedItem = this.searchResults.filter(
|
result => result.id === recordId
|
);
|
if (selectedItem.length === 0) {
|
return;
|
}
|
selectedItem = selectedItem[0];
|
const newSelection = [...this.curSelection];
|
newSelection.push(selectedItem);
|
this.curSelection = newSelection;
|
this.isDirty = true;
|
|
// Reset search
|
this.searchTerm = "";
|
this.searchResults = [];
|
|
// Notify parent components that selection has changed
|
this.dispatchEvent(new CustomEvent("selectionchange"));
|
}
|
|
handleComboboxClick() {
|
// Hide combobox immediatly
|
if (this.blurTimeout) {
|
window.clearTimeout(this.blurTimeout);
|
}
|
this.hasFocus = false;
|
}
|
|
handleFocus() {
|
// Prevent action if selection is not allowed
|
if (!this.isSelectionAllowed()) {
|
return;
|
}
|
this.hasFocus = true;
|
}
|
|
handleBlur() {
|
// Prevent action if selection is not allowed
|
if (!this.isSelectionAllowed()) {
|
return;
|
}
|
// Delay hiding combobox so that we can capture selected result
|
// eslint-disable-next-line @lwc/lwc/no-async-operation
|
this.blurTimeout = window.setTimeout(() => {
|
this.hasFocus = false;
|
this.blurTimeout = null;
|
}, 300);
|
}
|
|
handleRemoveSelectedItem(event) {
|
const recordId = event.currentTarget.name;
|
this.curSelection = this.curSelection.filter(item => item.id !== recordId);
|
this.isDirty = true;
|
// Notify parent components that selection has changed
|
this.dispatchEvent(new CustomEvent("selectionchange"));
|
}
|
|
handleClearSelection() {
|
this.curSelection = [];
|
this.isDirty = true;
|
// Notify parent components that selection has changed
|
this.dispatchEvent(new CustomEvent("selectionchange"));
|
}
|
|
// STYLE EXPRESSIONS
|
|
get getContainerClass() {
|
let css = "slds-combobox_container slds-has-inline-listbox ";
|
if (this.hasFocus && this.hasResults()) {
|
css += "slds-has-input-focus ";
|
}
|
if (this.errors.length > 0) {
|
css += "has-custom-error";
|
}
|
return css;
|
}
|
|
get getDropdownClass() {
|
let css =
|
"slds-combobox slds-dropdown-trigger slds-dropdown-trigger_click ";
|
if (
|
this.hasFocus &&
|
this.cleanSearchTerm &&
|
this.cleanSearchTerm.length >= MINIMAL_SEARCH_TERM_LENGTH
|
) {
|
css += "slds-is-open";
|
}
|
return css;
|
}
|
|
get getInputClass() {
|
let css = "slds-input slds-combobox__input has-custom-height ";
|
if (
|
this.errors.length > 0 ||
|
(this.isDirty && this.required && !this.hasSelection())
|
) {
|
css += "has-custom-error ";
|
}
|
if (!this.isMultiEntry) {
|
css +=
|
"slds-combobox__input-value " +
|
(this.hasSelection() ? "has-custom-border" : "");
|
}
|
return css;
|
}
|
|
get getComboboxClass() {
|
let css = "slds-combobox__form-element slds-input-has-icon ";
|
if (this.isMultiEntry) {
|
css += "slds-input-has-icon_right";
|
} else {
|
css += this.hasSelection()
|
? "slds-input-has-icon_left-right"
|
: "slds-input-has-icon_right";
|
}
|
return css;
|
}
|
|
get getSearchIconClass() {
|
let css = "slds-input__icon slds-input__icon_right ";
|
if (!this.isMultiEntry) {
|
css += this.hasSelection() ? "slds-hide" : "";
|
}
|
return css;
|
}
|
|
get getClearSelectionButtonClass() {
|
return (
|
"slds-button slds-button_icon slds-input__icon slds-input__icon_right " +
|
(this.hasSelection() ? "" : "slds-hide")
|
);
|
}
|
|
get getSelectIconName() {
|
return this.hasSelection() ? this.curSelection[0].icon : "standard:default";
|
}
|
|
get getSelectIconClass() {
|
return (
|
"slds-combobox__input-entity-icon " +
|
(this.hasSelection() ? "" : "slds-hide")
|
);
|
}
|
|
get getInputValue() {
|
if (this.isMultiEntry) {
|
return this.searchTerm;
|
}
|
return this.hasSelection() ? this.curSelection[0].title : this.searchTerm;
|
}
|
|
get getInputTitle() {
|
if (this.isMultiEntry) {
|
return "";
|
}
|
|
return this.hasSelection() ? this.curSelection[0].title : "";
|
}
|
|
get getListboxClass() {
|
return (
|
"slds-listbox slds-listbox_vertical slds-dropdown slds-dropdown_fluid " +
|
(this.scrollAfterNItems
|
? "slds-dropdown_length-with-icon-" + this.scrollAfterNItems
|
: "")
|
);
|
}
|
|
get isInputReadonly() {
|
if (this.isMultiEntry) {
|
return false;
|
}
|
return this.hasSelection();
|
}
|
|
get isExpanded() {
|
return this.hasResults();
|
}
|
}
|