import { HTMLTemplateResult, PropertyValues, unsafeCSS } from "lit";
import { property, query, queryAll, queryAssignedElements, state } from "lit/decorators.js";
import eleStyle from "./f-select.scss?inline";
import globalStyle from "./f-select-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 { FIcon } from "../f-icon/f-icon";
import { cloneDeep } from "lodash-es";
import render, { renderSingleSelection, renderMultipleSelectionTag } from "./render";
import {
handleDropDownOpen,
handleDropDownClose,
handleOptionSelection,
handleSelectionGroup,
handleRemoveGroupSelection,
handleCheckboxInput,
handleCheckboxGroup,
handleSelectAll,
handleViewMoreTags,
handleInput,
handleBlur,
handleKeyDown,
handleOptionMouseOver
} from "./handlers";
import { FIconButton } from "../f-icon-button/f-icon-button";
import { flowElement } from "./../../utils";
import { injectCss } from "@cldcvr/flow-core-config";
injectCss("f-select", globalStyle);
export type FSelectState = "primary" | "default" | "success" | "warning" | "danger";
export type FSelectHeightProp = number;
export type FSelectWidthProp = "fill-container" | `${number}`;
export type FSelectArrayOfStrings = string[];
export type FSelectOptionObject = Record> = {
icon?: string;
title: string;
data?: T;
qaId?: string;
disabled?: boolean;
};
export type FSelectOptionsGroup = { [key: string]: FSelectOptionsProp };
export type FSelectArrayOfObjects = FSelectOptionObject[];
export type FSelectArray = FSelectSingleOption[];
export type FSelectOptionsProp = FSelectSingleOption[];
export type FSelectSingleOption = FSelectOptionObject | string;
export type FSelectOptions = FSelectOptionsProp | FSelectOptionsGroup;
export type FSelectValue = FSelectOptions | FSelectSingleOption;
export type FSelectOptionTemplate = (
option: FSelectSingleOption,
isSelected?: boolean
) => HTMLTemplateResult;
export type FSelectCustomEvent = {
value: unknown;
searchValue?: string;
};
export type FSelectCreateOptionEvent = {
value: string;
options?: FSelectOptions;
};
@flowElement("f-select")
export class FSelect extends FRoot {
/**
* css loaded from scss file
*/
static styles = [
unsafeCSS(eleStyle),
unsafeCSS(globalStyle),
...FText.styles,
...FDiv.styles,
...FIcon.styles,
...FIconButton.styles
];
@queryAssignedElements({ slot: "label" })
_labelNodes!: NodeListOf;
@state()
_hasLabel = false;
@queryAssignedElements({ slot: "help" })
_helpNodes!: NodeListOf;
@state()
_hasHelperText = false;
/**
* @attribute local state for dropdown open and close boolean
*/
@state({})
openDropdown = false;
/**
* @attribute Multiple tags View/Hide
*/
@state({})
viewMoreTags = false;
/**
* @attribute local state for typing string and searching in f-select
*/
@state({})
searchValue = "";
/**
* @attribute local state for options selected
*/
@state({})
selectedOptions: FSelectOptions = [];
/**
* @attribute local state for filtered options on search
*/
@state({})
filteredOptions: FSelectOptions = [];
@state({})
optimizedHeight = 0;
@state({})
preferredOpenDirection = "below";
@state({})
optionsTop = "";
@state({})
optionsBottom = "";
@query("#f-select")
inputElement!: HTMLInputElement;
@query("#f-select-wrapper")
wrapperElement!: HTMLDivElement;
@query("#f-select-options")
optionElement!: HTMLDivElement;
@queryAll(".f-select-options-clickable:not([disabled])")
allOptions?: NodeListOf;
/**
* @attribute Categories are various visual representations of an input field.
*/
@property({ reflect: true, type: String })
type?: "single" | "multiple" = "single";
/**
* @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 States are used to communicate purpose and connotations.
*/
@property({ reflect: true, type: String })
state?: FSelectState = "default";
/**
* @attribute f-select can have 2 sizes. By default size is inherited by the parent f-field.
*/
@property({ reflect: true, type: String })
size?: "medium" | "small";
/**
* @attribute Defines the value of an f-select. Validation rules are applied on the value depending on the type property of the f-text-input.
*/
@property({ reflect: true, type: Object })
value?: FSelectValue;
/**
* @attribute Defines the placeholder text for f-text-input
*/
@property({ reflect: true, type: Object })
options!: FSelectOptions;
/**
* @attribute Defines the placeholder text for f-text-input
*/
@property({ reflect: true, type: String })
placeholder?: string;
/**
* @attribute Defines the placeholder text for f-text-input
*/
@property({ reflect: false, type: Function, attribute: "option-template" })
optionTemplate?: FSelectOptionTemplate;
set ["option-template"](val: FSelectOptionTemplate | undefined) {
this.optionTemplate = val;
}
/**
* @attribute Icon-left enables an icon on the left of the input value.
*/
@property({ reflect: true, type: String, attribute: "icon-left" })
iconLeft?: string;
/**
* @attribute height of `f-select`
*/
@property({ type: String, reflect: true })
height: FSelectHeightProp = 180;
/**
* @attribute width of `f-select`
*/
@property({ type: String, reflect: true })
width?: FSelectWidthProp = "fill-container";
/**
* @attribute Loader icon replaces the content of the button .
*/
@property({ reflect: true, type: Boolean })
loading?: boolean = false;
/**
* @attribute Shows disabled state of select element
*/
@property({ reflect: true, type: Boolean })
disabled?: boolean = false;
/**
* @attribute defines whether user can search within the options or not .
*/
@property({ reflect: true, type: Boolean })
searchable?: boolean = false;
/**
* @attribute a ‘close’ icon button appear on right of the select to clear the input value(s).
*/
@property({ reflect: true, type: Boolean })
clear?: boolean = false;
/**
* @attribute options with checkboxes.
*/
@property({ reflect: true, type: Boolean })
checkbox?: boolean = false;
/**
* @attribute when on search no option is presnt, show create new button
*/
@property({ reflect: true, type: Boolean, attribute: "create-option" })
createOption?: boolean = false;
/**
* @attribute when on search no option is presnt, and on click of create-button, for array of strings, auto-addition of option toggle
*/
@property({ reflect: true, type: Boolean, attribute: "auto-add-option" })
autoAddOption?: boolean = true;
/**
* @attribute limit to show the selection tags inside f-select.
*/
@property({ reflect: true, type: Number, attribute: "selection-limit" })
selectionLimit = 2;
/**
* icon size
*/
get iconSize() {
if (this.size === "medium") return "small";
else if (this.size === "small") return "x-small";
else return undefined;
}
outsideClick = (e: MouseEvent) => {
if (!this.contains(e.target as HTMLInputElement) && this.openDropdown) {
this.handleDropDownClose(e, false);
}
};
containerScroll = () => {
if (this.openDropdown) {
this.updateDimentions();
}
};
connectedCallback(): void {
super.connectedCallback();
/**
* click outside the f-select wrapper area
*/
window.addEventListener("mouseup", this.outsideClick);
/**
* on scoll apply dimetions to options wrapper
*/
window.addEventListener("scroll", this.containerScroll, {
capture: true
});
window.addEventListener("resize", this.updateDimentions);
}
disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener("mouseup", this.outsideClick);
window.removeEventListener("scroll", this.containerScroll, {
capture: true
});
window.removeEventListener("resize", this.updateDimentions);
}
/**
* apply styling to f-select options wrapper.
*/
applyOptionsStyle(width: number) {
if (this.openDropdown)
if (this.classList.contains("f-search-border")) {
return `max-height:${this.optimizedHeight}px; transition: max-height var(--transition-time-rapid) ease-in 0s; min-width:240px; max-width:fit-content; top:${this.optionsTop};bottom:${this.optionsBottom}`;
} else {
return `max-height:${this.optimizedHeight}px; transition: max-height var(--transition-time-rapid) ease-in 0s; width:${width}px; top:${this.optionsTop};bottom:${this.optionsBottom}`;
}
else if (this.classList.contains("f-search-border")) {
return `max-height:0px; transition: max-height var(--transition-time-rapid) ease-in 0s; min-width:240px; max-width:fit-content; top:${this.optionsTop};bottom:${this.optionsBottom}`;
} else {
return `max-height:0px; transition: max-height var(--transition-time-rapid) ease-in 0s; width:${width}px; top:${this.optionsTop};bottom:${this.optionsBottom}`;
}
}
/**
* index search for the resepctive option
*/
getIndex(option: FSelectSingleOption) {
if (typeof option === "string") {
return (this.selectedOptions as FSelectArrayOfStrings).indexOf(option);
} else {
return (this.selectedOptions as FSelectOptionsProp).findIndex(
item => (item as FSelectOptionObject)?.title === option?.title
);
}
}
/**
* index search for respective option of the respective group
*/
getIndexInGroup(option: FSelectSingleOption, group: string) {
if ((this.selectedOptions as FSelectOptionsGroup)[group]) {
return (
(this.selectedOptions as FSelectOptionsGroup)[group] as FSelectArrayOfObjects
).findIndex(item => JSON.stringify(item) === JSON.stringify(option));
} else {
return -1;
}
}
/**
* check selection for respective option.
*/
isSelected(option: FSelectOptionObject | string) {
return (this.selectedOptions as FSelectArrayOfObjects).find(
item => JSON.stringify(item) === JSON.stringify(option)
)
? true
: false;
}
/**
* check selection for respective option of the respective group
*/
isGroupSelection(option: FSelectSingleOption, group: string) {
if ((this.selectedOptions as FSelectOptionsGroup)[group]) {
return ((this.selectedOptions as FSelectOptionsGroup)[group] as FSelectArrayOfObjects).find(
item => JSON.stringify(item) === JSON.stringify(option)
)
? true
: false;
} else if (
this.type === "single" &&
JSON.stringify((this.selectedOptions as FSelectOptionsProp)[0]) === JSON.stringify(option)
) {
return true;
}
return false;
}
/**
* clear input value on clear icon clicked
*/
clearInputValue(e: MouseEvent) {
e.stopPropagation();
this.value = undefined;
const event = new CustomEvent("input", {
detail: {
value: this.value
},
bubbles: true,
composed: true
});
this.selectedOptions = [];
this.clearFilterSearchString();
this.dispatchEvent(event);
this.requestUpdate();
}
/**
* clear te search string
*/
clearSelectionInGroups(e: MouseEvent) {
e.stopPropagation();
const event = new CustomEvent("input", {
detail: {
value: Array.isArray(this.selectedOptions)
? []
: Object.keys(this.selectedOptions).forEach(group => {
(this.selectedOptions as FSelectOptionsGroup)[group] = [];
})
},
bubbles: true,
composed: true
});
(this.value as unknown) = Array.isArray(this.selectedOptions)
? []
: Object.keys(this.selectedOptions).forEach(group => {
(this.selectedOptions as FSelectOptionsGroup)[group] = [];
});
this.clearFilterSearchString();
this.dispatchEvent(event);
this.requestUpdate();
}
/**
* check if all values of group are selected or not or are in idetereminate state
*/
getCheckedValue(group: string) {
if (
(this.selectedOptions as FSelectOptionsGroup)[group]?.length === 0 ||
!(this.selectedOptions as FSelectOptionsGroup)[group]
) {
return "unchecked";
} else if (
(this.selectedOptions as FSelectOptionsGroup)[group]?.length ===
(this.options as FSelectOptionsGroup)[group]?.length
) {
return "checked";
} else {
return "indeterminate";
}
}
/**
* get sliced array to show selected options
*/
getSlicedSelections(optionList: FSelectOptionsProp) {
return this.viewMoreTags ? optionList.length : this.selectionLimit;
}
/**
* change width of input inside f-select according to searchable prop
*/
applyInputStyle() {
return this.searchable
? `${
this.openDropdown
? "width:75%;"
: "width:0px; transition: width var(--transition-time-rapid) ease-in 0s;"
}`
: `max-width:0px`;
}
/**
* get concatinated array from groups
*/
getConcaticateGroupOptions(array: FSelectOptionsGroup) {
const selectedOptions = cloneDeep(array);
return Object.keys(array).reduce(function (arr, key) {
return arr.concat(selectedOptions[key]);
}, [] as FSelectSingleOption[]);
}
/**
* clear search string
*/
clearFilterSearchString() {
this.searchValue = "";
this.filteredOptions = this.options;
this.requestUpdate();
}
isStringsArray(arr: unknown[]) {
return arr.every(i => typeof i === "string");
}
/**
* Create New Option when option not present
*/
createNewOption(e: MouseEvent) {
e.stopPropagation();
e.stopImmediatePropagation();
const event = new CustomEvent("add-option", {
detail: {
value: this.searchValue,
options: this.options
},
bubbles: true,
composed: true
});
if (this.autoAddOption && this.isStringsArray(this.options as unknown[])) {
const event = new CustomEvent("input", {
detail: {
value: this.searchValue
},
bubbles: true,
composed: true
});
this.dispatchEvent(event);
if (this.type === "single") {
(this.selectedOptions as string[]) = [this.searchValue];
this.value = this.searchValue;
} else {
(this.selectedOptions as string[]).push(this.searchValue);
this.value = this.selectedOptions;
}
(this.options as string[]).push(this.searchValue);
this.openDropdown = false;
this.clearFilterSearchString();
}
this.dispatchEvent(event);
}
/**
* validate properties
*/
validateProperties() {
if (!this.options) {
throw new Error("f-select : options field can't be empty");
}
if (this.type === "single" && this.checkbox) {
throw new Error("f-select : checkbox can only be present in `type=multiple`");
}
}
/**
* options wrapper dimentions update on the basis of window screen
*/
updateDimentions() {
if (this.wrapperElement) {
const spaceAbove = this.wrapperElement.getBoundingClientRect().top;
const spaceBelow = window.innerHeight - this.wrapperElement.getBoundingClientRect().bottom;
const hasEnoughSpaceBelow = spaceBelow > this.height;
if (hasEnoughSpaceBelow || spaceBelow > spaceAbove) {
this.preferredOpenDirection = "below";
this.optimizedHeight = +Math.min(spaceBelow - 40, this.height).toFixed(0);
this.optionsBottom = "";
this.optionsTop = `${(
this.wrapperElement.getBoundingClientRect().top +
this.wrapperElement.offsetHeight +
4
).toFixed(0)}px`;
} else {
this.preferredOpenDirection = "above";
this.optimizedHeight = +Math.min(spaceAbove - 40, this.height).toFixed(0);
this.optionsTop = "";
this.optionsBottom = `${(spaceBelow + this.wrapperElement.offsetHeight - 4).toFixed(0)}px`;
}
}
}
_onLabelSlotChange() {
this._hasLabel = this._labelNodes.length > 0;
}
_onHelpSlotChange() {
this._hasHelperText = this._helpNodes.length > 0;
}
get singleSelectionStyle() {
return `max-width:${`${this.offsetWidth - this.rightOffset}px`}`;
}
get rightOffset() {
return this.clear ? 74 : 54;
}
protected updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
this.updateDimentions();
if (changedProperties.has("value")) {
if (this.value && this.type === "single") {
this.selectedOptions = [this.value as FSelectSingleOption];
} else if (this.value && this.type === "multiple") {
this.selectedOptions = this.value as FSelectOptionsProp;
} else {
this.selectedOptions = [];
}
}
if (changedProperties.has("options")) {
this.filteredOptions = this.options;
}
}
getOptionQaId(option: FSelectSingleOption) {
if (typeof option === "string") {
return option;
} else {
return option.qaId ?? option.title;
}
}
handleDropDownOpen = handleDropDownOpen;
handleDropDownClose = handleDropDownClose;
handleOptionSelection = handleOptionSelection;
handleSelectionGroup = handleSelectionGroup;
handleRemoveGroupSelection = handleRemoveGroupSelection;
handleCheckboxInput = handleCheckboxInput;
handleCheckboxGroup = handleCheckboxGroup;
handleSelectAll = handleSelectAll;
handleViewMoreTags = handleViewMoreTags;
handleInput = handleInput;
handleBlur = handleBlur;
handleKeyDown = handleKeyDown;
handleOptionMouseOver = handleOptionMouseOver;
render = render;
renderSingleSelection = renderSingleSelection;
renderMultipleSelectionTag = renderMultipleSelectionTag;
}
/**
* Required for typescript
*/
declare global {
interface HTMLElementTagNameMap {
"f-select": FSelect;
}
}