// ============================================================================
// Stylescape | Filter Manager
// ============================================================================
// Manages filtering of items in lists, tables, or grids.
// Supports data-ss-filter attributes for declarative configuration.
// ============================================================================
/**
* Configuration options for FilterManager
*/
export interface FilterManagerOptions {
/** Selector for items to filter */
itemSelector?: string;
/** Attribute or property to search in (default: textContent) */
searchIn?: string | string[];
/** Debounce delay in ms */
debounce?: number;
/** Whether search is case-sensitive */
caseSensitive?: boolean;
/** Minimum characters before filtering */
minChars?: number;
/** CSS class for hidden items */
hiddenClass?: string;
/** CSS class for matching items */
matchClass?: string;
/** Callback when filter is applied */
onFilter?: (matches: HTMLElement[], query: string) => void;
/** Callback when no results found */
onEmpty?: (query: string) => void;
}
/**
* Filter manager for searching/filtering lists and collections.
*
* @example JavaScript
* ```typescript
* const filter = new FilterManager("#search", {
* itemSelector: ".list-item",
* debounce: 200
* })
* ```
*
* @example HTML with data-ss
* ```html
*
*
*
Item 1
* Item 2
* ```
*/
export class FilterManager {
private input: HTMLInputElement | null;
private items: HTMLElement[];
private options: Required;
private debounceTimer: number | null = null;
constructor(
inputSelectorOrElement: string | HTMLInputElement,
options: FilterManagerOptions = {},
) {
this.input =
typeof inputSelectorOrElement === "string"
? document.querySelector(
inputSelectorOrElement,
)
: inputSelectorOrElement;
this.options = {
itemSelector: options.itemSelector ?? ".filter-item",
searchIn: options.searchIn ?? "textContent",
debounce: options.debounce ?? 150,
caseSensitive: options.caseSensitive ?? false,
minChars: options.minChars ?? 0,
hiddenClass: options.hiddenClass ?? "filter--hidden",
matchClass: options.matchClass ?? "filter--match",
onFilter: options.onFilter ?? (() => {}),
onEmpty: options.onEmpty ?? (() => {}),
};
this.items = [];
if (!this.input) {
console.warn("[Stylescape] FilterManager input not found");
return;
}
this.init();
}
// ========================================================================
// Public Methods
// ========================================================================
/**
* Apply filter with current input value
*/
public filter(query?: string): HTMLElement[] {
const searchQuery = query ?? this.input?.value ?? "";
return this.applyFilter(searchQuery);
}
/**
* Clear the filter (show all items)
*/
public clear(): void {
if (this.input) {
this.input.value = "";
}
this.showAll();
}
/**
* Show all items
*/
public showAll(): void {
this.items.forEach((item) => {
item.classList.remove(this.options.hiddenClass);
item.classList.remove(this.options.matchClass);
});
}
/**
* Refresh items (call after DOM changes)
*/
public refresh(): void {
this.items = Array.from(
document.querySelectorAll(this.options.itemSelector),
);
}
/**
* Get current matches
*/
public getMatches(): HTMLElement[] {
return this.items.filter(
(item) => !item.classList.contains(this.options.hiddenClass),
);
}
/**
* Destroy the filter manager
*/
public destroy(): void {
this.input?.removeEventListener("input", this.handleInput);
this.input = null;
this.items = [];
}
// ========================================================================
// Private Methods
// ========================================================================
private init(): void {
this.refresh();
this.input?.addEventListener("input", this.handleInput);
// Apply initial filter if input has value
if (this.input?.value) {
this.filter();
}
}
private handleInput = (): void => {
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
this.debounceTimer = window.setTimeout(() => {
this.filter();
}, this.options.debounce);
};
private applyFilter(query: string): HTMLElement[] {
// Check minimum characters
if (query.length < this.options.minChars) {
this.showAll();
return this.items;
}
const normalizedQuery = this.options.caseSensitive
? query
: query.toLowerCase();
const matches: HTMLElement[] = [];
this.items.forEach((item) => {
const content = this.getSearchContent(item);
const normalizedContent = this.options.caseSensitive
? content
: content.toLowerCase();
const isMatch = normalizedContent.includes(normalizedQuery);
item.classList.toggle(this.options.hiddenClass, !isMatch);
item.classList.toggle(this.options.matchClass, isMatch);
if (isMatch) {
matches.push(item);
}
});
this.options.onFilter(matches, query);
if (matches.length === 0 && query.length > 0) {
this.options.onEmpty(query);
}
return matches;
}
private getSearchContent(item: HTMLElement): string {
const searchIn = Array.isArray(this.options.searchIn)
? this.options.searchIn
: [this.options.searchIn];
return searchIn
.map((attr) => {
if (attr === "textContent") {
return item.textContent || "";
}
return item.getAttribute(attr) || item.dataset[attr] || "";
})
.join(" ");
}
}
export default FilterManager;