/** * 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, } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { styles } from './nile-select.css'; import '../nile-icon'; import '../nile-popup/nile-popup'; import '../nile-tag/nile-tag'; import { animateTo, stopAnimations } from '../internal/animate'; import { classMap } from 'lit/directives/class-map.js'; import { query, state } from 'lit/decorators.js'; import { defaultValue } from '../internal/default-value'; import { FormControlController } from '../internal/form'; import { getAnimation, setDefaultAnimation, } from '../utilities/animation-registry'; import { HasSlotController } from '../internal/slot'; import { scrollIntoView } from '../internal/scroll'; import { waitForEvent } from '../internal/event'; import { watch } from '../internal/watch'; import NileElement from '../internal/nile-element'; import type { CSSResultGroup, PropertyValues } from 'lit'; import type { NileFormControl } from '../internal/nile-element'; import type NileOption from '../nile-option/nile-option'; import type NilePopup from '../nile-popup/nile-popup'; import type { RenderItemConfig } from '../nile-virtual-select/types.js'; import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import '../nile-virtual-select/nile-virtual-select'; import { VirtualScrollHelper } from './virtual-scroll-helper'; import NileOptionGroup from '../nile-option-group/nile-option-group'; import { GroupAttributes } from './nile-select.interface'; import { ResizeController } from '@lit-labs/observers/resize-controller.js'; import { PortalManager } from './portal-manager'; import { VisibilityManager } from '../utilities/visibility-manager'; type NileRemoveEvent = CustomEvent>; /** * Nile icon component. * * @tag nile-select * */ /** * @summary Selects allow you to choose items from a menu of predefined options. * @status stable * @since 2.0 * * @dependency nile-icon * @dependency nile-popup * @dependency nile-tag * @dependency nile-virtual-select * * @slot - The listbox options. Must be `` elements. You can use `` to group items visually. * @slot label - The input's label. Alternatively, you can use the `label` attribute. * @slot prefix - Used to prepend a presentational icon or similar element to the combobox. * @slot clear-icon - An icon to use in lieu of the default clear icon. * @slot expand-icon - The icon to show when the control is expanded and collapsed. Rotates on open and close. * @slot help-text - Text that describes how to use the input. Alternatively, you can use the `help-text` attribute. * @slot pre-footer - Content to display at the bottom of the dropdown listbox. Useful for action buttons like "Create New". In multi-select mode, appears above the "Show Selected" / "Clear All" bar. * * @event nile-change - Emitted when the control's value changes. * @event nile-clear - Emitted when the control's value is cleared. * @event nile-input - Emitted when the control receives input. * @event nile-focus - Emitted when the control gains focus. * @event nile-blur - Emitted when the control loses focus. * @event nile-show - Emitted when the select's menu opens. * @event nile-after-show - Emitted after the select's menu opens and all animations are complete. * @event nile-hide - Emitted when the select's menu closes. * @event nile-after-hide - Emitted after the select's menu closes and all animations are complete. * @event nile-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied. * @event nile-search - Emitted when the user types in the search input. The event payload includes the search query for backend search functionality. * @event nile-scroll - Emitted when the user scrolls within the listbox. The event payload includes scroll position information. * @event nile-scroll-start - Emitted when the user starts scrolling within the listbox. * @event nile-scroll-end - Emitted when the user stops scrolling and reaches the bottom of the listbox (debounced). * * @csspart form-control - The form control that wraps the label, input, and help text. * @csspart form-control-label - The label's wrapper. * @csspart form-control-input - The select's wrapper. * @csspart form-control-help-text - The help text's wrapper. * @csspart combobox - The container the wraps the prefix, combobox, clear icon, and expand button. * @csspart prefix - The container that wraps the prefix slot. * @csspart display-input - The element that displays the selected option's label, an `` element. * @csspart listbox - The listbox container where options are slotted. * @csspart tags - The container that houses option tags when `multiselect` is used. * @csspart tag - The individual tags that represent each multiselect option. * @csspart tag__base - The tag's base part. * @csspart tag__content - The tag's content part. * @csspart tag__remove-button - The tag's remove button. * @csspart tag__remove-button__base - The tag's remove button base part. * @csspart clear-button - The clear button. * @csspart expand-icon - The container that wraps the expand icon. * @csspart custom-footer - The container that wraps the custom footer slot content. * @csspart footer-area - The sticky wrapper around the custom footer and the built-in multi-select footer. */ @customElement('nile-select') export class NileSelect extends NileElement implements NileFormControl{ static styles: CSSResultGroup = styles; // protected override BUBBLES=false; private formControlController: FormControlController | null; private virtualScrollHelper!: VirtualScrollHelper; private readonly portalManager = new PortalManager(this); private readonly hasSlotController = new HasSlotController( this, 'help-text', 'label', 'pre-footer' ); private typeToSelectString = ''; private typeToSelectTimeout: number; private scrollTimeout: number | undefined; private scrolling = false; private options: NileOption[] = []; private resizeController?: ResizeController; private visibilityManager?: VisibilityManager; @query('.select') popup: NilePopup; @query('.select__combobox') combobox: HTMLSlotElement; @query('.select__display-input') displayInput: HTMLInputElement; @query('.select__value-input') valueInput: HTMLInputElement; @query('.select__listbox') listbox: HTMLSlotElement; @state() private hasFocus = false; @state() displayLabel = ''; @state() currentOption: NileOption; @state() selectedOptions: NileOption[] = []; @state() oldValue: string | string[] = ''; /** The name of the select, submitted as a name/value pair with form data. */ @property() name = ''; /** * The current value of the select, submitted as a name/value pair with form data. When `multiple` is enabled, the * value will be a space-delimited list of values based on the options selected. */ @property({ converter: { fromAttribute: (value: string) => value.split(' '), toAttribute: (value: string[]) => value.join(' '), }, }) value: string | string[] = ''; /** The default value of the form control. Primarily used for resetting the form control. */ @defaultValue() defaultValue: string | string[] = ''; /** The select's size. */ @property() size: 'small' | 'medium' | 'large' = 'medium'; /** Placeholder text to show as a hint when the select is empty. */ @property() placeholder = 'Select...'; /** Placeholder text to show as a hint when the select is empty. */ @state() searchValue: string = ''; @property({ type: Boolean, reflect: true }) searchEnabled = false; @property({attribute:'internal-search-placeholder'}) internalSearchPlaceHolder = 'Search...'; @property({ type: Boolean, reflect: true, attribute: true }) enableVisibilityEffect = false; @property({ type: Boolean, reflect: true }) blockValueChange = false; @property({ type: Boolean, reflect: true }) disableLocalSearch = false; @property({ type: Boolean, reflect: true }) optionsLoading = false; @property({ type: Boolean, reflect: true }) noWidthSync = false; /** * When true, the listbox 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; /** Allows more than one option to be selected. */ @property({ reflect: true, converter: { fromAttribute(value) { if (value === '' || value === 'true') return true; if (value === 'false') return false; return value; }, toAttribute(value) { return typeof value === 'boolean' ? String(value) : value; } } }) multiple = false as boolean | string; @property({ attribute: true, reflect: true }) helpText = ''; @property({ attribute: 'help-text', reflect: true }) help_text = ''; @property({ type: Boolean, attribute: true, reflect: true }) autoResize = false; @property({ attribute: 'error-message', reflect: true }) errorMessage = ''; /** Sets the input to a warning state, changing its visual appearance. */ @property({ type: Boolean }) warning = 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; /** * The maximum number of selected options to show when `multiple` is true. After the maximum, "+n" will be shown to * indicate the number of additional items that are selected. Set to 0 to remove the limit. */ @property({ attribute: 'max-options-visible', type: Number }) maxOptionsVisible = 3; /** Disables the select control. */ @property({ type: Boolean, reflect: true }) disabled = false; /** Adds a clear button when the select is not empty. */ @property({ type: Boolean }) clearable = false; /** * Indicates whether or not the select is open. You can toggle this attribute to show and hide the menu, or you can * use the `show()` and `hide()` methods and this attribute will reflect the select's open state. */ @property({ type: Boolean, reflect: true }) open = false; /** * Enable this option to prevent the listbox from being clipped when the component is placed inside a container with * `overflow: auto|scroll`. Hoisting uses a fixed positioning strategy that works in many, but not all, scenarios. */ @property({ type: Boolean }) hoist = false; /** Draws a filled select. */ @property({ type: Boolean, reflect: true }) filled = false; /** Draws a pill-style select with rounded edges. */ @property({ type: Boolean, reflect: true }) pill = false; /** The select's label. If you need to display HTML, use the `label` slot instead. */ @property() label = ''; /** * The preferred placement of the select's menu. Note that the actual placement may vary as needed to keep the listbox * inside of the viewport. */ @property({ reflect: true }) placement: 'top' | 'bottom' = 'bottom'; /** * By default, form controls are associated with the nearest containing `
` element. This attribute allows you * to place the form control outside of a form and associate it with the form that has this `id`. The form must be in * the same document or shadow root for this to work. */ @property({ reflect: true }) form = ''; /** The select's required attribute. */ @property({ type: Boolean, reflect: true }) required = false; @property({ type: Boolean }) showSelected = false; @state() oldMaxOptionsVisible: number = 1; @property({ type: Boolean, reflect: true, attribute: true }) enableTabClose = false; @property({ type: Boolean }) showNoResults: boolean = false; @property({ type: String }) noResultsMessage: string = 'No results found'; /** Enhanced configuration for rendering items with support for display text, value, and search text */ @property({ attribute: false }) renderItemConfig?: RenderItemConfig; @property({ type: Array }) data: any = []; @property({ type: Boolean }) enableVirtualScroll = false; /** To enable the group header in the select */ @property({ type: Boolean, reflect: true, attribute: true }) enableGroupHeader = false; /** To auto focus the search input when the select is opened */ @property({ type: Boolean, reflect: true, attribute: true }) autoFocusSearch = false; /** loading indicator for virtual select */ @property({ type: Boolean, reflect: true, attribute: true }) loading = false; @property({ type: Boolean, reflect: true }) stickyHeader = false; @property({ type: Boolean, reflect: true, attribute: true }) descriptionSearchEnabled = false; @property({ type: Boolean, reflect: true, attribute: true }) enableDescription = false; @property({ type: Boolean, reflect: true, attribute: true, converter: { fromAttribute: (value) => value === 'true' || value === '', toAttribute: (value) => String(value), }, }) allowHtmlLabel = true; /** Gets the validity state object */ get validity() { return this.valueInput?.validity; } /** Gets the validation message */ get validationMessage() { return this.valueInput?.validationMessage; } constructor() { super(); } connectedCallback() { super.connectedCallback(); if(!this.enableVirtualScroll) { this.formControlController = new FormControlController(this, { assumeInteractionOn: ['nile-blur', 'nile-input'], }); } this.virtualScrollHelper = new VirtualScrollHelper(this); this.handleDocumentFocusIn = this.handleDocumentFocusIn.bind(this); this.handleDocumentKeyDown = this.handleDocumentKeyDown.bind(this); this.handleDocumentMouseDown = this.handleDocumentMouseDown.bind(this); this.handleWindowResize = this.handleWindowResize.bind(this); this.handleWindowScroll = this.handleWindowScroll.bind(this); // Because this is a form control, it shouldn't be opened initially this.open = false; this.emit('nile-init'); } disconnectedCallback() { super.disconnectedCallback(); // Clear any pending scroll timeout to prevent memory leaks if (this.scrollTimeout) { clearTimeout(this.scrollTimeout); this.scrollTimeout = undefined; } this.visibilityManager?.cleanup(); // Clean up body append elements this.portalManager.cleanupPortalAppend(); this.emit('nile-destroy'); } private setupResizeObserver() { if (this.autoResize) { const tagsContainer = this.shadowRoot?.querySelector('.select__tags'); if (tagsContainer) { this.resizeController = new ResizeController(this, { callback: () => this.calculateTotalWidthOfTags() }); this.resizeController.observe(tagsContainer); } } else { this.resizeController?.unobserve?.( this.shadowRoot?.querySelector('.select__tags') as Element ); this.resizeController = undefined; } } protected updated(_changedProperties: PropertyValues): void { if(_changedProperties.has('searchEnabled')) { this.updateGroupStickyOffsets(); } if(_changedProperties.has('multiple')) { this.setCheckBoxInOption(this.multiple as boolean); } if (_changedProperties.has('autoResize')) { this.setupResizeObserver(); } if(_changedProperties.has('enableDescription')) { this.setEnableDescriptionInOptions(); } } protected firstUpdated(_changedProperties: PropertyValues): void { if(this.enableGroupHeader) { if(!this.disableLocalSearch) { this.handleGroupSearchChange(); } this.applyStickyToGroups(); } this.setupResizeObserver(); if(_changedProperties.has('multiple')) { this.setCheckBoxInOption(this.multiple as boolean); } if (_changedProperties.has('enableDescription')) { this.setEnableDescriptionInOptions(); } this.visibilityManager = new VisibilityManager({ host: this, target: this.combobox, enableVisibilityEffect: this.enableVisibilityEffect, enableTabClose: this.enableTabClose, isOpen: () => this.open, onAnchorOutOfView: () => { this.hide(); this.emit('nile-visibility-change', { visible: false, reason: 'anchor-out-of-view', name: this.name, }); }, onDocumentHidden: () => { this.hide(); this.emit('nile-visibility-change', { visible: false, reason: 'document-hidden', name: this.name, }); }, emit: (event, detail) => this.emit(`nile-${event}`, detail), }); } setCheckBoxInOption(checked: boolean): void { if(!this.options.length) { this.options = this.getAllOptions(); } this.options.forEach((option: NileOption) => { option.showCheckbox = checked; }) } setEnableDescriptionInOptions() { this.options = this.getAllOptions(); this.options.forEach((option: NileOption) => { (option?.updateComplete ?? Promise.resolve()) .then(() => { option.isDescriptionEnabled = this.enableDescription; }) .catch(() => { // ignore individual option update failures }); }); } private addOpenListeners() { document.addEventListener('focusin', this.handleDocumentFocusIn); document.addEventListener('keydown', this.handleDocumentKeyDown); document.addEventListener('mousedown', this.handleDocumentMouseDown); if (this.portal) { window.addEventListener('resize', this.handleWindowResize); window.addEventListener('scroll', this.handleWindowScroll, true); } } private removeOpenListeners() { document.removeEventListener('focusin', this.handleDocumentFocusIn); document.removeEventListener('keydown', this.handleDocumentKeyDown); document.removeEventListener('mousedown', this.handleDocumentMouseDown); window.removeEventListener('resize', this.handleWindowResize); window.removeEventListener('scroll', this.handleWindowScroll, true); } private handleFocus() { this.hasFocus = true; this.displayInput.setSelectionRange(0, 0); this.emit('nile-focus', { value: this.value, name: this.name }); } private handleBlur() { this.hasFocus = false; this.emit('nile-blur', { value: this.value, name: this.name }); } private handleDocumentFocusIn(event: FocusEvent) { if (!this.open) return; const path = event.composedPath(); const hitSelf = path.includes(this); const hitPopup = this.popup && path.includes(this.popup); const hitPortalAppend = this.portal && this.portalManager.portalContainerElement && path.includes(this.portalManager.portalContainerElement); if (!hitSelf && !hitPopup && !hitPortalAppend) { this.hide(); } } private updateGroupStickyOffsets() { const groups = this.querySelectorAll('nile-option-group'); groups.forEach(g => { if (this.searchEnabled) { g.setAttribute('search-enabled', ''); } else { g.removeAttribute('search-enabled'); } }); } /** * Handles the click event on the footer. * @param event - The click event. */ private handleFooterClick(event: MouseEvent): void { event.stopPropagation(); event.preventDefault(); } private handlePreFooterClick(): void { this.hide(); } /** * Toggles the display of selected options in the dropdown * @param event - the event object */ toggleShowSelected(event: Event): void { event.stopPropagation(); event.preventDefault(); // Only toggle if there are selected options if (this.selectedOptions?.length === 0) { return; } // Toggle the showSelected property this.showSelected = !this.showSelected; // Hide unselected options if showSelected is true const allOptions = this.getAllOptions(); allOptions.forEach(el => { if (!el.selected) { el.hidden = this.showSelected; } }); if(this.enableGroupHeader) { this.handleGroupShowSelected(); } // Update portal content if portal is active if (this.portal && this.open) { this.portalManager.updatePortalOptions(); } // this.requestUpdate(); } /** * Deselects all options and updates the value, selectedOptions, and displayLabel properties accordingly */ unSlectAll(): void { this.showSelected = false; const allOptions = this.getAllOptions(); allOptions.forEach(el => { el.selected = false; el.hidden = false; }); if(this.enableGroupHeader) { this.handleGroupShowSelected(); } this.value = ''; this.selectionChanged(); this.emit('nile-change', { value: this.value, name: this.name }); this.emit('nile-clear',{ value: this.multiple?this.value:'', name: this.name }) // Update portal content if portal is active if (this.portal && this.open) { this.portalManager.updatePortalOptions(); } } private handleDocumentKeyDown(event: KeyboardEvent) { const target = event.target as HTMLElement; const isClearButton = target.closest('.select__clear') !== null; const isIconButton = target.closest('nile-icon-button') !== null; // Ignore presses when the target is an icon button (e.g. the remove button in ) if (isClearButton || isIconButton) { return; } // Close when pressing escape if (event.key === 'Escape' && this.open) { event.preventDefault(); event.stopPropagation(); this.hide(); this.displayInput.focus({ preventScroll: true }); } // Handle enter and space. When pressing space, we allow for type to select behaviors so if there's anything in the // buffer we _don't_ close it. if ( event.key === 'Enter' || (event.key === ' ' && this.typeToSelectString === '') ) { event.preventDefault(); event.stopImmediatePropagation(); // If it's not open, open it if (!this.open) { this.show(); return; } // If it is open, update the value based on the current selection and close it if (this.currentOption && !this.currentOption.disabled) { if (this.multiple) { this.toggleOptionSelection(this.currentOption); } else { this.setSelectedOptions(this.currentOption); } // Emit after updating this.updateComplete.then(() => { this.nileInput({ value: this.value, name: this.name }) this.nileChange({ value: this.value, name: this.name }) }); if (!this.multiple) { this.hide(); this.displayInput.focus({ preventScroll: true }); } } return; } // All other "printable" keys trigger type to select if (event.key.length === 1 || event.key === 'Backspace') { const allOptions = this.getAllOptions(); // Don't block important key combos like CMD+R if (event.metaKey || event.ctrlKey || event.altKey) { return; } // Open, unless the key that triggered is backspace if (!this.open) { if (event.key === 'Backspace') { return; } this.show(); } event.stopPropagation(); event.preventDefault(); clearTimeout(this.typeToSelectTimeout); this.typeToSelectTimeout = window.setTimeout( () => (this.typeToSelectString = ''), 1000 ); if (event.key === 'Backspace') { this.typeToSelectString = this.typeToSelectString.slice(0, -1); } else { this.typeToSelectString += event.key.toLowerCase(); } for (const option of allOptions) { const label = option.getTextLabel().toLowerCase(); if (label.startsWith(this.typeToSelectString)) { this.setCurrentOption(option); break; } } } } private handleDocumentMouseDown(event: MouseEvent) { if (!this.open) return; const path = event.composedPath(); const hitSelf = path.includes(this); const hitPopup = this.popup && path.includes(this.popup); const hitPortalAppend = this.portal && this.portalManager.portalContainerElement && path.includes(this.portalManager.portalContainerElement); if (!hitSelf && !hitPopup && !hitPortalAppend) { this.hide(); } } private handleWindowResize = (): void => { this.portalManager.updatePortalAppendPosition(); }; private handleWindowScroll = (): void => { this.portalManager.updatePortalAppendPosition(); }; private handleLabelClick() { this.displayInput.focus(); this.hide(); } private handleComboboxMouseDown(event: MouseEvent) { const path = event.composedPath(); const isIconButton = path.some( el => el instanceof Element && el.tagName.toLowerCase() === 'nile-icon-button' ); // Ignore disabled controls and clicks on tags (remove buttons) if (this.disabled || isIconButton) { return; } event.preventDefault(); this.displayInput.focus({ preventScroll: true }); this.open = !this.open; } private handleComboboxKeyDown(event: KeyboardEvent) { event.stopPropagation(); this.handleDocumentKeyDown(event); } private handleClearClick(event: MouseEvent) { event.stopPropagation(); if (this.value !== '') { this.setSelectedOptions([]); this.showSelected = false; this.value = ''; this.displayInput.focus({ preventScroll: true }); if(this.enableGroupHeader) { this.handleGroupShowSelected(); } // Emit after update this.updateComplete.then(() => { const val={ value: this.multiple?this.value:'', name: this.name } this.emit('nile-clear', val); this.nileInput(val); this.nileChange(val); }); } } private handleClearMouseDown(event: MouseEvent) { // Don't lose focus or propagate events when clicking the clear button event.stopPropagation(); event.preventDefault(); } handleOptionClick(event: MouseEvent) { const target = event.target as HTMLElement; const option = target.closest('nile-option'); if (this.blockValueChange && option) { this.emit('nile-block-change', { value: option?.value, name: this.name }); this.hide(); return; } const oldValue = this.value; this.oldValue = oldValue; if (option && !option.disabled) { if (this.multiple) { this.toggleOptionSelection(option); } else { this.setSelectedOptions(option); } // Set focus after updating so the value is announced by screen readers this.updateComplete.then(() => this.displayInput.focus({ preventScroll: true }) ); if (this.value !== oldValue) { // Emit after updating this.updateComplete.then(() => { this.nileInput({ value: this.value, name: this.name }); this.nileChange({ value: this.value, name: this.name }); }); } if (!this.multiple) { this.hide(); this.displayInput.focus({ preventScroll: true }); } } if (this.showSelected) { const allOptions = this.getAllOptions(); allOptions.forEach(el => { if (!el.selected) { el.hidden = this.showSelected; } }); if(this.enableGroupHeader) { this.handleGroupShowSelected(); } this.requestUpdate(); } // Update portal content if portal is active if (this.portal && this.open) { this.portalManager.updatePortalOptions(); } } private applyStickyToGroups() { const groups = this.querySelectorAll('nile-option-group'); groups.forEach(g => { if (this.stickyHeader) { g.setAttribute('sticky', ''); } else { g.removeAttribute('sticky'); } }); } private handleDefaultSlotChange() { const allOptions = this.getAllOptions(); const value = Array.isArray(this.value) ? this.value : [this.value]; const values: string[] = []; this.updateGroupStickyOffsets(); this.applyStickyToGroups(); // Check for duplicate values in menu items if (customElements.get('nile-option')) { allOptions.forEach(option => values.push(option.value)); // Select only the options that match the new value this.setSelectedOptions( allOptions.filter(el => value.map(String).includes(el.value)) ); } else { // Rerun this handler when is registered customElements .whenDefined('nile-option') .then(() => this.handleDefaultSlotChange()); } // Update portal content if portal is active if (this.portal && this.open) { this.portalManager.updatePortalOptions(); } this.setEnableDescriptionInOptions(); } private handleTagRemove(event: NileRemoveEvent, option: NileOption) { event.stopPropagation(); if (!this.disabled) { this.toggleOptionSelection(option, false); const allOptions = this.getAllOptions(); allOptions.forEach(el => { if (!el.selected) { el.hidden = this.showSelected; } }); if(this.enableGroupHeader) { this.handleGroupShowSelected(); } // Emit after updating this.updateComplete.then(() => { this.nileInput( { value: this.value, name: this.name }); this.nileChange( { value: this.value, name: this.name }); this.emit('nile-tag-remove', { value: this.value, name: this.name,removedtagvalue:option.value }); }); } } // Gets an array of all elements private getAllOptions() { // Get all options as an array const options = [...this.querySelectorAll('nile-option')]; // Sort the options based on the order of values selected if (this.multiple && this.oldValue?.length > 0) { options.sort((a, b) => { let indexA = this.oldValue.indexOf(a.value); let indexB = this.oldValue.indexOf(b.value); // Handle cases where a __value is not found if (indexA === -1) { indexA = Infinity; // Place at the end if not found } if (indexB === -1) { indexB = Infinity; // Place at the end if not found } if (indexA < indexB) { return -1; } if (indexA > indexB) { return 1; } return 0; }); } return options; } private getOptionPrefix(option: NileOption): string { const prefixSlot = option.shadowRoot?.querySelector( 'slot[name="prefix"]' ) as HTMLSlotElement; if (!prefixSlot) return ''; const assignedNodes = prefixSlot.assignedNodes(); const htmlStrings: string[] = []; assignedNodes.forEach(node => { if (node instanceof HTMLElement) { htmlStrings.push(node.outerHTML); } else if (node.nodeType === Node.TEXT_NODE) { // Text node htmlStrings.push(node.textContent || ''); } }); return htmlStrings.join(''); } // Gets the first element private getFirstOption() { return this.querySelector('nile-option'); } // Sets the current option, which is the option the user is currently interacting with (e.g. via keyboard). Only one // option may be "current" at a time. private setCurrentOption(option: NileOption | null) { const allOptions = this.getAllOptions(); // Clear selection allOptions.forEach(el => { el.current = false; el.tabIndex = -1; }); // Select the target option if (option) { this.currentOption = option; option.current = true; option.tabIndex = 0; option.focus(); } } // Sets the selected option(s) private setSelectedOptions(option: NileOption | NileOption[]) { const allOptions = this.getAllOptions(); const newSelectedOptions = Array.isArray(option) ? option : [option]; // Clear existing selection allOptions.forEach(el => (el.selected = false)); // Set the new selection if (newSelectedOptions.length) { newSelectedOptions.forEach(el => (el.selected = true)); } // Update selection, value, and display label this.selectionChanged(); // Update portal content if portal is active if (this.portal && this.open) { this.portalManager.updatePortalOptions(); } } // Toggles an option's selected state private toggleOptionSelection(option: NileOption, force?: boolean) { if (force === true || force === false) { option.selected = force; } else { option.selected = !option.selected; } this.selectionChanged(); } // This method must be called whenever the selection changes. It will update the selected options cache, the current // value, and the display value private selectionChanged() { // Update selected options cache this.selectedOptions = this.getAllOptions().filter(el => el.selected); // Update the value and display label if (this.multiple) { this.value = this.selectedOptions.map(el => el.value); if (this.placeholder && this.value.length === 0) { // When no items are selected, keep the value empty so the placeholder shows this.displayLabel = ''; } else { this.displayLabel = this.selectedOptions.length + ' selected'; } if (this.selectedOptions.length === 0) { this.showSelected = false; const allOptions = this.getAllOptions(); allOptions.forEach(el => { if (!el.selected) { el.hidden = this.showSelected; } else {} }); this.requestUpdate(); } } else { this.value = this.selectedOptions[0]?.value ?? this.value; this.displayLabel = this.selectedOptions[0]?.getTextLabel() ? this.selectedOptions[0].getTextLabel() : this.value ?? ''; } // Update validity this.updateComplete.then(() => { this.formControlController?.updateValidity(); }); this.calculateTotalWidthOfTags(); } handleSearchFocus() { document.removeEventListener('keydown', this.handleDocumentKeyDown); } handleSearchBlur() { document.addEventListener('keydown', this.handleDocumentKeyDown); } handleSearchChange(e: any) { this.searchValue = e.detail.value; this.emit('nile-search', { query: this.searchValue, name: this.name }); if(this.enableGroupHeader && !this.disableLocalSearch) { this.handleGroupSearchChange(); } if (!this.disableLocalSearch) { let filteredOptions = this.filterOptions(this.searchValue); if (filteredOptions.length === 0) { // Display 'No results found' message. this.showNoResults = true; } else { this.showNoResults = false; } } } handleScroll(e: Event): void { const target = e.target as HTMLElement; this.emit('nile-scroll', { scrollTop: target.scrollTop, scrollLeft: target.scrollLeft, name: this.name }); if (!this.scrolling) { this.scrolling = true; this.emit('nile-scroll-start', { scrollTop: target.scrollTop, scrollLeft: target.scrollLeft, name: this.name }); } clearTimeout(this.scrollTimeout); this.scrollTimeout = window.setTimeout(() => { if (this.scrolling) { this.scrolling = false; } }, 300); const isAtBottom = Math.ceil(target.scrollTop) >= Math.floor(target.scrollHeight - target.offsetHeight); if (isAtBottom) { this.emit('nile-scroll-end', { scrollTop: target.scrollTop, scrollLeft: target.scrollLeft, name: this.name, isAtBottom: true }); } } filterOptions(searchValue: string) { const allOptions = this.getAllOptions(); const lowerCaseSearchValue = searchValue.toLowerCase(); let filteredOptions: NileOption[] = []; allOptions.forEach(el => { const lowerCaseLabel = el.getTextLabel().toLowerCase(); const lowerCaseValue = (el.value || '').toLowerCase(); const lowerCaseDescription = (el.description || '').toLowerCase(); const matches = lowerCaseLabel.includes(lowerCaseSearchValue) || lowerCaseValue.includes(lowerCaseSearchValue) || (this.descriptionSearchEnabled && lowerCaseDescription.includes(lowerCaseSearchValue)); el.hidden = !matches; if (matches) filteredOptions.push(el); }); // Update portal content if portal is active if (this.portal && this.open) { this.portalManager.updatePortalOptions(); } return filteredOptions; } private handleInvalid(event: Event) { this.formControlController?.setValidity(false); this.formControlController?.emitInvalidEvent(event); } @watch('disabled', { waitUntilFirstUpdate: true }) handleDisabledChange() { // Close the listbox when the control is disabled if (this.disabled) { this.open = false; this.handleOpenChange(); } } @watch('value', { waitUntilFirstUpdate: true }) handleValueChange() { const allOptions = this.getAllOptions(); const value = Array.isArray(this.value) ? this.value : [this.value]; if(!this.enableVirtualScroll) { this.setSelectedOptions(allOptions.filter(el => value.includes(el.value))); } } @watch('portal', { waitUntilFirstUpdate: true }) handlePortalAppendChange(): void { if (this.open) { if (this.portal) { this.portalManager.setupPortalAppend(); } else { this.portalManager.cleanupPortalAppend(); } } } @watch('open', { waitUntilFirstUpdate: true }) async handleOpenChange() { if (this.open && !this.disabled) { // Reset the current option this.setCurrentOption(this.selectedOptions[0] || this.getFirstOption()); this.emit('nile-show', { value: this.value, name: this.name }); if(this.enableGroupHeader) { this.getAllGroupAttributes().forEach((el: GroupAttributes) => { el.element.classList.remove('nile-group-hidden'); }); } this.addOpenListeners(); this.visibilityManager?.setup(); this.showNoResults = !this.getAllOptions()?.length; await stopAnimations(this); this.listbox.hidden = false; this.popup.active = true; // Select the appropriate option based on value after the listbox opens requestAnimationFrame(() => { this.setCurrentOption(this.currentOption); }); const { keyframes, options } = getAnimation(this, 'select.show', { dir: 'ltr', }); await animateTo(this.popup.popup, keyframes, options); // Make sure the current option is scrolled into view (required for Safari) if (this.currentOption) { scrollIntoView(this.currentOption, this.listbox, 'vertical', 'auto'); } this.searchValue = ''; this.filterOptions(this.searchValue); if (this.portal) { this.portalManager.setupPortalAppend(); } this.emit('nile-after-show', { value: this.value, name: this.name }); if (this.autoFocusSearch) { this.handleInputAfterInit(); } } else { // Hide this.emit('nile-hide', { value: this.value, name: this.name }); this.showSelected = false; this.removeOpenListeners(); this.visibilityManager?.cleanup(); await stopAnimations(this); const { keyframes, options } = getAnimation(this, 'select.hide', { dir: 'ltr', }); await animateTo(this.popup.popup, keyframes, options); this.listbox.hidden = true; this.popup.active = false; if (this.portal) { this.portalManager.cleanupPortalAppend(); } this.searchValue = ''; this.emit('nile-after-hide', { value: this.value, name: this.name }); } } private getAllGroupAttributes() { return Array.from( this.querySelectorAll('nile-option-group[name]') ).map((el: HTMLElement) => ({ name: el?.getAttribute('name') || '', data: el?.getAttribute('data'), element: el })); } getUniqueGroupNames(arr: NileOption[]) { return Array.from(new Set(arr.map(obj => obj?.groupName))); } handleGroupSearchChange() { const filtered = new Set( this.getUniqueGroupNames(this.filterOptions(this.searchValue)) ); this.getAllGroupAttributes().forEach(({ name, element }) => { element?.classList.toggle('nile-group-hidden', !filtered.has(name)); }); } handleGroupShowSelected() { const visibleGroups = new Set(); if (this.showSelected) { this.getAllOptions().forEach((option: NileOption) => { if (!option.hidden) visibleGroups.add(option.groupName); }); } this.getAllGroupAttributes().forEach(({ name, element }) => { element?.classList.toggle( 'nile-group-hidden', this.showSelected && !visibleGroups.has(name) ); }); } /** Shows the listbox. */ async show() { if (this.open || this.disabled) { this.open = false; return undefined; } this.open = true; return waitForEvent(this, 'nile-after-show'); } /** Hides the listbox. */ async hide() { if (!this.open || this.disabled) { this.open = false; return undefined; } this.open = false; return waitForEvent(this, 'nile-after-hide'); } /** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */ checkValidity() { return this.valueInput.checkValidity(); } /** Gets the associated form, if one exists. */ getForm(): HTMLFormElement | null { return this.formControlController?.getForm() || null; } /** Checks for validity and shows the browser's validation message if the control is invalid. */ reportValidity() { return this.valueInput.reportValidity(); } /** Sets a custom validation message. Pass an empty string to restore validity. */ setCustomValidity(message: string) { this.valueInput.setCustomValidity(message); this.formControlController?.updateValidity(); } /** Sets focus on the control. */ focus(options?: FocusOptions) { this.displayInput.focus(options); } /** Removes focus from the control. */ blur() { this.displayInput.blur(); } onInputChange(event: Event) { // This will stop the event from bubbling up to the parent `nile-select` component event.stopPropagation(); } calculateWidthOfSelectTagsDiv() { if (this.shadowRoot) { const selectTagsDiv = this.shadowRoot.querySelector('div.select__tags'); if (selectTagsDiv instanceof HTMLElement) { const width = selectTagsDiv.offsetWidth; return width - 70; } } } calculateTotalWidthOfTags() { if (this.maxOptionsVisible !== Infinity) { this.oldMaxOptionsVisible = this.maxOptionsVisible; } this.maxOptionsVisible = Infinity; setTimeout(() => { let widths: number[] = []; if (this.shadowRoot) { const tags = this.shadowRoot.querySelectorAll('nile-tag'); tags.forEach(tag => { if (tag instanceof HTMLElement) { widths.push(tag.offsetWidth); } }); } if (this.value.length !== widths.length) { return; } let sum = widths.reduce( (accumulator, currentValue) => accumulator + currentValue, 0 ); const widthOfSelectTagsDiv = this.calculateWidthOfSelectTagsDiv(); if (!widthOfSelectTagsDiv) { return; } let summ = 0; let lastindex = 0; for (let i = 0; i < widths.length; i++) { summ += widths[i]; if (summ > widthOfSelectTagsDiv) { lastindex = i; break; } } this.maxOptionsVisible = lastindex; }, 1); } handleInputAfterInit() { this.shadowRoot?.querySelector('nile-input')?.inputFocus(); } render() { if(this.enableVirtualScroll) { return this.virtualScrollHelper.renderVirtualizedContent(); } const hasLabelSlot = this.hasSlotController.test('label'); const hasHelpTextSlot = this.hasSlotController.test('help-text'); const hasLabelSuffixSlot = this.hasSlotController.test('label-suffix'); const hasCustomSelect = this.hasSlotController.test('custom-select'); const hasLabel = this.label ? true : !!hasLabelSlot; const hasClearIcon = this.clearable && !this.disabled && this.value.length > 0; const isPlaceholderVisible = this.placeholder && this.value.length === 0; const hasHelpText = (this.helpText || this.help_text) ? true : false; const hasErrorMessage = this.errorMessage ? true : false; const prefixContent = this.selectedOptions[0] ? this.getOptionPrefix(this.selectedOptions[0]) : ''; return html`
${hasLabelSuffixSlot ? html` ` : ``}
${hasCustomSelect ? html`` : html``}
${prefixContent && !this.multiple ? html`
${unsafeHTML(prefixContent)}
` : html``} ${this.multiple ? html`
${this.selectedOptions.map((option, index) => { if ( index < this.maxOptionsVisible || this.maxOptionsVisible <= 0 ) { const classes = { select__invisible: index + 1 > this.oldMaxOptionsVisible && this.maxOptionsVisible === Infinity, }; return html` this.handleTagRemove(event, option)} > ${this.getOptionPrefix(option) ? html`${unsafeHTML( this.getOptionPrefix(option) )}` : ''} ${option.getTextLabel()} `; } else if (index === this.maxOptionsVisible) { return html` +${this.selectedOptions.length - index} More `; } else { return null; } })}
` : ''} this.focus()} @invalid=${this.handleInvalid} /> ${hasClearIcon ? html` ` : ''}
${this.searchEnabled ? html` ` : ``} ${this.optionsLoading ? html` ` : ''}
${this.showNoResults ? html`
${this.noResultsMessage}
` : ''}
${this.hasSlotController.test('pre-footer') || this.multiple ? html`` : ``}
${hasHelpText ? html` ${(this.helpText || this.help_text)} ` : ``} ${hasErrorMessage ? html` ${this.errorMessage} ` : ``}
`; } nileInput(value:any){ this.emit('nile-input',value) } nileChange(value:any){ this.emit('nile-change',value) } } setDefaultAnimation('select.show', { keyframes: [ { opacity: 0, scale: 0.9 }, { opacity: 1, scale: 1 }, ], options: { duration: 100, easing: 'ease' }, }); setDefaultAnimation('select.hide', { keyframes: [ { opacity: 1, scale: 1 }, { opacity: 0, scale: 0.9 }, ], options: { duration: 100, easing: 'ease' }, }); export default NileSelect; declare global { interface HTMLElementTagNameMap { 'nile-select': NileSelect; } }