stylescape
Version:
Stylescape is a visual identity framework developed by Scape Agency.
369 lines (319 loc) • 11.9 kB
text/typescript
// ============================================================================
// Stylescape | Autocomplete Manager
// ============================================================================
// Provides autocomplete/search suggestions for input fields.
// Supports data-ss-autocomplete attributes for declarative configuration.
// ============================================================================
/**
* Configuration options for AutocompleteManager
*/
export interface AutocompleteOptions {
/** Static list of suggestions */
suggestions?: string[];
/** Async function to fetch suggestions */
fetchSuggestions?: (query: string) => Promise<string[]>;
/** Minimum characters before showing suggestions */
minChars?: number;
/** Maximum suggestions to show */
maxResults?: number;
/** Debounce delay in ms */
debounce?: number;
/** Whether to highlight matching text */
highlight?: boolean;
/** CSS class for the suggestions container */
containerClass?: string;
/** CSS class for suggestion items */
itemClass?: string;
/** CSS class for highlighted text */
highlightClass?: string;
/** CSS class for active/selected item */
activeClass?: string;
/** Callback when suggestion is selected */
onSelect?: (value: string, item: HTMLElement) => void;
}
/**
* Autocomplete manager for input fields with keyboard navigation.
*
* @example JavaScript
* ```typescript
* const autocomplete = new AutocompleteManager("#search", {
* suggestions: ["Apple", "Banana", "Cherry"],
* onSelect: (value) => console.log(`Selected: ${value}`)
* })
* ```
*
* @example HTML with data-ss
* ```html
* <input type="text"
* data-ss="autocomplete"
* data-ss-autocomplete-min-chars="2"
* data-ss-autocomplete-url="/api/search"
* placeholder="Search...">
* ```
*/
export class AutocompleteManager {
private input: HTMLInputElement | null;
private container: HTMLElement | null = null;
private options: Required<AutocompleteOptions>;
private activeIndex: number = -1;
private isOpen: boolean = false;
private debounceTimer: number | null = null;
private currentSuggestions: string[] = [];
constructor(
inputSelectorOrElement: string | HTMLInputElement,
options: AutocompleteOptions = {},
) {
this.input =
typeof inputSelectorOrElement === "string"
? document.querySelector<HTMLInputElement>(
inputSelectorOrElement,
)
: inputSelectorOrElement;
this.options = {
suggestions: options.suggestions ?? [],
fetchSuggestions: options.fetchSuggestions ?? (async () => []),
minChars: options.minChars ?? 1,
maxResults: options.maxResults ?? 10,
debounce: options.debounce ?? 200,
highlight: options.highlight !== false,
containerClass: options.containerClass ?? "autocomplete",
itemClass: options.itemClass ?? "autocomplete__item",
highlightClass:
options.highlightClass ?? "autocomplete__highlight",
activeClass: options.activeClass ?? "autocomplete__item--active",
onSelect: options.onSelect ?? (() => {}),
};
if (!this.input) {
console.warn("[Stylescape] AutocompleteManager input not found");
return;
}
this.init();
}
// ========================================================================
// Public Methods
// ========================================================================
/**
* Update suggestions list
*/
public setSuggestions(suggestions: string[]): void {
this.options.suggestions = suggestions;
}
/**
* Open suggestions dropdown
*/
public open(): void {
if (!this.container || this.currentSuggestions.length === 0) return;
this.container.style.display = "block";
this.isOpen = true;
this.input?.setAttribute("aria-expanded", "true");
}
/**
* Close suggestions dropdown
*/
public close(): void {
if (!this.container) return;
this.container.style.display = "none";
this.isOpen = false;
this.activeIndex = -1;
this.input?.setAttribute("aria-expanded", "false");
this.clearActive();
}
/**
* Destroy the autocomplete
*/
public destroy(): void {
this.close();
this.container?.remove();
this.input?.removeEventListener("input", this.handleInput);
this.input?.removeEventListener("keydown", this.handleKeyDown);
this.input?.removeEventListener("blur", this.handleBlur);
this.input = null;
this.container = null;
}
// ========================================================================
// Private Methods
// ========================================================================
private init(): void {
if (!this.input) return;
// Create suggestions container
this.createContainer();
// Set ARIA attributes
this.input.setAttribute("role", "combobox");
this.input.setAttribute("aria-autocomplete", "list");
this.input.setAttribute("aria-expanded", "false");
this.input.setAttribute("aria-haspopup", "listbox");
// Add event listeners
this.input.addEventListener("input", this.handleInput);
this.input.addEventListener("keydown", this.handleKeyDown);
this.input.addEventListener("blur", this.handleBlur);
this.input.addEventListener("focus", () => {
if (this.currentSuggestions.length > 0) this.open();
});
}
private createContainer(): void {
this.container = document.createElement("div");
this.container.className = this.options.containerClass;
this.container.setAttribute("role", "listbox");
this.container.style.display = "none";
this.container.style.position = "absolute";
// Position relative to input
const wrapper = document.createElement("div");
wrapper.style.position = "relative";
if (this.input) {
this.input.parentNode?.insertBefore(wrapper, this.input);
wrapper.appendChild(this.input);
}
wrapper.appendChild(this.container);
}
private handleInput = async (): Promise<void> => {
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
this.debounceTimer = window.setTimeout(async () => {
const query = this.input?.value.trim() || "";
if (query.length < this.options.minChars) {
this.close();
return;
}
await this.updateSuggestions(query);
}, this.options.debounce);
};
private async updateSuggestions(query: string): Promise<void> {
let suggestions: string[];
// Use fetch function if provided and no static suggestions match
if (
this.options.fetchSuggestions &&
this.options.suggestions.length === 0
) {
suggestions = await this.options.fetchSuggestions(query);
} else {
// Filter static suggestions
const lowerQuery = query.toLowerCase();
suggestions = this.options.suggestions.filter((s) =>
s.toLowerCase().includes(lowerQuery),
);
}
// Limit results
this.currentSuggestions = suggestions.slice(
0,
this.options.maxResults,
);
this.renderSuggestions(query);
if (this.currentSuggestions.length > 0) {
this.open();
} else {
this.close();
}
}
private renderSuggestions(query: string): void {
if (!this.container) return;
this.container.innerHTML = "";
this.activeIndex = -1;
this.currentSuggestions.forEach((suggestion, index) => {
const item = document.createElement("div");
item.className = this.options.itemClass;
item.setAttribute("role", "option");
item.setAttribute("data-index", String(index));
if (this.options.highlight) {
item.innerHTML = this.highlightMatch(suggestion, query);
} else {
item.textContent = suggestion;
}
item.addEventListener("mousedown", (e) => {
e.preventDefault();
this.selectSuggestion(index);
});
item.addEventListener("mouseenter", () => {
this.setActive(index);
});
this.container?.appendChild(item);
});
}
private highlightMatch(text: string, query: string): string {
const regex = new RegExp(`(${this.escapeRegex(query)})`, "gi");
return text.replace(
regex,
`<span class="${this.options.highlightClass}">$1</span>`,
);
}
private escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
private handleKeyDown = (e: KeyboardEvent): void => {
if (!this.isOpen) {
if (e.key === "ArrowDown" && this.currentSuggestions.length > 0) {
e.preventDefault();
this.open();
}
return;
}
switch (e.key) {
case "ArrowDown":
e.preventDefault();
this.moveActive(1);
break;
case "ArrowUp":
e.preventDefault();
this.moveActive(-1);
break;
case "Enter":
e.preventDefault();
if (this.activeIndex >= 0) {
this.selectSuggestion(this.activeIndex);
}
break;
case "Escape":
this.close();
break;
case "Tab":
this.close();
break;
}
};
private handleBlur = (): void => {
// Delay to allow click on suggestion
setTimeout(() => this.close(), 150);
};
private moveActive(delta: number): void {
const newIndex = this.activeIndex + delta;
const maxIndex = this.currentSuggestions.length - 1;
if (newIndex < 0) {
this.setActive(maxIndex);
} else if (newIndex > maxIndex) {
this.setActive(0);
} else {
this.setActive(newIndex);
}
}
private setActive(index: number): void {
this.clearActive();
this.activeIndex = index;
const item = this.container?.querySelector(`[data-index="${index}"]`);
if (item) {
item.classList.add(this.options.activeClass);
item.setAttribute("aria-selected", "true");
item.scrollIntoView({ block: "nearest" });
}
}
private clearActive(): void {
this.container
?.querySelectorAll(`.${this.options.activeClass}`)
.forEach((el) => {
el.classList.remove(this.options.activeClass);
el.setAttribute("aria-selected", "false");
});
}
private selectSuggestion(index: number): void {
const value = this.currentSuggestions[index];
if (!value || !this.input) return;
this.input.value = value;
this.close();
const item = this.container?.querySelector(
`[data-index="${index}"]`,
) as HTMLElement;
this.options.onSelect(value, item);
// Dispatch change event
this.input.dispatchEvent(new Event("change", { bubbles: true }));
}
}
export default AutocompleteManager;