/** * 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 '../nile-lite-tooltip/nile-lite-tooltip'; 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 { KeyCode } from '../internal/enum'; 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 { 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 observedTagsContainer?: Element; private resizeController = new ResizeController(this, { target: null, callback: (entries: ResizeObserverEntry[]) => { for (const entry of entries) { if (entry.target.classList.contains('select__tags')) { this.calculateTotalWidthOfTags(); } } } }); private visibilityManager?: VisibilityManager; private chipFitFrame = 0; private measurableWatch?: { observer: ResizeObserver; target: Element }; private lastChipLayout = { hidden: -1, visible: -1 }; @state() private clippedChips: boolean[] = []; private clipFrame = 0; @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[] = ''; @state() private hiddenChipCount = 0; @state() oldMaxOptionsVisible: number = 1; /** Last measured width of the overflow button, in px. */ private lastOverflowWidth = 0; /** 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({ type: Boolean, reflect: true }) legacy = false; /** Variant: show a tick on the right of the selected option (single-select). Propagated to options. */ @property({ type: Boolean, reflect: true, attribute: true }) tickOnSelect = 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 = ''; /** Re-runs the fitted-chip layout when the control's own width changes. */ @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. */ private _maxOptionsVisible = 3; private maxOptionsVisibleExplicit = false; @property({ attribute: 'max-options-visible', type: Number }) get maxOptionsVisible(): number { return this._maxOptionsVisible; } set maxOptionsVisible(value: number) { const previous = this._maxOptionsVisible; this._maxOptionsVisible = value; // Any assignment opts this instance out of automatic chip fitting. this.maxOptionsVisibleExplicit = true; this.requestUpdate('maxOptionsVisible', previous); } private get chipFittingEnabled(): boolean { return !!this.multiple && this.showTooltip && !this.maxOptionsVisibleExplicit; } private get legacyTagFitEnabled(): boolean { return !!this.multiple && !this.showTooltip; } private setFittedMaxOptionsVisible(value: number): void { const previous = this._maxOptionsVisible; this._maxOptionsVisible = value; this.requestUpdate('maxOptionsVisible', previous); } /** Minimum width, in px, of a squeezed chip. Needs `showtooltip`. */ @property({ type: Number, attribute: true }) chipMinWidth = 110; /** Maximum width, in px, of a squeezed chip. Needs `showtooltip`. */ @property({ type: Number, attribute: true }) chipMaxWidth = 180; /** * Opts into the fitted chip row: chips are sized between chipMinWidth and chipMaxWidth, the * rest collapse into a +N button, and a clipped chip shows its full value in a tooltip. * Off by default, which keeps the pre-2.0.9 chip row. */ @property({ type: Boolean, attribute: true, reflect: true }) showTooltip = false; /** 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; @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; if (this.hasUpdated && this.chipFittingEnabled) { this.calculateTotalWidthOfTags(); } 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.destroyChipFit(); this.emit('nile-destroy'); } private setupResizeObserver(): void { const wanted = this.autoResize && (this.chipFittingEnabled || this.legacyTagFitEnabled) ? (this.shadowRoot?.querySelector('.select__tags') as HTMLElement | null) ?? undefined : undefined; if (wanted === this.observedTagsContainer) { if (!this.chipFittingEnabled) this.destroyChipFit(); return; } if (this.observedTagsContainer) { this.resizeController.unobserve(this.observedTagsContainer); this.observedTagsContainer = undefined; } if (wanted) { this.resizeController.observe(wanted); this.observedTagsContainer = wanted; return; } if (!this.chipFittingEnabled) { this.destroyChipFit(); } } private syncVirtualSelectMaxOptionsVisible() { if (!this.enableVirtualScroll || !this.maxOptionsVisibleExplicit) return; const inner = this.shadowRoot?.querySelector('nile-virtual-select') as | { maxOptionsVisible: number } | null; if (inner && inner.maxOptionsVisible !== this._maxOptionsVisible) { inner.maxOptionsVisible = this._maxOptionsVisible; } } syncValueFromVirtualSelect(event: CustomEvent<{ value?: string | string[] }>) { if (!this.showTooltip) return; if (event.composedPath()[0] !== event.currentTarget) return; if (!event.detail || !('value' in event.detail)) return; this.value = event.detail.value ?? (this.multiple ? [] : ''); } protected willUpdate(changedProperties: PropertyValues): void { super.willUpdate(changedProperties); if ( changedProperties.has('showTooltip') && changedProperties.get('showTooltip') === false && this.showTooltip && this.enableVirtualScroll && !changedProperties.has('value') ) { const inner = this.shadowRoot?.querySelector('nile-virtual-select') as | { value: string | string[] } | null; if (inner && inner.value !== this.value) { this.value = inner.value; } } } protected updated(_changedProperties: PropertyValues): void { if(_changedProperties.has('searchEnabled')) { this.updateGroupStickyOffsets(); } if(_changedProperties.has('multiple')) { this.setCheckBoxInOption(this.multiple as boolean); } if(_changedProperties.has('legacy')) { this.setLegacyInOptions(this.legacy); } if(_changedProperties.has('tickOnSelect')) { this.setTickOnSelectInOptions(this.tickOnSelect); } if ( _changedProperties.has('autoResize') || _changedProperties.has('multiple') || _changedProperties.has('maxOptionsVisible') || _changedProperties.has('showTooltip') ) { this.setupResizeObserver(); } if ( _changedProperties.has('showTooltip') && _changedProperties.get('showTooltip') !== undefined && this.legacyTagFitEnabled ) { this.calculateTotalWidthOfTags(); } this.syncVirtualSelectMaxOptionsVisible(); if(_changedProperties.has('enableDescription')) { this.setEnableDescriptionInOptions(); } if ( _changedProperties.has('chipMinWidth') || _changedProperties.has('chipMaxWidth') ) { this.destroyChipFit(); } if (this.chipFittingEnabled) { this.calculateTotalWidthOfTags(); } if (this.multiple && this.showTooltip) { this.scheduleClipCheck(); } } 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); } this.setLegacyInOptions(this.legacy); this.setTickOnSelectInOptions(this.tickOnSelect); 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; }) } /** Propagates the legacy opt-out flag down to the slotted options. */ setLegacyInOptions(enabled: boolean): void { if(!this.options.length) { this.options = this.getAllOptions(); } this.options.forEach((option: NileOption) => { option.legacy = enabled; }) } /** Propagates the tick-on-select variant down to the slotted options. */ setTickOnSelectInOptions(enabled: boolean): void { if(!this.options.length) { this.options = this.getAllOptions(); } this.options.forEach((option: NileOption) => { option.tickOnSelect = enabled; }) } 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' ); const isOverflowButton = path.some( el => el instanceof HTMLButtonElement && el.classList.contains('select__tags-count') ); // Ignore disabled controls and clicks on tags (remove buttons) if (this.disabled || isIconButton || isOverflowButton) { 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 prefixCopies = new WeakMap>(); private getOptionPrefixNodes(option: NileOption, site: 'trigger' | 'chip'): Node[] { const prefixSlot = option.shadowRoot?.querySelector( 'slot[name="prefix"]' ) as HTMLSlotElement | null; if (!prefixSlot) return []; const sources = prefixSlot.assignedElements(); const markup = sources.map(el => el.outerHTML).join(''); let sites = this.prefixCopies.get(option); if (!sites) { sites = new Map(); this.prefixCopies.set(option, sites); } const cached = sites.get(site); if (cached && cached.markup === markup) return cached.nodes; const nodes = sources.map(el => el.cloneNode(true)); sites.set(site, { markup, nodes }); return nodes; } // 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) { const hasOptions = !this.showTooltip || this.getAllOptions().length > 0; if (hasOptions) { 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(); } calculateTotalWidthOfTags() { if (this.legacyTagFitEnabled) { this.fitTagsLegacy(); return; } if (!this.chipFittingEnabled) return; if (this.chipFitFrame) cancelAnimationFrame(this.chipFitFrame); this.chipFitFrame = requestAnimationFrame(() => { this.chipFitFrame = 0; this.layoutChips(); }); } private calculateWidthOfSelectTagsDiv() { if (this.shadowRoot) { const selectTagsDiv = this.shadowRoot.querySelector('div.select__tags'); if (selectTagsDiv instanceof HTMLElement) { const width = selectTagsDiv.offsetWidth; return width - 70; } } } private fitTagsLegacy() { if (this.maxOptionsVisible !== Infinity) { this.oldMaxOptionsVisible = this.maxOptionsVisible; } this.setFittedMaxOptionsVisible(Infinity); setTimeout(() => { const 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; } 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.setFittedMaxOptionsVisible(lastindex); }, 1); } private allocateChipWidths( natural: number[], available: number ): number[] | null { const min = this.chipMinWidth; const max = this.chipMaxWidth; const total = natural.reduce((sum, w) => sum + w, 0); if (total <= available) { return natural.slice(); } // Nothing reveals a truncated value without a tooltip, so chips keep their // natural width and the surplus moves into the +N button instead. if (!this.showTooltip) return null; const capped = natural.map(w => Math.min(w, max)); const cappedTotal = capped.reduce((sum, w) => sum + w, 0); if (cappedTotal <= available) { return capped; } const widths = new Array(natural.length).fill(0); let pool = capped.map((_, i) => i); let remaining = available; for (;;) { if (pool.length === 0) break; const share = remaining / pool.length; const settled = pool.filter(i => capped[i] <= share); if (settled.length === 0) break; for (const i of settled) { widths[i] = capped[i]; remaining -= capped[i]; } pool = pool.filter(i => capped[i] > share); } if (pool.length > 0) { const share = remaining / pool.length; if (share < min) { return null; } const width = Math.floor(share); for (const i of pool) { widths[i] = width; } } return widths; } private computeChipFit( natural: number[], available: number, overflowWidth: number, gap: number ): { visibleCount: number; widths: number[]; hiddenCount: number } { const total = natural.length; if (total === 0) { return { visibleCount: 0, widths: [], hiddenCount: 0 }; } for (let count = total; count >= 1; count--) { const hidden = total - count; const reserved = hidden > 0 ? overflowWidth + gap : 0; const row = available - reserved - gap * (count - 1); if (row <= 0) continue; const widths = this.allocateChipWidths(natural.slice(0, count), row); if (widths) { return { visibleCount: count, widths, hiddenCount: hidden }; } } const room = available - overflowWidth - gap; const fallback = Math.max( 0, // A single chip too wide for the row has to be clipped either way; only // the tooltip variant squeezes it to chipMinWidth. Math.min(natural[0], this.showTooltip ? Math.max(this.chipMinWidth, room) : room) ); return { visibleCount: 1, widths: [fallback], hiddenCount: total - 1 }; } private layoutChips(): void { const root = this.shadowRoot; if (!root) return; const container = root.querySelector('.select__tags') as HTMLElement | null; if (!container) return; const chips = Array.from( container.querySelectorAll('nile-tag') ) as HTMLElement[]; if (chips.length === 0) { this.hiddenChipCount = 0; return; } if (container.clientWidth === 0) { this.refitWhenMeasurable(container); return; } this.stopMeasurableWatch(); const overflowEl = container.querySelector( '.select__tags-count' ) as HTMLElement | null; for (const chip of chips) { chip.style.display = ''; chip.style.width = 'auto'; chip.style.maxWidth = 'none'; chip.style.minWidth = '0'; chip.style.flex = '0 0 auto'; } const natural = chips.map(chip => Math.ceil(chip.getBoundingClientRect().width) ); // Not rendered yet on the pass that first finds an overflow; reuse the last width. const overflowWidth = overflowEl ? Math.ceil(overflowEl.getBoundingClientRect().width) : this.lastOverflowWidth; if (overflowEl) this.lastOverflowWidth = overflowWidth; const containerStyles = getComputedStyle(container); const gap = parseFloat(containerStyles.columnGap || containerStyles.gap || '0') || 0; const fit = this.computeChipFit( natural, container.clientWidth, overflowWidth, gap ); chips.forEach((chip, index) => { if (index < fit.visibleCount) { // Never squeeze a chip below chipMinWidth: narrower than that it is // also narrower than the tag's own box - the label is already at zero // while the remove button is fixed - so the box overflows the chip and // paints the X over the next chip or the +N button. A chip whose // natural width is already smaller is left alone, not padded out. const width = Math.min( natural[index], Math.max(fit.widths[index], this.chipMinWidth) ); chip.style.display = ''; chip.style.width = `${width}px`; chip.style.maxWidth = `${width}px`; chip.style.flex = '0 0 auto'; } else { chip.style.display = 'none'; } }); if ( this.lastChipLayout.hidden !== fit.hiddenCount || this.lastChipLayout.visible !== fit.visibleCount ) { this.lastChipLayout = { hidden: fit.hiddenCount, visible: fit.visibleCount }; this.hiddenChipCount = fit.hiddenCount; } this.refreshClippedChips(); } private labelOverflows(content: HTMLSlotElement): boolean { let left = Infinity; let right = -Infinity; const range = document.createRange(); for (const node of content.assignedNodes({ flatten: true })) { let rect: DOMRect; if (node.nodeType === Node.TEXT_NODE) { if (!node.textContent?.trim()) continue; range.selectNodeContents(node); rect = range.getBoundingClientRect(); } else if (node instanceof Element) { rect = node.getBoundingClientRect(); } else { continue; } if (rect.width === 0) continue; left = Math.min(left, rect.left); right = Math.max(right, rect.right); } if (right < left) return false; const box = content.getBoundingClientRect(); const styles = getComputedStyle(content); const boxLeft = box.left + parseFloat(styles.borderLeftWidth) + parseFloat(styles.paddingLeft); const boxRight = box.right - parseFloat(styles.borderRightWidth) - parseFloat(styles.paddingRight); const epsilon = 1 / 64; return right > boxRight + epsilon || left < boxLeft - epsilon; } private isChipClipped(el: HTMLElement | null | undefined): boolean { if (!el) return false; const root = el.shadowRoot; if (root) { const content = root.querySelector( '[part="content"]' ) as HTMLSlotElement | null; if (content && this.labelOverflows(content)) return true; const inner = root.querySelector( '[part="prefix_content_wrapper"]' ) as HTMLElement | null; if (inner && inner.scrollWidth > inner.clientWidth + 1) return true; } return el.scrollWidth > el.clientWidth + 1; } private refreshClippedChips(): void { if (!this.multiple) return; const root = this.shadowRoot; if (!root) return; const chips = Array.from( root.querySelectorAll('.select__tags nile-tag') ) as HTMLElement[]; const next = chips.map(chip => this.isChipClipped(chip)); const same = next.length === this.clippedChips.length && next.every((v, i) => v === this.clippedChips[i]); if (!same) { this.clippedChips = next; } } private scheduleClipCheck(): void { if (this.clipFrame) cancelAnimationFrame(this.clipFrame); this.clipFrame = requestAnimationFrame(() => { this.clipFrame = 0; this.refreshClippedChips(); }); } /** A chip gets a tooltip only while its text is measured as clipped. */ private chipTooltipDisabled(index: number): boolean { return !this.showTooltip || !(this.clippedChips[index] ?? false); } private destroyChipFit(): void { if (this.chipFitFrame) cancelAnimationFrame(this.chipFitFrame); this.chipFitFrame = 0; if (this.clipFrame) cancelAnimationFrame(this.clipFrame); this.clipFrame = 0; this.lastChipLayout = { hidden: -1, visible: -1 }; this.stopMeasurableWatch(); } private refitWhenMeasurable(container: Element): void { if (!this.isConnected) return; if (this.measurableWatch?.target === container) return; this.stopMeasurableWatch(); const observer = new ResizeObserver(entries => { if (!entries.some(entry => entry.contentRect.width > 0)) return; this.stopMeasurableWatch(); this.calculateTotalWidthOfTags(); }); observer.observe(container); this.measurableWatch = { observer, target: container }; } private stopMeasurableWatch(): void { this.measurableWatch?.observer.disconnect(); this.measurableWatch = undefined; } private getChipLabel(option: NileOption): string { return (option.getTextLabel?.() ?? '').trim(); } private handleChipKeyDown(event: KeyboardEvent, option: NileOption): void { if (event.key !== KeyCode.BACKSPACE && event.key !== KeyCode.DELETE) return; if (this.disabled) return; event.preventDefault(); event.stopPropagation(); const removedIndex = this.selectedOptions.indexOf(option); this.handleTagRemove( new CustomEvent('nile-remove') as unknown as NileRemoveEvent, option ); this.restoreFocusAfterChipRemoval(removedIndex); } /** Moves focus to the next chip, the last chip, or the field after a removal. */ private async restoreFocusAfterChipRemoval( removedIndex: number ): Promise { if (removedIndex < 0) return; await this.updateComplete; // Wait for the fit pass so hidden chips are not focused. await new Promise(resolve => requestAnimationFrame(() => resolve())); const visibleChips = Array.from( this.shadowRoot?.querySelectorAll('.select__tags nile-tag') ?? [] ).filter(chip => chip.style.display !== 'none'); const target = visibleChips[removedIndex] ?? visibleChips[visibleChips.length - 1]; if (target) { target.focus({ preventScroll: true }); } else { this.displayInput?.focus({ preventScroll: true }); } } private handleOverflowActivate(event: Event): void { event.preventDefault(); event.stopPropagation(); if (this.disabled) return; if (this.open) { this.hide(); } else { this.show(); } } private handleOverflowKeyDown(event: KeyboardEvent): void { if (event.key !== KeyCode.ENTER && event.key !== KeyCode.SPACE) return; this.handleOverflowActivate(event); } private renderFittedTags() { return html`
${this.selectedOptions.map((option, index) => { const label = this.getChipLabel(option); const prefixNodes = this.getOptionPrefixNodes(option, 'chip'); return html` this.handleTagRemove(event, option)} @keydown=${(event: KeyboardEvent) => this.handleChipKeyDown(event, option)} > ${prefixNodes.length ? html`${prefixNodes}` : ''} ${label} `; })} ${this.hiddenChipCount > 0 ? html`` : ''}
`; } private renderLegacyTags() { return html`
${this.selectedOptions.map((option, index) => { if (index < this.maxOptionsVisible || this.maxOptionsVisible <= 0) { return this.showTooltip ? html`${this.renderLegacyTag(option, index)}` : this.renderLegacyTag(option, index); } else if (index === this.maxOptionsVisible) { return html` +${this.selectedOptions.length - index} More `; } else { return null; } })}
`; } private renderLegacyTag(option: NileOption, index: number) { const prefixNodes = this.getOptionPrefixNodes(option, 'chip'); const classes = { select__invisible: index + 1 > this.oldMaxOptionsVisible && this.maxOptionsVisible === Infinity, }; return html` this.handleTagRemove(event, option)} > ${prefixNodes.length ? html`${prefixNodes}` : ''} ${option.getTextLabel()} `; } 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 prefixNodes = this.selectedOptions[0] ? this.getOptionPrefixNodes(this.selectedOptions[0], 'trigger') : []; return html`
${hasLabelSuffixSlot ? html` ` : ``}
${hasCustomSelect ? html`` : html``}
${prefixNodes.length && !this.multiple ? html`
${prefixNodes}
` : html``} ${this.multiple ? this.chipFittingEnabled ? this.renderFittedTags() : this.renderLegacyTags() : ''} 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`
${this.hasSlotController.test('pre-footer') ? html`
` : ''} ${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; } }