import type { PropertyValueMap, PropertyValues, TemplateResult } from 'lit' import { css, html } from 'lit' import { property, query, state } from 'lit/decorators.js' import type { DirectiveClass, DirectiveResult } from 'lit/directive.js' import { classMap } from 'lit/directives/class-map.js' import { ifDefined } from 'lit/directives/if-defined.js' import { live } from 'lit/directives/live.js' import { map } from 'lit/directives/map.js' import { when } from 'lit/directives/when.js' import SimpleBar from 'simplebar' import simplebar_css from 'simplebar/dist/simplebar.css' import { localized } from '@upswot/localize' import { v4 as uuidv4 } from 'uuid' import { customElementIfNotExist } from '../../../utils/customElementIfNotExist' import { dispatchCustomEvent } from '../../../utils/dispatchCustomEvent' import { UsBaseElement } from '../../base/UsBaseElement' import type { UsInput } from '../input/UsInput' import { localizeManager } from '../../../dependencies' import type { TIconsStorageKeys } from '../../icon/iconsStorage' import { debounce } from '../../../upswot-components' import { toggleImageDarkTheme } from '../../../utils/toggleImageDarkTheme' import vars_css from '../input/vars.pcss' import type { ISelectItem } from './model/ISelectItem' import type { ISelectProps } from './model/ISelectProps' import UsSelect_css from './select.pcss' import '../messages/validMessage' import '../../button/UsButton' import '../../tooltip/UsTooltip' import '../input/UsInput' // import '../../icon/UsIcon' import '../../list/UsList' declare global { interface HTMLElementTagNameMap { 'us-select': UsSelect } } @customElementIfNotExist('us-select') @localized() export class UsSelect extends UsBaseElement implements ISelectProps { static styles = [ UsBaseElement.styles, css([UsSelect_css] as TemplateStringsArray | any), css([simplebar_css] as TemplateStringsArray | any), css([vars_css] as TemplateStringsArray | any), ] _options: ISelectItem[] | any[] = [] @property({ type: Array }) get options(): ISelectItem[] | any[] { return this._options } set options(value: ISelectItem[] | any[]) { const oldValue = this._options this._options = this.adaptOptions(value) this.requestUpdate('options', oldValue) } @property({ type: String }) placeholder = '' @property({ type: Boolean, attribute: 'disabled', reflect: true }) disabled = false @property({ type: Boolean }) required = false @property({ type: Boolean }) multiple = false @property({ type: Boolean }) showCountItems = false @property({ type: Boolean }) filterable = false @property({ type: Boolean }) emitSearch = false @property({ type: String }) label = '' @property({ type: String }) propertyLabel = '' @property({ type: String }) propertyValue = '' @property({ type: String }) propertyImage = '' @property({ type: Number }) filterDebounce = 0 @property({ type: Boolean }) showTitle = false @property({ type: Boolean }) openAbove = false @property({ type: Boolean }) cantUnselect = false @property({ type: String }) ariaLabel = 'aria-label' @property({ type: String }) filterableProperty: 'value' | 'label' = 'value' @property({ type: String, attribute: 'data-testid', reflect: true }) dataTestId = 'select' @property({ type: String }) errorMsg = '' @property({ type: String }) translationOfWordSelected = 'selected' @property({ type: String }) mainCheckBoxName = 'All' @property({ type: String }) contentSlotTooltip = '' @property({ type: String }) infoToolTipTitleText = '' @property({ type: String }) infoToolTipLinkName = '' @property({ type: String }) infoToolTipLinkHref = '' @property({ type: String }) widgetView = '' @property({ type: Boolean }) isDarkTheme = false @property({ type: Object }) selectedValue: ISelectItem | ISelectItem[] | null = null @property({ type: String }) heightList: ISelectProps['heightList'] = '235' @property({ type: Boolean }) showTooltip = false @property({ type: Boolean }) nothingFounded = false @state() inputValue = '' @state() selectedItem: ISelectItem | null = null @state() selectedItems: ISelectItem[] = [] @state() readonly = true @state() showList = false @state() isValid = true @state() isSearchInputInFocus = false @state() filterableOptions: ISelectItem[] = [] @state() errorList: string[] = [] @state() wasListOpen = false @state() isHovered = false @state() arrowColor = 'var(--color-neutral-700)' @query('[data-simplebar]') simpleBarEl: HTMLDivElement | undefined @query('.select__input') selectInput: UsInput | undefined @query('#list') inputElement: HTMLDivElement | undefined simpleBar!: SimpleBar debounce = 150 defaultPropLabel = 'label' defaultPropValue = 'value' defaultPropImage = 'image' debouncedEmmitSearchEvent!: Function selectId = uuidv4() onWindowClickBinded = this.onWindowClick.bind(this) get select(): HTMLDivElement | null { return this.renderRoot?.querySelector(`#usSelectId_${this.selectId}`) ?? null } connectedCallback() { super.connectedCallback() window.addEventListener('click', this.onWindowClickBinded, true) } disconnectedCallback() { window.removeEventListener('click', this.onWindowClickBinded, true) super.disconnectedCallback() } firstUpdated(_changedProperties: PropertyValues) { if (this.simpleBarEl) this.simpleBar = new SimpleBar(this.simpleBarEl) super.firstUpdated(_changedProperties) } willUpdate(changedProperties: PropertyValueMap | Map) { if (changedProperties.has('filterable')) this.readonly = !this.filterable if (changedProperties.has('options')) this.setFilterableOptions() if (changedProperties.has('errorMsg')) this.errorList = [this.errorMsg] if (changedProperties.has('inputValue')) this.onInputValueChanged() if (changedProperties.has('filterDebounce')) { if (this.filterDebounce && this.filterDebounce > 0) { this.debounce = this.filterDebounce this.debouncedEmmitSearchEvent = debounce(this.emmitSearchEvent.bind(this), this.debounce, false) } } if (changedProperties.has('propertyLabel') || changedProperties.has('propertyValue')) { if (this.propertyLabel || this.propertyValue) { const originalArr = this.options.map(option => option.originalValue) this.options = this.adaptOptions(originalArr) this.setFilterableOptions() } } if (changedProperties.has('selectedItem')) { this.addMultipleInitValue() this.setCheckedToInitValue() } if (changedProperties.has('selectedValue')) { this.selectedItem = this.adaptSelectedValue(this.selectedValue) this.initMultipleValue() if (this.filterable) this.inputValue = this.selectedItem?.originalValue[this.propertyLabel || this.defaultPropLabel || this.propertyImage] || '' } if (changedProperties.has('multiple') && this.multiple && this.options.length > 1) { if (changedProperties.has('multiple') && this.multiple && this.options.length > 1) { const mainCheckBoxElement = { id: '0', image: '', label: this.mainCheckBoxName, value: 'mainCheckBox', checked: false, originalValue: 0, } const hasMainCheckBox = this.options.some(item => item.value === 'mainCheckBox') if (!hasMainCheckBox) this.options.unshift(mainCheckBoxElement) } } } render() { return html`
${when(this.shouldShowSelectedItemImage(), () => html` `)} ${when(this.selectedItem && !this.inputValue && !this.multiple && !this.filterable, () => html` `)} ${when(this.showCountItems && this.multiple && this.selectedItems.length > 0, () => html` `)} ${when(this.selectedItems && this.selectedItems.length > 0 && this.multiple && !this.showCountItems, () => html`
${map(this.selectedItems, (item: ISelectItem) => html`
${item.label}
`)} ${when(this.filterable, () => this.getSearchInputTemplate())}
`)} ${when(this.isShowSelectInput(), () => this.getSearchInputTemplate())}
${when((this.readonly || this.filterable) && !this.isValid, () => html`
${map(this.errorList, error => html` ${error} `)}
`)}
` } shouldShowSelectedItemImage() { return this.filterable ? (this.inputValue && this.selectedItem?.originalValue?.image) : this.selectedItem?.originalValue?.image } getSearchInputTemplate(): TemplateResult<1> { return html` ` } clearInputValue() { this.inputValue = '' this.emmitChangeEvent(null) } setInputValueFromSelectedItem() { this.inputValue = this.selectedItem?.label || '' this.emmitChangeEvent(this.selectedItem) } handleSearchInputClick(e: Event) { e.stopPropagation() if (this.selectedItem && this.filterable && !this.multiple) { if (this.showList) this.setInputValueFromSelectedItem() else this.clearInputValue() } this.showListItems() } handleSearchInputBlur(event: FocusEvent) { const inputValue = (event.target as HTMLInputElement).value || '' this.isSearchInputInFocus = false if (this.selectedItem && this.filterable && !this.multiple) this.inputValue = inputValue } getSelectClasses(): DirectiveResult { return classMap({ select_disabled: this.disabled, select_error: this.required && !this.isValid, select_open: this.showList, select_selected: !!this.selectedItem, }) } getLabelClasses(): DirectiveResult { return classMap({ label_disabled: this.disabled, label_hide: !this.label, }) } getSelectIconName(): TIconsStorageKeys { if (this.filterable && !this.filterableOptions?.length) return 'search' return this.showList ? 'direction-up' : 'direction-down' } getSelectTabindex() { return (this.disabled || this.isSearchInputInFocus) ? -1 : 0 } handleSelectFocus() { if (!this.filterable) return this.returnSelectInputFocus() } handleSelectClick(e: PointerEvent) { if (!this.isCanShowListItems()) return if (!this.filterableOptions.length) return if (!this.wasListOpen) this.showList = false this.wasListOpen = true if (this.filterable && this.showList) this.blurSelectInput() else if (this.filterable) this.returnSelectInputFocus() if (this.selectedItem && this.filterable && !this.multiple) { if (this.showList) this.setInputValueFromSelectedItem() else this.clearInputValue() } e.stopPropagation() this.showListItems() } handleKeydown(event: KeyboardEvent) { if (!this.isCanShowListItems()) return let usInput: HTMLElement switch (event.key) { case 'Enter': if (!this.filterableOptions.length) return this.showListItems() if (this.filterable && this.showList && !this.inputValue) this.returnSelectInputFocus() else this.select?.focus() break case 'ArrowUp': case 'ArrowDown': if (!this.filterableOptions.length) return event.preventDefault() this.showList = true if (this.nothingFounded) return usInput = this.inputElement?.shadowRoot?.querySelector('.list') as HTMLElement usInput.querySelector('ul')?.focus() break case 'Tab': if (event.shiftKey) this.blurElement() break } } blurElement() { this.showList = false this.arrowColor = 'var(--color-neutral-700)' this.validate() } handleSearchClear() { this.selectedItem = this.adaptSelectedValue(this.selectedValue) } async handleListItemClick(e: CustomEvent) { const item = e.detail?.data if (this.multiple) await this.onMultiSelectItem(item, e) else await this.onSelectItem(item, e) } toggleMainCheckbox() { const listLength = this.filterableOptions.length const checkedItemsCount = this.filterableOptions.filter(item => item.checked && item.value !== 'mainCheckBox').length if (checkedItemsCount === listLength) this.filterableOptions = this.filterableOptions.map(item => ({ ...item, checked: false })) else if (checkedItemsCount === 0) this.filterableOptions = this.filterableOptions.map(item => ({ ...item, checked: true })) else if (checkedItemsCount > 0 && checkedItemsCount < listLength) this.filterableOptions = this.filterableOptions.map(item => ({ ...item, checked: false })) this.selectedItems = this.filterableOptions.filter(item => item.checked && item.value !== 'mainCheckBox') this.sendMultiSelectedItem() } handleTagCloseClick(item: ISelectItem, e: PointerEvent) { e.stopPropagation() this.selectedItems = this.selectedItems.filter(i => i.id !== item.id) item.checked = !item.checked } async handleFilterableInput(e: InputEvent) { this.inputValue = (e.target as HTMLInputElement).value if (this.filterable) { this.setFilterableOptions(this.inputValue) this.selectedItem = this.filterableOptions.find(item => item.label === this.inputValue) || null await this.updateComplete this.sendSelectedItem() this.showList = true } if (this.emitSearch) { if (this.inputValue) this.debouncedEmmitSearchEvent(this.inputValue) else this.emmitSearchEvent(this.inputValue) } } emmitSearchEvent(data: string) { dispatchCustomEvent('search', data, this) } emmitChangeEvent(data: ISelectItem | ISelectItem[] | null) { dispatchCustomEvent('change', data, this) } isCanShowListItems() { let isCanShow if (!this.filterable) isCanShow = this.readonly && !this.disabled else isCanShow = !this.disabled return isCanShow } showListItems() { this.showList = !this.showList this.arrowColor = this.showList ? 'var(--input-hover-font-color)' : 'var(--input-icon-color)' if (!this.showList) this.validate() } onHover() { this.isHovered = !this.isHovered if (this.isHovered && this.isValid) this.arrowColor = 'var(--input-hover-font-color)' else if (!this.isHovered && !this.showList) this.arrowColor = 'var(--input-icon-color)' } onWindowClick(event: Event): void { if (!event.composedPath().includes(this) && this.showList) { const shouldUpdateInputValue = !this.inputValue.length && this.filterable && !this.multiple if (shouldUpdateInputValue && this.selectedItem) this.setInputValueFromSelectedItem() this.showListItems() } } sendSelectedItem() { this.emmitChangeEvent(this.selectedItem) } sendMultiSelectedItem() { this.emmitChangeEvent(this.selectedItems) } initMultipleValue() { if (this.multiple && this.selectedValue && Array.isArray(this.selectedValue)) this.selectedItems = this.selectedValue as ISelectItem[] } isShowSelectInput() { if (this.selectedItem && !this.multiple && !this.filterable) return false if (this.selectedItems && this.selectedItems.length > 0 && this.multiple) return false return true } async onSelectItem(item: ISelectItem, e: CustomEvent) { e.stopPropagation() this.updateSelectItem(item) this.showListItems() await this.updateComplete this.sendSelectedItem() if (this.filterable && !this.showList) this.emmitSearchEvent('') /* commented on this code, maybe someday it will be relevant if (this.filterable) this.returnSelectInputFocus() else this.select?.focus() */ } async onMultiSelectItem(item: ISelectItem, e: CustomEvent) { e.stopPropagation() const hasItem = this.selectedItems.some((element: ISelectItem) => { return element.id === item.id && element.value !== 'mainCheckBox' }) if (hasItem) this.selectedItems = this.selectedItems.filter(element => element.id !== item.id && element.value !== 'mainCheckBox') else this.selectedItems = [...this.selectedItems, item] item.checked = !hasItem await this.updateComplete this.filterable && this.returnSelectInputFocus() this.sendMultiSelectedItem() } setFilterableOptions(filterStr: string | null = null) { const optionsToShow = (this.filterable && this.nothingFounded) ? [{ id: '0', label: localizeManager.nothing_found(), value: '0', disabled: true }] : this.options if ((!this.options || this.options.length < 1) && !this.filterable) return if (!this.multiple) { this.options.forEach((o) => { if (this.selectedItem && this.selectedItem.id !== o.id) o.checked = false }) } if (this.filterable && this.nothingFounded) this.filterableOptions = optionsToShow else this.filterableOptions = (this.filterable && filterStr && this.getFilteredArray(this.options, filterStr)) || optionsToShow } getFilteredArray(array: ISelectItem[], filterStr: string): ISelectItem[] { if (!array || array.length < 1) return [] return array.filter((element) => { const val = element[this.filterableProperty].toString().trim().toLowerCase() return val.includes(filterStr.trim().toLowerCase()) }) } getPlaceholder(): string { if (this.multiple && this.selectedItems.length > 0) return '' return this.placeholder } onInputValueChanged() { if (this.inputValue) this.setFilterableOptions(this.inputValue) else this.setFilterableOptions() } returnSelectInputFocus() { if (this.selectInput) { this.selectInput.inputElement?.focus() this.isSearchInputInFocus = true } } blurSelectInput() { this.selectInput && this.selectInput.blur() } adaptOptions(optionsArrs: ISelectItem[] | any[]): ISelectItem[] { if (optionsArrs && Array.isArray(optionsArrs)) { const propLabel = this.propertyLabel || this.defaultPropLabel const propValue = this.propertyValue || this.defaultPropValue const propImage = this.propertyImage || this.defaultPropImage const option = optionsArrs[0] if (typeof option === 'object' && !Array.isArray(option)) { if ('value' in option && 'label' in option) return optionsArrs as ISelectItem[] return optionsArrs.map(option => ({ id: option.id, label: option[propLabel], value: option[propValue], image: option[propImage], originalValue: option })) } return optionsArrs.map(option => ({ label: option, value: option, image: option, originalValue: option })) } return [] } adaptSelectedValue(selectedValue: ISelectItem | any): ISelectItem | null { if (selectedValue) { if (typeof selectedValue === 'object' && !Array.isArray(selectedValue)) { if ('value' in selectedValue && 'label' in selectedValue) return selectedValue as ISelectItem return { id: selectedValue.id, label: selectedValue[this.propertyLabel], value: selectedValue[this.propertyValue], image: selectedValue[this.propertyImage], originalValue: selectedValue } } return { label: selectedValue, value: selectedValue, image: selectedValue, originalValue: selectedValue } } return null } addMultipleInitValue() { this.selectedItem && this.multiple && this.selectedItems.push(this.selectedItem) } setCheckedToInitValue() { this.selectedItem && this.filterable && this.options.forEach((o) => { if (this.selectedItem!.id === o.id) o.checked = true else o.checked = false }) } updateSelectItem(item: ISelectItem) { if ((item.id && item.id !== this.selectedItem?.id) || (item.value && item.value !== this.selectedItem?.value)) { if (this.selectedItem) this.selectedItem.checked = false item.checked = true this.selectedItem = item } else if (this.selectedItem && !this.required) { if (!this.cantUnselect) { item.checked = false this.selectedItem = null } } if (this.filterable) this.setInputValueFromSelectedItem() } validate() { this.errorList = [] this.isValid = this.validRequired() return this.isValid } validRequired(): boolean { if (this.required) { const valid = !!this.selectedItem || (this.selectedItems && this.selectedItems.length > 0) if (!valid) this.errorList.push(this.errorMsg) return valid } else { return true } } isShowTitle() { if (!this.showTitle) return return this.selectedItem?.label || '' } }