import { html, HTMLTemplateResult, nothing, PropertyValues, unsafeCSS } from "lit";
import { property, query, state } from "lit/decorators.js";
import eleStyle from "./f-suggest.scss?inline";
import globalStyle from "./f-suggest-global.scss?inline";
import { FRoot } from "../../mixins/components/f-root/f-root";
import { FText } from "../f-text/f-text";
import { FDiv } from "../f-div/f-div";
import { FPopover } from "../f-popover/f-popover";
import { FInput } from "../f-input/f-input";
import { ifDefined } from "lit-html/directives/if-defined.js";
import { classMap } from "lit-html/directives/class-map.js";
import { cloneDeep } from "lodash-es";
import { flowElement } from "./../../utils";
import { displayCustomTemplate, displayOptions, displayCategories } from "./display-options";
import { injectCss } from "@cldcvr/flow-core-config";
injectCss("f-suggest", globalStyle);
export type FSuggestState = "primary" | "default" | "success" | "warning" | "danger";
export type FSuggestCustomEvent = {
value?: string | FSuggestTemplate;
};
export type FSuggestSuffixWhen = (value: string) => boolean;
export type FSuggestSuggestionsCategory = Record;
export type FSuggestTemplate = {
value: Type;
template: (value?: string) => HTMLTemplateResult;
toString: () => string;
};
export type FSuggestWhen = (suggestion: string | FSuggestTemplate, value?: string) => boolean;
export type FSuggestSuggestions = string[] | FSuggestSuggestionsCategory | FSuggestTemplate[];
@flowElement("f-suggest")
export class FSuggest extends FRoot {
/**
* css loaded from scss file
*/
static styles = [
unsafeCSS(eleStyle),
unsafeCSS(globalStyle),
...FText.styles,
...FDiv.styles,
...FPopover.styles,
...FInput.styles
];
/**
* @attribute Variants are various visual representations of an input field.
*/
@property({ reflect: true, type: String })
variant?: "curved" | "round" | "block" = "curved";
/**
* @attribute Categories are various visual representations of an input field.
*/
@property({ reflect: true, type: String })
category?: "fill" | "outline" | "transparent" = "fill";
/**
* @attribute suggestions to show on value
*/
@property({ reflect: false, type: Array })
suggestions?: FSuggestSuggestions = [];
/**
* @attribute disable showing suggestions
*/
@property({ reflect: true, type: Boolean, attribute: "disable-suggestions" })
disableSuggestions = false;
/**
* @attribute States are used to communicate purpose and connotations.
*/
@property({ reflect: true, type: String })
state?: FSuggestState = "default";
/**
* @attribute f-input can have 2 sizes. By default size is inherited by the parent f-field.
*/
@property({ reflect: true, type: String })
size?: "medium" | "small" = "medium";
/**
* @attribute Defines the value of an f-input. Validation rules are applied on the value depending on the type property of the f-text-input.
*/
@property({ reflect: true, type: String })
value?: string;
/**
* @attribute Defines the placeholder text for f-text-input
*/
@property({ reflect: true, type: String })
placeholder?: string;
/**
* @attribute Icon-left enables an icon on the left of the input value.
*/
@property({ reflect: true, type: String, attribute: "icon-left" })
iconLeft?: string;
/**
* @attribute Icon-right enables an icon on the right of the input value.
*/
@property({ reflect: true, type: String, attribute: "icon-right" })
iconRight?: string;
/**
* @attribute Prefix property enables a string before the input value.
*/
@property({ reflect: true, type: String })
prefix: string | null = null;
/**
* @attribute Suffix property enables a string on the right side of the input box.
*/
@property({ reflect: true, type: String })
suffix?: string;
/**
* @attribute This shows the character count while typing and auto limits after reaching the max length.
*/
@property({ reflect: true, type: [Number, undefined], attribute: "max-length" })
maxLength?: number;
/**
* @attribute Loader icon replaces the content of the button .
*/
@property({ reflect: true, type: Boolean })
loading?: boolean = false;
/**
* @attribute Shows disabled state of input element
*/
@property({ reflect: true, type: Boolean })
disabled?: boolean = false;
/**
* @attribute Displays a close icon-button on the right side of the input that allows the user to clear the input value
*/
@property({ reflect: true, type: Boolean })
clear?: boolean = true;
/**
* @attribute When true the user can not select the input element.
*/
@property({ reflect: true, type: Boolean, attribute: "read-only" })
readOnly?: boolean = false;
@property({ reflect: false, type: Function })
suffixWhen?: FSuggestSuffixWhen;
@property({ reflect: false, type: Function, attribute: "suggest-when" })
suggestWhen: FSuggestWhen = (sg, value) => {
if (typeof sg === "object") {
return sg
.toString()
.toLocaleLowerCase()
.includes(value?.toLocaleLowerCase() ?? "");
}
return sg.toLocaleLowerCase().includes(value?.toLocaleLowerCase() ?? "");
};
set ["suggest-when"](val: FSuggestWhen) {
this.suggestWhen = val;
}
/**
* @attribute max height for options
*/
@property({ reflect: true, type: String, attribute: "options-max-height" })
optionsMaxHeight?: string;
/**
* input element reference
*/
@query("f-input")
fInput!: FInput;
/**
* popover element reference
*/
@query("f-popover")
popOverElement!: FPopover;
@query(".f-select-options-clickable")
FSelectOptions?: FDiv;
@state()
currentIndex = -1;
@state()
currentCategoryIndex = 0;
filteredSuggestions?: FSuggestSuggestions;
/**
* emit input custom event
*/
handleInput(e: CustomEvent<{ value: string }>) {
e.stopPropagation();
this.value = e.detail.value;
this.handleFocus();
this.dispatchInputEvent(e.detail.value);
}
dispatchInputEvent(value?: string | FSuggestTemplate) {
const event = new CustomEvent("input", {
detail: {
value
},
bubbles: true,
composed: true
});
this.dispatchEvent(event);
}
async handleBlur(wait = true) {
// waiting if it is normal blur or selection blur, otherwise value not updated
if (wait) {
await new Promise(resolve => setTimeout(resolve, 200));
}
this.popOverElement.open = false;
this.currentIndex = -1;
this.currentCategoryIndex = 0;
}
handleFocus() {
if (!this.disableSuggestions) {
this.popOverElement.target = this.fInput.inputWrapperElement;
this.popOverElement.offset = {
mainAxis: 4
};
this.popOverElement.style.width = this.offsetWidth + "px";
this.popOverElement.style.maxHeight = this.optionsMaxHeight ?? "600px";
if (!this.loading) {
this.popOverElement.open = true;
}
}
}
get filteredSuggestionsLength() {
if (Array.isArray(this.filteredSuggestions)) {
return this.filteredSuggestions.length;
} else if (this.filteredSuggestions) {
return Object.keys(this.filteredSuggestions).length;
}
return 0;
}
get anySuggestions() {
return this.filteredSuggestionsLength > 0;
}
get isStringArraySuggestions() {
return Array.isArray(this.suggestions);
}
get isTemplateArraySuggestions() {
return (
this.isStringArraySuggestions &&
(this.suggestions as FSuggestTemplate[])?.every(
item => typeof item === "object" && item !== null && !Array.isArray(item)
)
);
}
get isSearchComponent() {
return this.getAttribute("data-suggest") === "search";
}
handleKeyDown(event: KeyboardEvent) {
switch (event.key) {
case "ArrowUp":
event.preventDefault();
this.navigateOptions(-1);
break;
case "ArrowDown":
event.preventDefault();
this.navigateOptions(1);
break;
case "Enter":
event.preventDefault();
this.selectOption();
break;
case "Escape":
event.preventDefault();
this.popOverElement.open = false;
break;
}
}
navigateOptions(direction: number) {
if (this.isStringArraySuggestions) {
const totalOptions = this.filteredSuggestions?.length;
if (totalOptions === 0) return;
// Calculate the next index based on the direction
const newIndex = this.currentIndex + direction;
// Ensure the new index stays within bounds
this.currentIndex = (newIndex + (totalOptions as number)) % (totalOptions as number);
// Optionally, you can scroll the dropdown to bring the selected option into view if it's outside the viewport.
this.scrollFocusedOptionIntoView();
} else {
if (this.filteredSuggestions) {
const totalCategories = Object.keys(this.filteredSuggestions).length;
if (totalCategories === 0) return;
const currentCategory = Object.keys(this.filteredSuggestions)[this.currentCategoryIndex];
const totalOptions = (this.filteredSuggestions as FSuggestSuggestionsCategory)[
currentCategory
].length;
// Calculate the next option index based on the direction
const newIndex = this.currentIndex + direction;
// Handle navigation within the current category
if (newIndex >= 0 && newIndex < totalOptions) {
this.currentIndex = newIndex;
} else if (newIndex >= totalOptions) {
// Move to the next category
this.currentCategoryIndex = (this.currentCategoryIndex + 1) % totalCategories;
this.currentIndex = 0; // Set the first option of the new category as focused
} else {
// Move to the previous category
this.currentCategoryIndex =
(this.currentCategoryIndex - 1 + totalCategories) % totalCategories;
this.currentIndex =
(this.filteredSuggestions as FSuggestSuggestionsCategory)[currentCategory].length - 1; // Set the last option of the new category as focused
}
// Optionally, you can scroll the dropdown to bring the selected option into view if it's outside the viewport.
this.scrollFocusedOptionIntoView();
}
}
}
scrollFocusedOptionIntoView() {
const optionElements = this.shadowRoot?.querySelectorAll(".f-select-options-clickable");
if (optionElements) {
if (optionElements.length > this.currentIndex) {
optionElements[this.currentIndex].scrollIntoView({
behavior: "auto", // 'auto' or 'smooth' for scrolling behavior
block: "nearest" // Scroll to the nearest edge of the container
});
}
}
}
selectOption() {
if (this.isStringArraySuggestions) {
if (this.filteredSuggestions) {
if (this.currentIndex >= 0 && this.currentIndex < this.filteredSuggestionsLength) {
const selectedOption = (this.filteredSuggestions as string[] | FSuggestTemplate[])[
this.currentIndex
];
if (this.isTemplateArraySuggestions) {
this.value = (selectedOption as FSuggestTemplate).toString();
} else {
this.value = selectedOption as string;
}
this.dispatchInputEvent(selectedOption);
void this.handleBlur(false);
}
}
} else {
if (this.currentCategoryIndex >= 0 && this.currentIndex >= 0 && this.filteredSuggestions) {
const selectedCategory = Object.keys(this.filteredSuggestions)[this.currentCategoryIndex];
const selectedOption = (this.filteredSuggestions as FSuggestSuggestionsCategory)[
selectedCategory
][this.currentIndex];
this.dispatchInputEvent(selectedOption);
void this.handleBlur(false);
}
}
}
displayOptions = displayOptions;
displayCategories = displayCategories;
displayCustomTemplate = displayCustomTemplate;
willUpdate(changedProperties: PropertyValues) {
if (changedProperties.has("value") || changedProperties.has("suggestions")) {
if (this.value) {
if (this.isStringArraySuggestions && !this.isTemplateArraySuggestions) {
this.filteredSuggestions = (this.suggestions as string[])?.filter(sg =>
this.suggestWhen(sg, this.value)
);
} else if (this.isTemplateArraySuggestions) {
this.filteredSuggestions = (this.suggestions as FSuggestTemplate[])?.filter(sg =>
this.suggestWhen(sg, this.value)
);
} else {
const filtered = cloneDeep(this.suggestions) as FSuggestSuggestionsCategory;
Object.entries(filtered).forEach(([objName, objValue]) => {
filtered[objName] = objValue.filter(item => this.suggestWhen(item, this.value));
});
for (const key in filtered) {
if (Array.isArray(filtered[key]) && !filtered[key].length) delete filtered[key];
}
this.filteredSuggestions = filtered;
}
} else {
this.filteredSuggestions = this.suggestions;
}
} else if (this.value === undefined) {
this.filteredSuggestions = this.suggestions;
}
super.willUpdate(changedProperties);
}
render() {
// classes to apply on inner element
const classes: Record = {};
// merging host classes
this.classList.forEach(cl => {
classes[cl] = true;
});
return html`
${this.isSearchComponent
? ""
: html`
`}
${this.filteredSuggestions && this.filteredSuggestionsLength > 0
? this.getSuggestionHtml(this.filteredSuggestions)
: html``}
`;
}
getSuggestionHtml(suggestions: FSuggestSuggestions) {
if (this.isStringArraySuggestions && !this.isTemplateArraySuggestions) {
if (this.anySuggestions) {
return this.displayOptions(suggestions as string[]);
}
return nothing;
} else if (this.isTemplateArraySuggestions) {
if (this.anySuggestions) {
return this.displayCustomTemplate(suggestions as FSuggestTemplate[]);
}
return nothing;
} else {
return this.displayCategories(suggestions as FSuggestSuggestionsCategory);
}
}
async handleSuggest(event: PointerEvent) {
if (event.target && (event.target as FDiv).textContent) {
this.value = (event.target as FDiv).textContent?.trim();
this.dispatchInputEvent(this.value as string);
this.dispatchSelectedEvent(this.value as string);
}
await this.handleBlur(false);
}
async handleSelect(sg: FSuggestTemplate) {
this.value = sg.toString();
this.dispatchInputEvent(sg.toString());
this.dispatchSelectedEvent(sg);
await this.handleBlur(false);
}
dispatchSelectedEvent(value: string | FSuggestTemplate) {
const event = new CustomEvent("selected", {
detail: {
value
},
bubbles: true,
composed: true
});
this.dispatchEvent(event);
}
}
/**
* Required for typescript
*/
declare global {
interface HTMLElementTagNameMap {
"f-suggest": FSuggest;
}
}