/** * Copyright Aquera Inc 2025 * * 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 { html } from 'lit'; import { customElement, property, query, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { Ref, createRef, ref } from 'lit/directives/ref.js'; import { styles } from './nile-combobox.css'; import '../nile-icon'; import '../nile-popup/nile-popup'; import '../nile-tag/nile-tag'; import '../nile-option/nile-option'; import '../nile-checkbox/nile-checkbox'; import '../nile-loader/nile-loader'; import { animateTo, stopAnimations } from '../internal/animate'; import { defaultValue } from '../internal/default-value'; import { FormControlController } from '../internal/form'; import { getAnimation, setDefaultAnimation } from '../utilities/animation-registry'; import { HasSlotController } from '../internal/slot'; import { waitForEvent } from '../internal/event'; import { watch } from '../internal/watch'; import NileElement from '../internal/nile-element'; import type { CSSResultGroup, PropertyValues, TemplateResult } from 'lit'; import type { NileFormControl } from '../internal/nile-element'; import type NilePopup from '../nile-popup/nile-popup'; import { VirtualizerController } from '@tanstack/lit-virtual'; import type { ComboboxOption, ComboboxRenderItemConfig, ComboboxTagLayout, ComboboxSize, ComboboxPlacement, ComboboxRow, NileRemoveEvent, } from './types.js'; import { ComboboxSelectionManager } from './selection-manager.js'; import { ComboboxSearchManager } from './search-manager.js'; import { ComboboxRenderer } from './renderer.js'; import { ComboboxPortalManager } from './portal-manager.js'; import { hasGroups, flattenRows, filterRows, getOptionRows } from './group-utils.js'; import { VisibilityManager } from '../utilities/visibility-manager.js'; /** * @summary A data-driven combobox with virtualized options, inline search, multi-select tags, * custom value creation, and full WAI-ARIA Combobox keyboard navigation. * * @tag nile-combobox * @status stable * @since 2.0 * * @dependency nile-icon * @dependency nile-popup * @dependency nile-tag * @dependency nile-checkbox * @dependency nile-loader * * @slot label - The input's label. * @slot prefix - Prepend a presentational icon or element before the input. * @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/collapsed. * @slot help-text - Text that describes how to use the input. * @slot footer - Custom footer content (overrides default footer in multi-select mode). * @slot no-results - Custom no-results content. * * @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 dropdown opens. * @event nile-after-show - Emitted after the dropdown opens and all animations complete. * @event nile-hide - Emitted when the dropdown closes. * @event nile-after-hide - Emitted after the dropdown closes and all animations complete. * @event nile-search - Emitted (debounced) when the user types. Useful for API-driven filtering. * @event nile-tag-remove - Emitted when a tag is removed in multi-select mode. * @event nile-tag-add - Emitted when a custom value is added via allowCustomValue. * @event nile-scroll-end - Emitted when scrolled to the bottom (for infinite loading). * @event nile-invalid - Emitted when form validation constraints aren't satisfied. * @event nile-select-all - Emitted when the Select all / Deselect all control toggles. Detail: { value, name, action: 'select-all' | 'deselect-all' }. * * @csspart form-control - The form control wrapper. * @csspart form-control-label - The label wrapper. * @csspart form-control-input - The input area wrapper. * @csspart combobox - The combobox trigger (input + tags + icons). * @csspart input - The text input element. * @csspart listbox - The dropdown listbox. * @csspart tags - The tags container in multi-select mode. * @csspart tag - Each individual tag. * @csspart clear-button - The clear button. * @csspart expand-icon - The expand/collapse icon. * @csspart top-actions - The sticky row above the option list that contains the Select all checkbox and the Selected / Show all filter toggle. Rendered only when multiple && selectAllEnabled. * @csspart select-all - The Select all / Deselect all checkbox wrapper inside top-actions. * @csspart show-toggle - The "Selected / Show all" filter button inside top-actions. * @csspart no-results - The empty-state container shown when a search/filter returns no items. Contains no-results-title and no-results-subtitle. * @csspart no-results-title - The title row of the no-results empty state. * @csspart no-results-subtitle - The subtitle row of the no-results empty state. * @csspart no-data - The empty-state container shown when the dataset is empty (no active search/filter). * @csspart footer - The footer with "Show Selected" / "Clear All". * @csspart no-results - The no-results message. */ @customElement('nile-combobox') export class NileCombobox extends NileElement implements NileFormControl { static styles: CSSResultGroup = styles; private readonly formControlController = new FormControlController(this, { assumeInteractionOn: ['nile-blur', 'nile-input'], }); private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label'); private readonly portalManager = new ComboboxPortalManager(this); private readonly searchManager = new ComboboxSearchManager(); private scrollElementRef: Ref = createRef(); private virtualizerCtrl = new VirtualizerController(this, { getScrollElement: () => this.scrollElementRef.value ?? null, count: 0, estimateSize: () => 38, overscan: 5, }); private hScrollElementRef: Ref = createRef(); private hVirtualizerCtrl = new VirtualizerController(this, { getScrollElement: () => this.hScrollElementRef.value ?? null, count: 0, estimateSize: () => 160, horizontal: true, overscan: 3, }); private scrollTimeout: number | undefined; private scrolling = false; private visibilityManager?: VisibilityManager; private keyboardActiveIndex = -1; @query('.combobox-popup') popup: NilePopup; @query('.combobox__trigger') combobox: HTMLElement; @query('.combobox__input') inputElement: HTMLInputElement; @query('.combobox__value-input') valueInput: HTMLInputElement; @state() private hasFocus = false; @state() displayLabel = ''; @state() selectedOptions: ComboboxOption[] = []; /** The items displayed after filtering. Renderer reads from this. */ @state() filteredData: any[] = []; /** * Mixed (header + option) row list, only populated when `data` contains * group entries (`type: 'group'`). When non-empty, the listbox renders from * this instead of `filteredData`. `filteredData` stays in sync as the * option-only projection so existing select-all / strict-match / etc. logic * keeps working unchanged. */ @state() private filteredRows: ComboboxRow[] = []; /** The complete unfiltered dataset (preserved for re-filtering). */ @state() private originalData: any[] = []; @state() showNoResults = false; @state() showListbox = false; @state() searchValue = ''; @state() private showSelectedOnly = false; @state() private selectAllChecked = false; @state() private selectAllIndeterminate = false; /** * Index into `filteredRows` of the group header that should be pinned at * the top of the (virtualized) listbox right now. -1 means none. * Recomputed on scroll. */ @state() private stickyHeaderIndex = -1; // ── Public properties ── @property() name = ''; @property({ type: Array }) data: any[] = []; @property({ converter: { fromAttribute: (value: string) => value.split(' '), toAttribute: (value: string[]) => value.join(' '), }, }) value: string | string[] = ''; @defaultValue() defaultValue: string | string[] = ''; @property() size: ComboboxSize = 'medium'; @property() placeholder = 'Type to search...'; @property({ type: Boolean, reflect: true }) multiple = false; @property() label = ''; @property({ type: Boolean, reflect: true }) required = false; @property({ type: Boolean, reflect: true }) disabled = false; @property({ type: Boolean, reflect: true }) open = false; @property({ type: Boolean, reflect: true }) clearable = false; @property({ type: Boolean, reflect: true }) loading = false; @property({ type: Boolean, reflect: true }) optionsLoading = false; /** When true, skip local filtering and rely solely on the `nile-search` event for API-driven results. */ @property({ type: Boolean, reflect: true }) disableLocalSearch = false; /** When true, displays a "+ Add [value]" option for values not in the data. */ @property({ type: Boolean, reflect: true }) allowCustomValue = false; /** When true, typing free text and pressing Enter/Tab adds it as a tag (like nile-chip's acceptUserInput). */ @property({ type: Boolean, reflect: true }) acceptUserInput = false; /** When true, custom values added via allowCustomValue or acceptUserInput are also appended to the suggestions list. */ @property({ type: Boolean, reflect: true }) addToSuggestions = false; /** When true, value must match an option. On blur, reverts to the last valid value if text doesn't match. */ @property({ type: Boolean, reflect: true }) strict = false; /** Max tags visible before showing "+N more" (0 = no limit). */ @property({ attribute: 'max-tags-visible', type: Number }) maxTagsVisible = 3; /** Controls how tags wrap in multi-select mode. */ @property() tagLayout: ComboboxTagLayout = 'single-line'; /** * Show footer with "Show Selected" and "Clear All" in multi-select mode. * Automatically suppressed when `selectAllEnabled` is true, since the top * actions row already provides the same controls. */ @property({ type: Boolean, reflect: true }) showFooter = true; /** * When true (and `multiple` is true), renders a "Select all" / "Deselect all" * checkbox at the top of the listbox. Operates on the currently visible, * non-disabled options (respects the active search filter). */ @property({ type: Boolean, reflect: true, attribute: 'select-all-enabled' }) selectAllEnabled = false; /** * When true (default), data-driven group headers stick to the top of the * listbox while scrolling through that group's options (Atlassian-style). * Works in both plain and virtualized rendering modes. Set to false for * inline-only headers that scroll away with their options. */ @property({ type: Boolean, reflect: true, attribute: 'sticky-group-header' }) stickyGroupHeader = true; @property({ type: Boolean, reflect: true }) portal = false; @property({ type: Boolean }) hoist = false; @property({ reflect: true }) placement: ComboboxPlacement = 'bottom'; @property({ reflect: true }) form = ''; @property({ attribute: 'help-text', reflect: true }) helpText = ''; @property({ attribute: 'error-message', reflect: true }) errorMessage = ''; @property({ type: Boolean }) warning = false; @property({ type: Boolean }) error = false; @property({ type: Boolean }) success = false; @property({ type: Boolean, reflect: true }) filled = false; @property({ type: Boolean, reflect: true }) pill = false; @property({ type: String }) noResultsMessage = 'No result found'; @property({ type: String, attribute: 'no-results-subtitle' }) noResultsSubtitle = 'Clear search or change filter'; @property({ type: String, attribute: 'no-data-message' }) noDataMessage = 'No data available'; /** * Pre-defined autocomplete suggestions. Accepts a JSON array string attribute * or a JS array property (like nile-chip). When `addToSuggestions` is true, * custom values added by the user are appended to this list and persisted. */ @property({ type: Array, converter: { fromAttribute: (value: string | null) => { if (!value) return []; try { return JSON.parse(value); } catch { return []; } }, toAttribute: (value: any[]) => JSON.stringify(value), }, }) autoCompleteOptions: any[] = []; /** Debounce interval (ms) for the nile-search event. */ @property({ type: Number }) debounceMs = 300; @property({ attribute: false }) renderItemConfig?: ComboboxRenderItemConfig; @property({ type: Boolean, reflect: true }) allowHtmlLabel = true; @property({ type: Boolean, reflect: true, attribute: true }) enableVisibilityEffect = false; @property({ type: Boolean, reflect: true, attribute: true }) enableTabClose = false; @property({ type: Boolean, reflect: true }) noWidthSync = false; /** Number of columns in the dropdown grid (vertical scroll). When > 1, options render in a multi-column grid layout. */ @property({ attribute: 'grid-columns', type: Number, reflect: true }) gridColumns = 1; /** Number of visible rows in horizontal grid mode. When > 0, enables horizontal virtual scroll with columns scrolling left/right. */ @property({ attribute: 'grid-rows', type: Number, reflect: true }) gridRows = 0; /** Width of each column in horizontal grid mode (px). */ @property({ attribute: 'grid-column-width', type: Number }) gridColumnWidth = 160; // ── Accessors ── get validity() { return this.valueInput?.validity; } get validationMessage() { return this.valueInput?.validationMessage ?? ''; } // ── Lifecycle ── connectedCallback() { super.connectedCallback(); this.open = false; this.setupBoundHandlers(); this.emit('nile-init'); this.updateComplete.then(() => { if (this.autoCompleteOptions.length > 0 && this.data.length === 0) { this.data = [...this.autoCompleteOptions]; } if (this.value && this.data.length > 0) { this.syncSelection(); } }); } disconnectedCallback() { this.removeOpenListeners(); this.visibilityManager?.cleanup(); this.searchManager.cancelDebounce(); if (this.scrollTimeout) { clearTimeout(this.scrollTimeout); this.scrollTimeout = undefined; } this.portalManager.cleanupPortal(); } protected firstUpdated(_changed: PropertyValues) { 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: string, detail: any) => this.emit(`nile-${event}`, detail), }); } protected updated(changedProperties: PropertyValues): void { if (changedProperties.has('value')) { this.syncSelection(); } this.updateVirtualizerCount(); } private get isHorizontalGrid(): boolean { return this.gridRows > 0 && this.gridColumns <= 1; } /** True when the source data contains at least one group entry. */ private get hasGroupedData(): boolean { const base = this.originalData.length > 0 ? this.originalData : this.data; return hasGroups(base); } /** * Walk filteredRows and find the index of the deepest group header whose * virtual position is at or above `scrollTop`. That's the header that * should be pinned at the top of the listbox right now. */ private updateStickyHeader(scrollTop: number): void { if (!this.stickyGroupHeader || !this.hasGroupedData) { if (this.stickyHeaderIndex !== -1) this.stickyHeaderIndex = -1; return; } let offset = 0; let stuck = -1; for (let i = 0; i < this.filteredRows.length; i++) { const row = this.filteredRows[i]; const size = row.kind === 'header' ? 32 : 38; if (offset > scrollTop) break; if (row.kind === 'header') stuck = i; offset += size; } if (stuck !== this.stickyHeaderIndex) this.stickyHeaderIndex = stuck; } /** Recursively keep only options whose value is in `selectedSet`; drop empty groups. */ private pruneTreeBySelection(items: any[], selectedSet: Set): any[] { const out: any[] = []; for (const item of items) { if (item && typeof item === 'object' && item.type === 'group' && Array.isArray(item.options)) { const kept = this.pruneTreeBySelection(item.options, selectedSet); if (kept.length > 0) out.push({ ...item, options: kept }); } else if (selectedSet.has(String(this.getItemValue(item)))) { out.push(item); } } return out; } private get hasActiveFilter(): boolean { return !!this.searchValue || this.showSelectedOnly; } private renderEmptyState(): TemplateResult { if (this.hasActiveFilter) { return ComboboxRenderer.renderNoResults(this.noResultsMessage, this.noResultsSubtitle); } return ComboboxRenderer.renderNoData(this.noDataMessage); } private get isBidirectionalGrid(): boolean { return this.gridRows > 0 && this.gridColumns > 1; } private get virtualRowCount(): number { if (this.gridColumns > 1) { return Math.ceil(this.filteredData.length / this.gridColumns); } if (this.hasGroupedData) { return this.filteredRows.length; } return this.filteredData.length; } private get virtualColumnCount(): number { return Math.ceil(this.filteredData.length / Math.max(this.gridRows, 1)); } private updateVirtualizerCount(): void { if (this.isHorizontalGrid) { const hVirtualizer = this.hVirtualizerCtrl.getVirtualizer(); const colCount = this.virtualColumnCount; if (hVirtualizer.options.count !== colCount || hVirtualizer.options.estimateSize(0) !== this.gridColumnWidth) { hVirtualizer.setOptions({ ...hVirtualizer.options, count: colCount, estimateSize: () => this.gridColumnWidth, }); hVirtualizer.measure(); } } else { const virtualizer = this.virtualizerCtrl.getVirtualizer(); const count = this.virtualRowCount; const grouped = this.hasGroupedData; const countChanged = virtualizer.options.count !== count; const modeChanged = this.lastVirtualizerGrouped !== grouped; if (countChanged || modeChanged) { const estimateSize = grouped ? (index: number) => (this.filteredRows[index]?.kind === 'header' ? 32 : 38) : () => 38; virtualizer.setOptions({ ...virtualizer.options, count, estimateSize, }); virtualizer.measure(); this.lastVirtualizerGrouped = grouped; } } } private lastVirtualizerGrouped = false; // ── Data helpers ── private getDisplayText(item: any): string { if (this.renderItemConfig?.getDisplayText) return this.renderItemConfig.getDisplayText(item); return item?.label || item?.name || item?.toString?.() || ''; } private getItemValue(item: any): string { if (this.renderItemConfig?.getValue) return this.renderItemConfig.getValue(item); return item?.value ?? item; } private getSearchText(item: any): string { if (this.renderItemConfig?.getSearchText) return this.renderItemConfig.getSearchText(item); return this.getDisplayText(item); } private getItemDescription(item: any): string { return this.renderItemConfig?.getDescription?.(item) ?? item?.description ?? ''; } private getItemPrefix(item: any): string { return this.renderItemConfig?.getPrefix?.(item) ?? item?.prefix ?? ''; } private getItemSuffix(item: any): string { return this.renderItemConfig?.getSuffix?.(item) ?? item?.suffix ?? ''; } // ── Selection ── private syncSelection(): void { const baseData = this.originalData.length > 0 ? this.originalData : this.data; const items = this.hasGroupedData ? getOptionRows(flattenRows(baseData)).map(r => r.item) : baseData; this.selectedOptions = ComboboxSelectionManager.createOptionsFromValues( this.value, items, this.getDisplayText.bind(this), this.renderItemConfig?.getValue, ); if (!this.multiple) { const label = this.selectedOptions[0]?.getTextLabel() ?? ''; this.displayLabel = label; if (label) { this.searchValue = label; } else if (!this.hasFocus) { this.searchValue = ''; } } else { this.displayLabel = ''; } this.updateValidity(); this.updateSelectAllState(); } // ── Select All ── /** * Returns the options eligible for Select All — the currently filtered data * (so the active search/filter is respected) minus any `disabled` items. * Mirrors nile-select's `getSelectableOptions()` contract. */ private getSelectableData(): any[] { return this.filteredData.filter((item: any) => !item?.disabled); } /** * Derives `selectAllChecked` + `selectAllIndeterminate` from the current * selection vs. the selectable set. No-op when `multiple` is false. * * options=0 → both false (nothing to select, e.g. empty search) * selected=0 → checked=false, indeterminate=false * selected=options → checked=true, indeterminate=false * otherwise (partial) → checked=false, indeterminate=true */ private updateSelectAllState(): void { if (!this.multiple) { this.selectAllChecked = false; this.selectAllIndeterminate = false; return; } const options = this.getSelectableData(); if (options.length === 0) { this.selectAllChecked = false; this.selectAllIndeterminate = false; return; } const selectedValues = Array.isArray(this.value) ? this.value : []; const selectedSet = new Set(selectedValues.map(String)); const selectedInView = options.reduce((n, item) => { return n + (selectedSet.has(String(this.getItemValue(item))) ? 1 : 0); }, 0); if (selectedInView === 0) { this.selectAllChecked = false; this.selectAllIndeterminate = false; } else if (selectedInView === options.length) { this.selectAllChecked = true; this.selectAllIndeterminate = false; } else { this.selectAllChecked = false; this.selectAllIndeterminate = true; } } /** * Toggles all selectable, visible options. Indeterminate → clears selection * (matches the "Deselect All" label flip), empty → selects all, * fully-selected → clears. */ private handleSelectAllToggle(event: Event): void { event.stopPropagation(); event.preventDefault(); if (!this.multiple || !this.selectAllEnabled) return; const options = this.getSelectableData(); if (options.length === 0) return; const shouldUnselectAll = this.selectAllChecked || this.selectAllIndeterminate; const current = Array.isArray(this.value) ? [...this.value] : []; const inViewValues = options.map(item => String(this.getItemValue(item))); const inViewSet = new Set(inViewValues); let next: string[]; if (shouldUnselectAll) { // Deselect only the in-view options; preserve selections outside the filter. next = current.filter(v => !inViewSet.has(String(v))); } else { // Select all in-view options on top of any existing (out-of-view) selection. const merged = new Set(current.map(String)); inViewValues.forEach(v => merged.add(v)); next = Array.from(merged); } this.value = next; this.searchValue = ''; this.syncSelection(); this.filterOptions('', true); this.updateComplete.then(() => { this.emit('nile-input', { value: this.value, name: this.name }); this.emit('nile-change', { value: this.value, name: this.name }); this.emit('nile-select-all', { value: this.value, name: this.name, action: shouldUnselectAll ? 'deselect-all' : 'select-all', }); }); } // ── Event handler setup ── private handleDocumentFocusIn!: (e: FocusEvent) => void; private handleDocumentKeyDown!: (e: KeyboardEvent) => void; private handleDocumentMouseDown!: (e: MouseEvent) => void; private handleWindowError!: (e: ErrorEvent) => void; private handleWindowResize!: () => void; private handleWindowScroll!: () => void; private setupBoundHandlers(): void { this.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 hitPortal = this.portal && this.portalManager.portalContainerElement && path.includes(this.portalManager.portalContainerElement); if (!hitSelf && !hitPopup && !hitPortal) this.hide(); }; this.handleDocumentKeyDown = (event: KeyboardEvent) => { if (!this.open && event.key !== 'ArrowDown' && event.key !== 'ArrowUp' && event.key !== 'Enter') return; this.onGlobalKeyDown(event); }; this.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 hitPortal = this.portal && this.portalManager.portalContainerElement && path.includes(this.portalManager.portalContainerElement); if (!hitSelf && !hitPopup && !hitPortal) this.hide(); }; this.handleWindowError = (event: ErrorEvent) => { const msg = event.error?.message || event.message || ''; if (msg.includes("Cannot read properties of null (reading 'insertBefore')")) { event.preventDefault(); } }; this.handleWindowResize = () => this.portalManager.updatePosition(); this.handleWindowScroll = () => this.portalManager.updatePosition(); } private addOpenListeners(): void { document.addEventListener('focusin', this.handleDocumentFocusIn); document.addEventListener('keydown', this.handleDocumentKeyDown); document.addEventListener('mousedown', this.handleDocumentMouseDown); window.addEventListener('error', this.handleWindowError); if (this.portal) { window.addEventListener('resize', this.handleWindowResize); window.addEventListener('scroll', this.handleWindowScroll, true); } } private removeOpenListeners(): void { document.removeEventListener('focusin', this.handleDocumentFocusIn); document.removeEventListener('keydown', this.handleDocumentKeyDown); document.removeEventListener('mousedown', this.handleDocumentMouseDown); window.removeEventListener('error', this.handleWindowError); window.removeEventListener('resize', this.handleWindowResize); window.removeEventListener('scroll', this.handleWindowScroll, true); } // ── Keyboard navigation (WAI-ARIA Combobox) ── private onInputKeyDown(event: KeyboardEvent): void { switch (event.key) { case 'ArrowDown': event.preventDefault(); if (!this.open) { this.show(); } else { this.moveActiveOption(this.gridColumns > 1 ? this.gridColumns : 1); } break; case 'ArrowUp': event.preventDefault(); if (!this.open) { this.show(); } else { this.moveActiveOption(this.gridColumns > 1 ? -this.gridColumns : -1); } break; case 'ArrowRight': if (this.gridColumns > 1 && this.open) { event.preventDefault(); this.moveActiveOption(1); } break; case 'ArrowLeft': if (this.gridColumns > 1 && this.open) { event.preventDefault(); this.moveActiveOption(-1); } break; case 'Enter': event.preventDefault(); if (this.open) { this.selectActiveOption(); } else if (this.searchValue.trim() && (this.acceptUserInput || this.allowCustomValue)) { this.addCustomValue(this.searchValue.trim()); } else { this.show(); } break; case 'Escape': if (this.open) { event.preventDefault(); event.stopPropagation(); this.hide(); } break; case 'Home': if (this.open) { event.preventDefault(); this.setActiveOption(0); } break; case 'End': if (this.open) { event.preventDefault(); this.setActiveOption(this.filteredData.length - 1); } break; case 'Backspace': if (this.multiple && this.searchValue === '' && this.selectedOptions.length > 0) { const last = this.selectedOptions[this.selectedOptions.length - 1]; this.removeTag(last); } break; case 'Tab': if (this.multiple && this.acceptUserInput && this.searchValue.trim()) { this.addCustomValue(this.searchValue.trim()); } if (this.open) this.hide(); break; } } private onGlobalKeyDown(event: KeyboardEvent): void { if (event.key === 'Escape' && this.open) { event.preventDefault(); event.stopPropagation(); this.hide(); this.inputElement?.focus({ preventScroll: true }); } } private getVisibleOptions(): NodeListOf | Element[] { const root = this.portal ? this.portalManager.portalContainerElement : this.shadowRoot; if (!root) return []; return root.querySelectorAll('nile-option:not([disabled])'); } private moveActiveOption(delta: number): void { const options = this.getVisibleOptions(); if (!options.length) return; let next = this.keyboardActiveIndex + delta; if (next < 0) next = options.length - 1; if (next >= options.length) next = 0; this.setActiveOption(next); } private setActiveOption(index: number): void { const options = this.getVisibleOptions(); if (!options.length) return; options.forEach((opt, i) => { const el = opt as HTMLElement; if (i === index) { el.setAttribute('aria-current', 'true'); el.classList.add('combobox__option--active'); el.scrollIntoView?.({ block: 'nearest' }); } else { el.removeAttribute('aria-current'); el.classList.remove('combobox__option--active'); } }); this.keyboardActiveIndex = index; } private selectActiveOption(): void { const options = this.getVisibleOptions(); if (this.keyboardActiveIndex >= 0 && this.keyboardActiveIndex < options.length) { const opt = options[this.keyboardActiveIndex] as any; if (opt && !opt.disabled) { this.handleOptionSelection(opt.value); } } else if (this.searchValue.trim() && (this.allowCustomValue || this.acceptUserInput)) { this.addCustomValue(this.searchValue.trim()); } } // ── Input handling ── private onInputChange(event: Event): void { event.stopPropagation(); } private onInputHandler(event: Event): void { const target = event.target as HTMLInputElement; this.searchValue = target.value; if (!this.open) this.show(); this.emit('nile-input', { value: this.searchValue, name: this.name }); if (this.disableLocalSearch) { this.searchManager.debounceSearch( (q: string) => this.emit('nile-search', { query: q, name: this.name }), this.searchValue, this.debounceMs, ); } else { this.filterOptions(this.searchValue); } this.keyboardActiveIndex = -1; } private filterOptions(search: string, preserveScroll = false): void { const baseData = this.originalData.length > 0 ? this.originalData : this.data; if (this.hasGroupedData) { // Grouped path: filter the tree, derive options-only projection. let tree = baseData; if (this.showSelectedOnly) { const selectedValues = Array.isArray(this.value) ? this.value : [this.value]; const selectedSet = new Set(selectedValues.map(v => String(v))); tree = this.pruneTreeBySelection(baseData, selectedSet); } const { rows } = filterRows(tree, search, this.getSearchText.bind(this)); this.filteredRows = rows; this.filteredData = getOptionRows(rows).map(r => r.item); this.showNoResults = this.filteredData.length === 0; // Recompute sticky header at current scroll (defaults to top on filter). const st = this.scrollElementRef.value?.scrollTop ?? 0; this.updateStickyHeader(st); } else { let source = baseData; if (this.showSelectedOnly) { const selectedValues = Array.isArray(this.value) ? this.value : [this.value]; const selectedSet = new Set(selectedValues.map(v => String(v))); source = baseData.filter((item: any) => selectedSet.has(String(this.getItemValue(item)))); } const { filteredItems, showNoResults } = this.searchManager.filter( search, source, this.getSearchText.bind(this), ); this.filteredData = filteredItems; this.filteredRows = []; this.showNoResults = showNoResults; } this.portalManager.resetMeasuredHeight(); if (!preserveScroll) { this.resetScrollPosition(); } this.updateSelectAllState(); this.requestUpdate(); } // ── Focus ── private onFocusIn(): void { this.hasFocus = true; this.emit('nile-focus'); if (!this.multiple && this.displayLabel) { this.searchValue = this.displayLabel; this.requestUpdate(); this.updateComplete.then(() => { this.inputElement?.select(); }); } } private onFocusOut(): void { this.hasFocus = false; this.emit('nile-blur'); if (this.open) return; if (!this.multiple) { if (this.strict) { const baseData = this.originalData.length > 0 ? this.originalData : this.data; const allItems = this.hasGroupedData ? getOptionRows(flattenRows(baseData)).map(r => r.item) : baseData; const match = allItems.find( (item: any) => this.getDisplayText(item).toLowerCase() === this.searchValue.toLowerCase(), ); if (match) { const val = this.getItemValue(match); if (this.value !== val) { this.value = val; this.syncSelection(); this.emit('nile-change', { value: this.value, name: this.name }); } } else { this.searchValue = this.displayLabel; } } else { this.searchValue = this.displayLabel; } } } // ── Combobox trigger interaction ── private onTriggerMouseDown(event: MouseEvent): void { if (this.disabled) return; const path = event.composedPath(); if (path.some(el => el instanceof Element && el.tagName.toLowerCase() === 'nile-icon-button')) return; event.preventDefault(); this.inputElement?.focus({ preventScroll: true }); if (this.open) { this.hide(); } else { this.show(); } } // ── Option click ── private onOptionClick(event: MouseEvent): void { // Use composedPath to find nile-option across shadow boundaries const path = event.composedPath(); const option = path.find((el: EventTarget) => (el as HTMLElement).tagName === 'NILE-OPTION' ) as any; if (!option || option.disabled) return; this.handleOptionSelection(option.value); } private handleOptionSelection(optionValue: string): void { const oldValue = this.value; if (this.multiple) { const current = Array.isArray(this.value) ? this.value : []; this.value = ComboboxSelectionManager.toggleMultiValue(current, optionValue); this.searchValue = ''; this.syncSelection(); this.filterOptions('', true); } else { this.value = optionValue; this.syncSelection(); this.searchValue = this.displayLabel; this.hide(); } if (JSON.stringify(this.value) !== JSON.stringify(oldValue)) { this.updateComplete.then(() => { this.emit('nile-input', { value: this.value, name: this.name }); this.emit('nile-change', { value: this.value, name: this.name }); }); } this.inputElement?.focus({ preventScroll: true }); } private addCustomValue(val: string): void { if (this.addToSuggestions) { const allItems = this.originalData.length > 0 ? this.originalData : this.data; const alreadyExists = allItems.some((item: any) => String(this.getItemValue(item)) === val || this.getDisplayText(item) === val, ); if (!alreadyExists) { const newItem = this.renderItemConfig?.getValue ? { value: val, label: val } : val; this.originalData = [...allItems, newItem]; this.data = [...this.originalData]; this.autoCompleteOptions = [...this.originalData]; this.filteredData = [...this.originalData]; } } if (this.multiple) { const current = Array.isArray(this.value) ? this.value : []; if (!current.includes(val)) { this.value = [...current, val]; } } else { this.value = val; this.hide(); } this.searchValue = this.multiple ? '' : val; this.syncSelection(); this.filterOptions(this.searchValue); this.emit('nile-tag-add', { value: val, name: this.name }); this.emit('nile-change', { value: this.value, name: this.name }); } // ── Clear ── private onClearClick(event: MouseEvent): void { event.stopPropagation(); this.value = this.multiple ? [] : ''; this.searchValue = ''; this.syncSelection(); this.filterOptions(''); this.emit('nile-clear', { value: this.value, name: this.name }); this.emit('nile-change', { value: this.value, name: this.name }); } // ── Tags ── private removeTag(option: ComboboxOption): void { if (this.disabled) return; const current = Array.isArray(this.value) ? this.value : []; this.value = ComboboxSelectionManager.removeValue(current, option.value); this.syncSelection(); this.emit('nile-tag-remove', { value: this.value, name: this.name, removedtagvalue: option.value }); this.emit('nile-change', { value: this.value, name: this.name }); } // ── Footer ── private onFooterClick(event: MouseEvent): void { event.stopPropagation(); event.preventDefault(); } private toggleShowSelected(event: Event): void { event.stopPropagation(); event.preventDefault(); if (this.selectedOptions.length === 0) return; this.showSelectedOnly = !this.showSelectedOnly; if (this.showSelectedOnly) this.searchValue = ''; this.filterOptions(this.searchValue); } private clearAll(): void { this.showSelectedOnly = false; this.value = this.multiple ? [] : ''; this.filterOptions(''); this.syncSelection(); this.emit('nile-change', { value: this.value, name: this.name }); this.emit('nile-clear', { value: this.value, name: this.name }); this.resetScrollPosition(); } // ── Scroll ── private onScroll(e: Event): void { const target = e.target as HTMLElement; this.updateStickyHeader(target.scrollTop); if (this.showSelectedOnly) return; 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(() => { 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 }); } } // ── Show / Hide ── async show(): Promise { if (this.open || this.disabled) return undefined; this.open = true; return waitForEvent(this, 'nile-after-show'); } async hide(): Promise { if (!this.open) return undefined; this.open = false; return waitForEvent(this, 'nile-after-hide'); } @watch('open', { waitUntilFirstUpdate: true }) async handleOpenChange(): Promise { if (this.open && !this.disabled) { this.visibilityManager?.setup(); this.showListbox = true; await this.updateComplete; await this.doOpen(); if (this.portal) this.portalManager.setupPortal(); } else { this.visibilityManager?.cleanup(); await this.doClose(); this.showListbox = false; if (this.portal) this.portalManager.cleanupPortal(); } } private async doOpen(): Promise { this.emit('nile-show', { value: this.value, name: this.name }); this.addOpenListeners(); this.keyboardActiveIndex = -1; if (this.originalData.length === 0 && this.data.length > 0) { this.originalData = [...this.data]; } if (this.hasGroupedData) { this.filterOptions(this.searchValue); } else { this.filteredData = [...(this.originalData.length > 0 ? this.originalData : this.data)]; this.filteredRows = []; this.showNoResults = this.filteredData.length === 0; } await stopAnimations(this); if (this.popup?.popup) { this.popup.popup.style.visibility = 'hidden'; } this.popup.active = true; await new Promise(r => requestAnimationFrame(r)); await new Promise(r => requestAnimationFrame(r)); if (this.popup?.popup) { this.popup.popup.style.visibility = ''; } const { keyframes, options } = getAnimation(this, 'combobox.show', { dir: 'ltr' }); await animateTo(this.popup.popup, keyframes, options); this.resetScrollPosition(); this.emit('nile-after-show', { value: this.value, name: this.name }); } private async doClose(): Promise { this.emit('nile-hide', { value: this.value, name: this.name }); this.removeOpenListeners(); await stopAnimations(this); const { keyframes, options } = getAnimation(this, 'combobox.hide', { dir: 'ltr' }); await animateTo(this.popup.popup, keyframes, options); this.popup.active = false; if (this.popup?.popup) this.popup.popup.style.visibility = ''; this.showSelectedOnly = false; this.portalManager.resetMeasuredHeight(); if (!this.multiple) { this.searchValue = this.displayLabel; } this.emit('nile-after-hide', { value: this.value, name: this.name }); } // ── Watchers ── @watch('disabled', { waitUntilFirstUpdate: true }) handleDisabledChange(): void { if (this.disabled) { this.open = false; this.handleOpenChange(); } } @watch('value', { waitUntilFirstUpdate: true }) handleValueChange(): void { this.syncSelection(); this.requestUpdate(); if (this.portal && this.portalManager.portalContainerElement) { this.portalManager.updatePosition(); } } @watch('data', { waitUntilFirstUpdate: true }) handleDataChange(): void { if (this.data.length > 0 && !this.showSelectedOnly) { this.originalData = [...this.data]; } if (this.hasGroupedData) { this.filterOptions(this.searchValue); } else { this.filteredData = [...this.data]; this.filteredRows = []; } this.syncSelection(); this.updateSelectAllState(); if (!this.optionsLoading && !this.loading && this.data.length === 0) { this.showNoResults = true; } else if (this.data.length > 0) { this.showNoResults = false; } this.portalManager.resetMeasuredHeight(); if (this.portal && this.portalManager.portalContainerElement) { this.portalManager.updatePosition(); } this.requestUpdate(); } @watch('autoCompleteOptions', { waitUntilFirstUpdate: true }) handleAutoCompleteOptionsChange(): void { if (this.autoCompleteOptions.length > 0) { this.data = [...this.autoCompleteOptions]; } } @watch('multiple', { waitUntilFirstUpdate: true }) @watch('selectAllEnabled', { waitUntilFirstUpdate: true }) handleSelectAllConfigChange(): void { this.updateSelectAllState(); } @watch('portal', { waitUntilFirstUpdate: true }) handlePortalChange(): void { if (this.open) { if (this.portal) this.portalManager.setupPortal(); else this.portalManager.cleanupPortal(); } } // ── Form integration ── checkValidity(): boolean { return this.valueInput.checkValidity(); } getForm(): HTMLFormElement | null { return this.formControlController.getForm(); } reportValidity(): boolean { return this.valueInput.reportValidity(); } setCustomValidity(message: string): void { this.valueInput.setCustomValidity(message); this.formControlController.updateValidity(); } focus(options?: FocusOptions): void { this.inputElement?.focus(options); } blur(): void { this.inputElement?.blur(); } private handleInvalid(event: Event): void { this.formControlController.setValidity(false); this.formControlController.emitInvalidEvent(event); } private updateValidity(): void { this.updateComplete.then(() => this.formControlController.updateValidity()); } private async resetScrollPosition(): Promise { await this.portalManager.resetScrollPosition(); } // ── Render ── render(): TemplateResult { const hasLabelSlot = this.hasSlotController.test('label'); const hasLabel = this.label ? true : !!hasLabelSlot; const hasValue = this.multiple ? (Array.isArray(this.value) && this.value.length > 0) : (typeof this.value === 'string' && this.value !== ''); const hasClearIcon = this.clearable && !this.disabled && hasValue; const hasHelpText = !!this.helpText; const hasErrorMessage = !!this.errorMessage; const isEmpty = !hasValue; return html`
${this.renderTrigger(hasClearIcon)} ${this.showListbox ? this.renderListbox() : html``} ${hasHelpText ? html`${this.helpText}` : html``} ${hasErrorMessage ? html`${this.errorMessage}` : html``}
`; } private renderTrigger(hasClearIcon: boolean): TemplateResult { const hasTags = this.multiple && this.selectedOptions.length > 0; const inputPlaceholder = hasTags ? '' : this.placeholder; return html`
${hasTags ? this.renderInlineTags() : html``}
= 0 ? `option-${this.keyboardActiveIndex}` : ''} role="combobox" tabindex="0" @input=${this.onInputHandler} @keydown=${this.onInputKeyDown} @focus=${this.onFocusIn} @blur=${this.onFocusOut} @change=${this.onInputChange} />
this.focus()} @invalid=${this.handleInvalid} />
${hasClearIcon ? this.renderClearButton() : html``}
`; } private renderInlineTags(): TemplateResult[] { const tags: TemplateResult[] = []; this.selectedOptions.forEach((option, index) => { if (this.maxTagsVisible > 0 && index >= this.maxTagsVisible) { if (index === this.maxTagsVisible) { tags.push(html`+${this.selectedOptions.length - this.maxTagsVisible} More`); } return; } tags.push(html` { e.stopPropagation(); this.removeTag(option); }} > ${option.getTextLabel()} `); }); return tags; } private renderClearButton(): TemplateResult { return html` `; } private renderStickyHeaderOverlay(grouped: boolean, useVirtual: boolean): TemplateResult { if (!this.stickyGroupHeader || !grouped || !useVirtual) return html``; const idx = this.stickyHeaderIndex; if (idx < 0 || idx >= this.filteredRows.length) return html``; const row = this.filteredRows[idx]; if (row.kind !== 'header') return html``; return html`
${ComboboxRenderer.renderGroupHeader(row)}
`; } private renderListbox(): TemplateResult { const showAddOption = this.allowCustomValue && this.searchValue.trim() && !this.filteredData.some((item: any) => { const v = this.getItemValue(item); return String(v).toLowerCase() === this.searchValue.trim().toLowerCase(); }) && !this.filteredData.some((item: any) => { const t = this.getDisplayText(item); return t.toLowerCase() === this.searchValue.trim().toLowerCase(); }); if (this.isHorizontalGrid) { return this.renderHorizontalListbox(!!showAddOption); } const isGrid = this.gridColumns > 1 || this.isBidirectionalGrid; const grouped = this.hasGroupedData && !isGrid; const useVirtual = grouped ? this.filteredRows.length >= 5 : ComboboxRenderer.shouldUseVirtualizer(this.filteredData, this.gridColumns); const virtualizer = this.virtualizerCtrl.getVirtualizer(); const virtualItems = (useVirtual || isGrid) ? virtualizer.getVirtualItems() : []; const totalSize = (useVirtual || isGrid) ? virtualizer.getTotalSize() : 0; return html`
${this.renderLoader()} ${this.renderSelectAll()} ${this.renderStickyHeaderOverlay(grouped, useVirtual)} ${this.showNoResults && !this.optionsLoading && !this.loading ? this.renderEmptyState() : isGrid ? ComboboxRenderer.renderVirtualizedGrid( virtualItems, totalSize, this.filteredData, this.value, this.multiple, this.gridColumns, this.getDisplayText.bind(this), this.getItemValue.bind(this), this.optionsLoading || this.loading, this.allowHtmlLabel, this.renderItemConfig?.getDescription ? this.getItemDescription.bind(this) : undefined, this.renderItemConfig?.getPrefix ? this.getItemPrefix.bind(this) : undefined, this.renderItemConfig?.getSuffix ? this.getItemSuffix.bind(this) : undefined, this.isBidirectionalGrid ? this.gridColumnWidth : undefined, ) : grouped ? (useVirtual ? ComboboxRenderer.renderRowsVirtualized( virtualItems, totalSize, this.filteredRows, this.value, this.multiple, this.getDisplayText.bind(this), this.getItemValue.bind(this), this.optionsLoading || this.loading, this.allowHtmlLabel, this.renderItemConfig?.getDescription ? this.getItemDescription.bind(this) : undefined, this.renderItemConfig?.getPrefix ? this.getItemPrefix.bind(this) : undefined, this.renderItemConfig?.getSuffix ? this.getItemSuffix.bind(this) : undefined, ) : ComboboxRenderer.renderRowsPlain( this.filteredRows, this.value, this.multiple, this.getDisplayText.bind(this), this.getItemValue.bind(this), this.showNoResults, this.noResultsMessage, this.optionsLoading || this.loading, this.onScroll.bind(this), this.allowHtmlLabel, this.renderItemConfig?.getDescription ? this.getItemDescription.bind(this) : undefined, this.renderItemConfig?.getPrefix ? this.getItemPrefix.bind(this) : undefined, this.renderItemConfig?.getSuffix ? this.getItemSuffix.bind(this) : undefined, undefined, this.hasActiveFilter ? this.noResultsSubtitle : undefined, )) : useVirtual ? ComboboxRenderer.renderVirtualizedOptions( virtualItems, totalSize, this.filteredData, this.value, this.multiple, this.getDisplayText.bind(this), this.getItemValue.bind(this), this.optionsLoading || this.loading, this.allowHtmlLabel, virtualizer.measureElement, this.renderItemConfig?.getDescription ? this.getItemDescription.bind(this) : undefined, this.renderItemConfig?.getPrefix ? this.getItemPrefix.bind(this) : undefined, this.renderItemConfig?.getSuffix ? this.getItemSuffix.bind(this) : undefined, ) : ComboboxRenderer.renderPlainOptions( this.filteredData, this.value, this.multiple, this.getDisplayText.bind(this), this.getItemValue.bind(this), this.showNoResults, this.noResultsMessage, this.optionsLoading || this.loading, this.onScroll.bind(this), this.allowHtmlLabel, this.renderItemConfig?.getDescription ? this.getItemDescription.bind(this) : undefined, this.renderItemConfig?.getPrefix ? this.getItemPrefix.bind(this) : undefined, this.renderItemConfig?.getSuffix ? this.getItemSuffix.bind(this) : undefined, undefined, this.hasActiveFilter ? this.noResultsSubtitle : undefined, ) } ${showAddOption ? html`
{ e.stopPropagation(); this.addCustomValue(this.searchValue.trim()); }}> ${ComboboxRenderer.renderAddCustomOption(this.searchValue.trim(), this.multiple)}
` : ''} ${this.multiple && this.showFooter && !this.selectAllEnabled ? this.renderFooter() : ''}
`; } private renderHorizontalListbox(showAddOption: boolean): TemplateResult { const hVirtualizer = this.hVirtualizerCtrl.getVirtualizer(); const virtualItems = hVirtualizer.getVirtualItems(); const totalSize = hVirtualizer.getTotalSize(); return html`
${this.renderLoader()} ${this.renderSelectAll()} ${this.showNoResults && !this.optionsLoading && !this.loading ? this.renderEmptyState() : ComboboxRenderer.renderHorizontalGrid( virtualItems, totalSize, this.filteredData, this.value, this.multiple, this.gridRows, this.gridColumnWidth, this.getDisplayText.bind(this), this.getItemValue.bind(this), this.optionsLoading || this.loading, this.allowHtmlLabel, this.renderItemConfig?.getDescription ? this.getItemDescription.bind(this) : undefined, this.renderItemConfig?.getPrefix ? this.getItemPrefix.bind(this) : undefined, this.renderItemConfig?.getSuffix ? this.getItemSuffix.bind(this) : undefined, ) } ${showAddOption ? html`
{ e.stopPropagation(); this.addCustomValue(this.searchValue.trim()); }}> ${ComboboxRenderer.renderAddCustomOption(this.searchValue.trim(), this.multiple)}
` : ''} ${this.multiple && this.showFooter && !this.selectAllEnabled ? this.renderFooter() : ''}
`; } private renderLoader(): TemplateResult { if (this.loading) { return html``; } if (this.optionsLoading) { return html``; } return html``; } private renderSelectAll(): TemplateResult { if (!this.multiple || !this.selectAllEnabled) return html``; const disabled = this.showNoResults || this.optionsLoading || this.loading; const anySelected = this.selectAllChecked || this.selectAllIndeterminate; const label = anySelected ? 'Deselect all' : 'Select all'; const showToggleDisabled = this.selectedOptions.length === 0 || this.showNoResults; const iconColor = showToggleDisabled ? 'var(--nile-colors-primary-500, var(--ng-colors-fg-quaternary-400))' : 'var(--nile-colors-primary-600, var(--ng-colors-fg-brand-secondary-600))'; return html`
e.preventDefault()} >
${label}
`; } private renderFooter(): TemplateResult { return html` `; } } // ── Default animations ── setDefaultAnimation('combobox.show', { keyframes: [ { opacity: 0, scale: 0.9 }, { opacity: 1, scale: 1 }, ], options: { duration: 100, easing: 'ease' }, }); setDefaultAnimation('combobox.hide', { keyframes: [ { opacity: 1, scale: 1 }, { opacity: 0, scale: 0.9 }, ], options: { duration: 100, easing: 'ease' }, }); export default NileCombobox; declare global { interface HTMLElementTagNameMap { 'nile-combobox': NileCombobox; } }