/** * Copyright Aquera Inc 2023 * * This source code is licensed under the BSD-3-Clause license found in the * LICENSE file in the root directory of this source tree. */ import { LitElement, html, CSSResultArray, TemplateResult, PropertyValues, } from 'lit'; import { customElement, query, state, property } from 'lit/decorators.js'; import { styles } from './nile-chip.css'; import { classMap } from 'lit/directives/class-map.js'; import { HasSlotController } from '../internal/slot'; import NileElement, { NileFormControl } from '../internal/nile-element'; import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import { VisibilityManager } from '../utilities/visibility-manager.js'; interface CustomEventDetail { value: string; } @customElement('nile-chip') export class NileChip extends NileElement { public static get styles(): CSSResultArray { return [styles]; } private readonly hasSlotController = new HasSlotController( this, 'help-text', 'label' ); @state() tags: string[] = []; @state() inputValue: string = ''; @state() isDropdownOpen: boolean = false; @state() tooltips: (string | null)[] = []; @state() private chipFocusIndex: number | null = null; @query('nile-auto-complete') autoComplete!: any; /** Sets the input to a warning state, changing its visual appearance. */ @property({ type: Boolean }) warning = false; @property({ type: Boolean }) noAutoComplete = false; /** Sets the input to an error state, changing its visual appearance. */ @property({ type: Boolean }) error = false; /** Sets the input to a success state, changing its visual appearance. */ @property({ type: Boolean }) success = false; /** Disables the duplicate entries. */ @property({ type: Boolean }) noDuplicates = false; /** The input's label. If you need to display HTML, use the `label` slot instead. */ @property() label = ''; @property({ type: String }) tagVariant: string = ''; // can be '', 'normal', 'success', etc. /** Adds a clear button when the input is not empty. */ @property({ type: Boolean }) acceptUserInput = false; /** When true, adds a chip when the input loses focus or when clicking outside the component. Only works when acceptUserInput is true. */ @property({ type: Boolean }) addOnBlur = false; /** Adds a clear button when the input is not empty. */ @property({ type: Boolean }) clearable = false; /** Placeholder text to show as a hint when the input is empty. */ @property() placeholder = 'type here...'; /** Makes the input readonly. */ @property({ type: Boolean, reflect: true }) readonly = false; /** Disables the input. */ @property({ type: Boolean, reflect: true }) disabled = false; /** * When true, the dropdown menu will be appended to the document body instead of the parent container. * This is useful when the parent has overflow: hidden, clip-path, or transform applied. */ @property({ type: Boolean, reflect: true }) portal = false; // AUTO-COMPLETE-OPTIONS /** Virtual scroll in dropdown options. */ @property({ type: Boolean }) enableVirtualScroll = false; @property({ type: Array }) autoCompleteOptions: any[] = []; @property({ type: Array }) filteredAutoCompleteOptions: any[] = []; @property({ type: Array }) value: any[] = []; @property({ type: Boolean }) noWrap: boolean = false; @property({ type: Boolean }) loading: boolean = false; @property({ type: Array }) errorIndexes: number[] = []; @property({ attribute: 'help-text', reflect: true }) helpText = ''; @property({ attribute: 'error-message', reflect: true }) errorMessage = ''; @property({ attribute:false}) filterFunction: (item:string,searchedValue:string)=>boolean = (item:string,searchedValue:string)=>item.toLowerCase().includes(searchedValue.toLowerCase()); @property({ attribute:false}) renderItemFunction: (item:any)=>string = (item:any)=>item; private visibilityManager?: VisibilityManager; @property({ type: Boolean, reflect: true }) enableVisibilityEffect = false; @property({ type: Boolean, reflect: true }) enableTabClose = false; @property({ type: Boolean }) showTooltip: boolean = false; @property({ type: Boolean }) enableTagDelete = false; @property({ type: Boolean, reflect: true }) required = false; @property({ reflect: true, converter: { fromAttribute: value => value !== 'false', toAttribute: value => (value ? '' : 'false'), }, }) openDropdownOnFocus = true; @property({ type: Boolean, reflect: true, attribute: true }) noDropdownClose = false; private isSelectingFromDropdown = false; private resetDropdownState() { this.isSelectingFromDropdown = false; } protected updated(changedProperties: PropertyValues): void { super.updated(changedProperties); if (changedProperties.has('autoCompleteOptions')) { let options = this.autoCompleteOptions; if (typeof options === 'string') { try { options = JSON.parse(options); } catch (e) { options = []; } } this.filteredAutoCompleteOptions = Array.isArray(options) ? [...options] : []; if (this.noDuplicates) { this.filteredAutoCompleteOptions = this.filteredAutoCompleteOptions.filter( option => !this.value.includes(option) ); } } if (changedProperties.has('value')){ this.tags = [...this.value]; this.onTagsChanged(); } if (changedProperties.has('tags')){ this.onTagsChanged(); } } protected async firstUpdated(_changed: PropertyValues) { await this.updateComplete; const inputTarget = this.noAutoComplete ? this.renderRoot.querySelector('nile-input')?.input : this.autoComplete?.inputElement?.input || this.autoComplete?.inputElement; this.visibilityManager = new VisibilityManager({ host: this, target: inputTarget, enableVisibilityEffect: this.enableVisibilityEffect, enableTabClose: this.enableTabClose, isOpen: () => this.isDropdownOpen, onAnchorOutOfView: () => { this.isDropdownOpen = false; if (this.autoComplete) { this.autoComplete.isDropdownOpen = false; } this.emit('nile-visibility-change', { visible: false, reason: 'anchor-out-of-view', }); }, onDocumentHidden: () => { this.isDropdownOpen = false; if (this.autoComplete) { this.autoComplete.isDropdownOpen = false; } this.emit('nile-visibility-change', { visible: false, reason: 'document-hidden', }); }, emit: (event, detail) => this.emit(`nile-${event}`, detail), }); } private handleDocumentClick = (event: MouseEvent) => { const path = event.composedPath(); if (this.isSelectingFromDropdown) { this.resetDropdownState(); return; } if (!path.includes(this)) { if (this.addOnBlur && this.acceptUserInput && this.inputValue) { this.addChipFromInputValue(); } this.isDropdownOpen = false; } }; connectedCallback() { super.connectedCallback(); document.addEventListener('click', this.handleDocumentClick); if (this.noDuplicates) { this.filteredAutoCompleteOptions = this.filteredAutoCompleteOptions.filter( option => !this.value.includes(option) ); } this.emit('nile-init'); } disconnectedCallback() { super.disconnectedCallback(); this.visibilityManager?.cleanup(); document.removeEventListener('click', this.handleDocumentClick); this.emit('nile-destroy'); } private markDropdownSelectStart(event: MouseEvent) { this.isSelectingFromDropdown = true; } render() { // Check if slots are present const hasLabelSlot = this.hasSlotController.test('label'); const hasHelpTextSlot = this.hasSlotController.test('help-text'); // Check if label and help text are present const hasLabel = this.label ? true : !!hasLabelSlot; const hasHelpText = this.helpText ? true : false; const hasErrorMessage = this.errorMessage ? true : false; return html`
${this.tags.map((tag, index) => { const tooltipContent = this.tooltips[index]; const isFocused = this.enableTagDelete && this.chipFocusIndex === index; let tagText = ""; if(tag || typeof tag === "number") { tagText = tag.toString(); } const tagTemplate = html` this.handleRemove(tag)} removable ?pill=${this.tagVariant !== 'normal'} ?disabled=${this.disabled} > ${unsafeHTML(tagText)} `; if (this.showTooltip && tooltipContent) { return html` ${tagTemplate} `; } return tagTemplate; })}
${this.noAutoComplete ? html` ` : html` `}
${hasHelpText ? html` ${this.helpText} ` : ``} ${hasErrorMessage ? html` ${this.errorMessage} ` : ``}
`; } private handleSelect(event: CustomEvent) { this.isSelectingFromDropdown = false; this.resetDropdownState(); // Add the selected value to the tags array only if it doesn't already exist const selectedValue = event.detail.value; const selectedOption = this.autoCompleteOptions.find( (opt) => opt.name === selectedValue || opt.id === selectedValue ); let tooltipContent: string | null = null; if (this.showTooltip) { if (selectedOption?.tooltip?.content) { const { content, for: showFor } = selectedOption.tooltip; if (!showFor || showFor === 'tag') { if (content instanceof Promise) { this.tooltips = [...this.tooltips, 'Loading...']; const currentIndex = this.tooltips.length - 1; content.then((resolved) => { this.tooltips[currentIndex] = resolved; this.requestUpdate(); }); } else { tooltipContent = content; } } } else { tooltipContent = selectedOption?.name || selectedValue; } } if (!this.noDuplicates || !this.tags.includes(selectedValue)) { this.tags = [...this.tags, selectedValue]; if (!(selectedOption?.tooltip?.content instanceof Promise)) { this.tooltips = [...this.tooltips, tooltipContent]; } this.emit('nile-chip-change', { value: this.tags }); this.resetInput(); } } private handleRemove(value: string) { // Remove the tag from the tags array this.tags = this.tags.filter(tag => tag !== value); if (this.noDuplicates && this.autoCompleteOptions.includes(value)) { this.filteredAutoCompleteOptions = [ ...this.filteredAutoCompleteOptions, value, ]; } this.emit('nile-chip-change', { value: this.tags }); } private handleInputChange(event: CustomEvent) { // Update the input value this.inputValue = event.detail.value; } private addChipFromInputValue(): boolean { if (this.noDuplicates && this.tags.includes(this.inputValue)) { this.emit('nile-duplicates-blocked'); return false; } this.tags = [...this.tags, this.inputValue]; if (this.showTooltip) { this.tooltips = [...this.tooltips, this.inputValue]; } else { this.tooltips = [...this.tooltips, null]; } this.inputValue = ''; this.visibilityManager?.cleanup(); this.emit('nile-chip-change', { value: this.tags }); return true; } private handleInputKeydown(event: KeyboardEvent) { if ( this.enableTagDelete && (event.key === 'Backspace' || event.key === 'Delete') && !this.inputValue && !this.readonly && !this.disabled ) { if (this.tags.length === 0) { this.chipFocusIndex = null; return; } event.preventDefault(); if (this.chipFocusIndex === null) { this.chipFocusIndex = this.tags.length - 1; this.requestUpdate(); return; } const index = this.chipFocusIndex; const removedTag = this.tags[index]; this.tags = this.tags.filter((_, i) => i !== index); this.tooltips = this.tooltips.filter((_, i) => i !== index); this.emit('nile-chip-change', { value: this.tags }); if (this.tags.length > 0) { this.chipFocusIndex = Math.max(0, index - 1); } else { this.chipFocusIndex = null; } return; } if (this.readonly) { const allowedKeys = ['ArrowUp', 'ArrowDown', 'Enter', 'Tab']; if (!allowedKeys.includes(event.key)) { event.preventDefault(); return; } } if (!this.acceptUserInput) { return; } if(event.key === 'Tab'){ event.preventDefault() } if ( (event.key === 'Enter' || event.key === 'Tab' ) && this.inputValue && (!this.noDuplicates || !this.tags.includes(this.inputValue)) ) { event.preventDefault() this.tags = [...this.tags, this.inputValue]; if (this.showTooltip) { this.tooltips = [...this.tooltips, this.inputValue]; } else { this.tooltips = [...this.tooltips, null]; } this.resetInput(); this.emit('nile-chip-change', { value: this.tags }); } if( (event.key === 'Enter'|| event.key === 'Tab' ) && this.inputValue && (this.noDuplicates || this.tags.includes(this.inputValue)) ){ this.emit('nile-duplicates-blocked'); } } private handleFocus() { if (this.noAutoComplete) { return; } this.visibilityManager?.setup(); this.isDropdownOpen = true; } private handleBlur() { if (this.isSelectingFromDropdown) return; if (this.addOnBlur && this.acceptUserInput && this.inputValue) { this.addChipFromInputValue(); } } onTagsChanged() { if (this.noDuplicates) this.filteredAutoCompleteOptions = this.filteredAutoCompleteOptions.filter(option => !this.tags.includes(option)); } private resetInput() { // Reset the input-related properties this.inputValue = ''; this.isDropdownOpen = false; this.visibilityManager?.cleanup(); if (!this.noAutoComplete && this.autoComplete) { this.autoComplete.value = ''; this.autoComplete.handleFocus(); } } } export default NileChip; declare global { interface HTMLElementTagNameMap { 'nile-chip': NileChip; } }