{
if (!this.multiple) return new Set();
const selected = (this._getCurrentValue() as string[] | undefined) || [];
return new Set(selected);
}
// Memoized getters - return cached values computed in willUpdate
private get _filteredItems(): AutocompleteItem[] {
return this._cachedFilteredItems;
}
private get _alreadySelectedItems(): AutocompleteItem[] {
return this._cachedAlreadySelectedItems;
}
private get _showCustomOption(): boolean {
return this._cachedShowCustomOption;
}
// Computed: total selectable items (limited to what's actually rendered)
private get _totalSelectableItems(): number {
const maxRender = this.maxVisible + 4;
const filteredCount = Math.min(this._filteredItems.length, maxRender);
return filteredCount +
(this._showCustomOption ? 1 : 0) +
this._alreadySelectedItems.length;
}
override render() {
const dropdownClasses = {
dropdown: true,
hidden: !this._isOpen,
};
return html`
`;
}
private _renderDropdownContent() {
const filtered = this._filteredItems;
const alreadySelected = this._alreadySelectedItems;
// Show empty state only if no selectable items, no custom option, AND no already-selected items
if (
filtered.length === 0 && !this._showCustomOption &&
alreadySelected.length === 0
) {
return html`
No matching options
`;
}
// Limit rendered items to maxVisible + small buffer for performance
// This avoids rendering 600 DOM nodes when only 8 are visible
const maxRender = this.maxVisible + 4;
const filteredToRender = filtered.slice(0, maxRender);
const options = filteredToRender.map((item, index) => {
const optionClasses = {
option: true,
highlighted: index === this._highlightedIndex,
};
return html`
this._selectItem(item)}"
@mouseenter="${() => this._setHighlight(index)}"
>
${item.label || item.value}
${item.group
? html`
${item.group}
`
: nothing}
`;
});
// Add already-selected items after filtered items (multi-select only)
// Order: filtered items → already-selected items → custom option
const alreadySelectedStartIndex = filtered.length;
if (alreadySelected.length > 0) {
// Add separator if there are selectable items above
const needsSeparator = filtered.length > 0;
alreadySelected.forEach((item, index) => {
const globalIndex = alreadySelectedStartIndex + index;
const optionClasses = {
option: true,
"already-selected": true,
"selected-separator": needsSeparator && index === 0,
highlighted: globalIndex === this._highlightedIndex,
};
options.push(html`
this._removeItem(item)}"
@mouseenter="${() => this._setHighlight(globalIndex)}"
>
${item.label || item.value}
Already added
Remove
`);
});
}
// Add custom value option at the very end
if (this._showCustomOption) {
const customIndex = filtered.length + alreadySelected.length;
const customClasses = {
option: true,
custom: true,
highlighted: customIndex === this._highlightedIndex,
};
options.push(html`
this._setHighlight(customIndex)}"
>
Add "${this._query}"
`);
}
return options;
}
// Event handlers
//
// PERFORMANCE NOTE (Dec 2025):
// We use setTimeout(0) here instead of synchronous state updates. This was extensively
// investigated and verified:
//
// 1. VERIFIED: Typing does NOT trigger pattern/cell recomputation - filtering is purely
// internal Lit state (_query). Console instrumentation confirmed no framework overhead.
//
// 2. WHY setTimeout(0) FEELS FASTER than synchronous updates:
// - Synchronous: Lit queues microtasks → blocks rendering → user sees lag
// - setTimeout(0): Returns immediately → browser paints keystroke → dropdown updates next frame
// The browser event loop order is: Task → Microtasks → Render → Next Task
// By deferring to the next task, we let the browser paint the input first.
//
// 3. clearTimeout prevents "flashing" when typing quickly by canceling stale updates.
//
// See commit history for detailed performance investigation with Oracle agents.
//
private _handleInput(e: Event) {
const input = e.target as HTMLInputElement;
const newValue = input.value;
// Clear pending update to prevent stale renders (no flashing)
if (this._debounceTimer !== null) {
clearTimeout(this._debounceTimer);
}
// Defer state updates to next task, allowing input to render immediately
this._debounceTimer = setTimeout(() => {
if (!this._isOpen && newValue) {
this._isOpen = true;
this.emit("ct-open", {});
}
this._query = newValue;
this._highlightedIndex = 0;
this._debounceTimer = null;
}, 0) as unknown as number;
}
private _handleFocus() {
// In single-select mode with a selected value, select all text so user can easily replace
if (!this.multiple && this._displayValue && this._input) {
// Copy the selected label to query so user can modify it
this._query = this._displayValue;
// Select all text after render
requestAnimationFrame(() => {
this._input?.select();
});
}
if (!this._isOpen) {
this._open();
}
}
private _handleKeyDown(e: KeyboardEvent) {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
if (!this._isOpen) {
this._open();
} else {
this._moveHighlight(1);
}
break;
case "ArrowUp":
e.preventDefault();
if (this._isOpen) {
this._moveHighlight(-1);
}
break;
case "Enter":
if (this._isOpen) {
e.preventDefault();
this._selectHighlighted();
}
// If dropdown is closed, don't prevent default - allow form submission
break;
case "Escape":
e.preventDefault();
this._close();
break;
case "Backspace":
// In single-select mode, if input is empty (showing selected label),
// clear the selection
if (!this.multiple && !this._query) {
const currentValue = this._getCurrentValue();
if (currentValue) {
e.preventDefault();
this._cellController.setValue("");
this._query = "";
}
}
break;
case "Tab":
this._close();
break;
}
}
private _handleOutsideClick = (e: MouseEvent) => {
if (!this._isOpen) return;
const path = e.composedPath();
if (!path.includes(this)) {
this._close();
}
};
// Selection methods
private _selectItem(item: AutocompleteItem) {
// Always emit ct-select for side effects
// Include data field if present (allows passing arbitrary objects through selection)
this.emit("ct-select", {
value: item.value,
label: item.label || item.value,
group: item.group,
isCustom: false,
...(item.data !== undefined && { data: item.data }),
});
// Update value through cell controller
if (this.multiple) {
// Add to array
const current =
(this._getCurrentValue() as readonly string[] | undefined) || [];
if (!current.includes(item.value)) {
this._cellController.setValue([...current, item.value]);
}
} else {
// Replace single value
this._cellController.setValue(item.value);
}
// Clear query for multi, keep empty for single (user can see there's no selection displayed)
this._query = "";
this._close();
}
private _selectCustomValue() {
if (!this._query.trim()) return;
const customValue = this._query.trim();
// Always emit ct-select for side effects
this.emit("ct-select", {
value: customValue,
label: customValue,
isCustom: true,
});
// Update value through cell controller
if (this.multiple) {
// Add to array
const current =
(this._getCurrentValue() as readonly string[] | undefined) || [];
if (!current.includes(customValue)) {
this._cellController.setValue([...current, customValue]);
}
} else {
// Replace single value
this._cellController.setValue(customValue);
}
this._query = "";
this._close();
}
private _selectHighlighted() {
const filtered = this._filteredItems;
const alreadySelected = this._alreadySelectedItems;
// Limit to rendered items
const maxRender = this.maxVisible + 4;
const renderedFilteredCount = Math.min(filtered.length, maxRender);
// Order: filtered items → already-selected items → custom option
const alreadySelectedStartIndex = renderedFilteredCount;
const customOptionIndex = renderedFilteredCount + alreadySelected.length;
if (this._highlightedIndex < renderedFilteredCount) {
// Regular selectable item
this._selectItem(filtered[this._highlightedIndex]);
} else if (
this._highlightedIndex >= alreadySelectedStartIndex &&
this._highlightedIndex < customOptionIndex
) {
// Already-selected item - remove it
const alreadySelectedIndex = this._highlightedIndex -
alreadySelectedStartIndex;
this._removeItem(alreadySelected[alreadySelectedIndex]);
} else if (
this._showCustomOption && this._highlightedIndex === customOptionIndex
) {
// Custom value option (at the end)
this._selectCustomValue();
}
}
// Remove an item from the selected values (multi-select only)
private _removeItem(item: AutocompleteItem) {
if (!this.multiple) return;
const current =
(this._getCurrentValue() as readonly string[] | undefined) || [];
const newValue = current.filter((v) => v !== item.value);
this._cellController.setValue(newValue);
// Don't close - user might want to remove more or add new ones
this._query = "";
}
// Highlight navigation
private _setHighlight(index: number) {
this._highlightedIndex = index;
}
private _moveHighlight(delta: number) {
const total = this._totalSelectableItems;
if (total === 0) return;
this._highlightedIndex = (this._highlightedIndex + delta + total) % total;
// Scroll the highlighted option into view
this._scrollHighlightedIntoView();
}
private _scrollHighlightedIntoView() {
// Use requestAnimationFrame to ensure DOM has updated
requestAnimationFrame(() => {
const option = this.shadowRoot?.querySelector(
`#option-${this._highlightedIndex}`,
);
if (option) {
option.scrollIntoView({ block: "nearest", behavior: "auto" });
}
});
}
// Open/close methods
private _open() {
if (this.disabled) return;
this._isOpen = true;
this._highlightedIndex = 0;
this.emit("ct-open", {});
}
private _close() {
this._isOpen = false;
// Clear query so display reverts to selected value (in single mode)
// or empty (in multi mode)
this._query = "";
this.emit("ct-close", {});
}
// Dropdown positioning
private _updateDropdownPosition() {
if (!this._input) return;
const inputRect = this._input.getBoundingClientRect();
const viewportHeight = globalThis.innerHeight;
const dropdownHeight = Math.min(
this._totalSelectableItems * 40,
this.maxVisible * 40,
);
const spaceBelow = viewportHeight - inputRect.bottom;
const spaceAbove = inputRect.top;
// Calculate fixed position coordinates
const left = inputRect.left;
const width = inputRect.width;
// Position above if not enough space below but enough above
let top: number;
if (spaceBelow < dropdownHeight && spaceAbove > dropdownHeight) {
// Position above the input
top = inputRect.top - dropdownHeight - 4;
} else {
// Position below the input
top = inputRect.bottom + 4;
}
this._dropdownStyle = `top: ${top}px; left: ${left}px; width: ${width}px`;
}
// Public API
override focus(): void {
this._input?.focus();
}
override blur(): void {
this._input?.blur();
this._close();
}
/** Clear the current query */
clear(): void {
this._query = "";
this._close();
}
}
globalThis.customElements.define("ct-autocomplete", CTAutocomplete);