Adds fuzzy search filter system
Implements a fuzzy search filter system using Fuse.js to enhance event searching. This system allows users to quickly find events by typing partial matches of event titles or descriptions, providing visual feedback by dimming non-matching events. The filter persists during navigation and includes escape key support for quick clearing. It also includes performance optimizations like requestAnimationFrame debouncing.
This commit is contained in:
parent
a3ed03ff44
commit
12df6a9b06
9 changed files with 513 additions and 1 deletions
|
|
@ -7,6 +7,7 @@ import { GridManager } from './GridManager.js';
|
|||
import { EventRenderingService } from '../renderers/EventRendererManager.js';
|
||||
import { ScrollManager } from './ScrollManager.js';
|
||||
import { DateCalculator } from '../utils/DateCalculator.js';
|
||||
import { EventFilterManager } from './EventFilterManager.js';
|
||||
|
||||
/**
|
||||
* CalendarManager - Main coordinator for all calendar managers
|
||||
|
|
@ -19,6 +20,7 @@ export class CalendarManager {
|
|||
private gridManager: GridManager;
|
||||
private eventRenderer: EventRenderingService;
|
||||
private scrollManager: ScrollManager;
|
||||
private eventFilterManager: EventFilterManager;
|
||||
private dateCalculator: DateCalculator;
|
||||
private currentView: CalendarView = 'week';
|
||||
private currentDate: Date = new Date();
|
||||
|
|
@ -38,6 +40,7 @@ export class CalendarManager {
|
|||
this.gridManager = gridManager;
|
||||
this.eventRenderer = eventRenderer;
|
||||
this.scrollManager = scrollManager;
|
||||
this.eventFilterManager = new EventFilterManager();
|
||||
this.dateCalculator = new DateCalculator(config);
|
||||
this.setupEventListeners();
|
||||
console.log('📋 CalendarManager: Created with proper dependency injection');
|
||||
|
|
|
|||
238
src/managers/EventFilterManager.ts
Normal file
238
src/managers/EventFilterManager.ts
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
/**
|
||||
* EventFilterManager - Handles fuzzy search filtering of calendar events
|
||||
* Uses Fuse.js for fuzzy matching (Apache 2.0 License)
|
||||
*/
|
||||
|
||||
import { eventBus } from '../core/EventBus';
|
||||
import { CoreEvents } from '../constants/CoreEvents';
|
||||
import { CalendarEvent } from '../types/CalendarTypes';
|
||||
|
||||
// Import Fuse.js ES module
|
||||
// @ts-ignore - Fuse.js types not available for local file
|
||||
import Fuse from '../../wwwroot/js/lib/fuse.min.mjs';
|
||||
|
||||
export class EventFilterManager {
|
||||
private searchInput: HTMLInputElement | null = null;
|
||||
private allEvents: CalendarEvent[] = [];
|
||||
private matchingEventIds: Set<string> = new Set();
|
||||
private isFilterActive: boolean = false;
|
||||
private frameRequest: number | null = null;
|
||||
private fuse: any = null;
|
||||
|
||||
constructor() {
|
||||
// Wait for DOM to be ready before initializing
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
this.init();
|
||||
});
|
||||
} else {
|
||||
this.init();
|
||||
}
|
||||
}
|
||||
|
||||
private init(): void {
|
||||
// Find search input
|
||||
this.searchInput = document.querySelector('swp-search-container input[type="search"]');
|
||||
|
||||
if (!this.searchInput) {
|
||||
console.warn('EventFilterManager: Search input not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up event listeners
|
||||
this.setupSearchListeners();
|
||||
this.subscribeToEvents();
|
||||
|
||||
// Initialization complete
|
||||
}
|
||||
|
||||
private setupSearchListeners(): void {
|
||||
if (!this.searchInput) return;
|
||||
|
||||
// Listen for input changes
|
||||
this.searchInput.addEventListener('input', (e) => {
|
||||
const query = (e.target as HTMLInputElement).value;
|
||||
this.handleSearchInput(query);
|
||||
});
|
||||
|
||||
// Listen for escape key
|
||||
this.searchInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
this.clearFilter();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private subscribeToEvents(): void {
|
||||
// Listen for events data updates
|
||||
eventBus.on(CoreEvents.EVENTS_RENDERED, (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
if (detail?.events) {
|
||||
this.updateEventsList(detail.events);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private updateEventsList(events: CalendarEvent[]): void {
|
||||
this.allEvents = events;
|
||||
|
||||
// Initialize Fuse with the new events list
|
||||
this.fuse = new Fuse(this.allEvents, {
|
||||
keys: ['title', 'description'],
|
||||
threshold: 0.3,
|
||||
includeScore: true,
|
||||
minMatchCharLength: 2, // Minimum 2 characters for a match
|
||||
shouldSort: true,
|
||||
ignoreLocation: true // Search anywhere in the string
|
||||
});
|
||||
|
||||
|
||||
// Re-apply filter if active
|
||||
if (this.isFilterActive && this.searchInput) {
|
||||
this.applyFilter(this.searchInput.value);
|
||||
}
|
||||
}
|
||||
|
||||
private handleSearchInput(query: string): void {
|
||||
// Cancel any pending filter
|
||||
if (this.frameRequest) {
|
||||
cancelAnimationFrame(this.frameRequest);
|
||||
}
|
||||
|
||||
// Debounce with requestAnimationFrame
|
||||
this.frameRequest = requestAnimationFrame(() => {
|
||||
if (query.length === 0) {
|
||||
// Only clear when input is completely empty
|
||||
this.clearFilter();
|
||||
} else {
|
||||
// Let Fuse.js handle minimum character length via minMatchCharLength
|
||||
this.applyFilter(query);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private applyFilter(query: string): void {
|
||||
if (!this.fuse) {
|
||||
console.warn('EventFilterManager: Cannot filter - Fuse not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
// Perform fuzzy search
|
||||
const results = this.fuse.search(query);
|
||||
|
||||
// Extract matching event IDs
|
||||
this.matchingEventIds.clear();
|
||||
results.forEach((result: any) => {
|
||||
if (result.item && result.item.id) {
|
||||
this.matchingEventIds.add(result.item.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Update filter state
|
||||
this.isFilterActive = true;
|
||||
|
||||
// Update visual state
|
||||
this.updateVisualState();
|
||||
|
||||
// Emit filter changed event
|
||||
eventBus.emit(CoreEvents.FILTER_CHANGED, {
|
||||
active: true,
|
||||
query: query,
|
||||
matchingIds: Array.from(this.matchingEventIds)
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
private clearFilter(): void {
|
||||
this.isFilterActive = false;
|
||||
this.matchingEventIds.clear();
|
||||
|
||||
// Clear search input
|
||||
if (this.searchInput) {
|
||||
this.searchInput.value = '';
|
||||
}
|
||||
|
||||
// Update visual state
|
||||
this.updateVisualState();
|
||||
|
||||
// Emit filter cleared event
|
||||
eventBus.emit(CoreEvents.FILTER_CHANGED, {
|
||||
active: false,
|
||||
query: '',
|
||||
matchingIds: []
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
private updateVisualState(): void {
|
||||
// Update search container styling
|
||||
const searchContainer = document.querySelector('swp-search-container');
|
||||
if (searchContainer) {
|
||||
if (this.isFilterActive) {
|
||||
searchContainer.classList.add('filter-active');
|
||||
} else {
|
||||
searchContainer.classList.remove('filter-active');
|
||||
}
|
||||
}
|
||||
|
||||
// Update all events layers
|
||||
const eventsLayers = document.querySelectorAll('swp-events-layer');
|
||||
eventsLayers.forEach(layer => {
|
||||
if (this.isFilterActive) {
|
||||
layer.setAttribute('data-filter-active', 'true');
|
||||
|
||||
// Mark matching events
|
||||
const events = layer.querySelectorAll('swp-event');
|
||||
events.forEach(event => {
|
||||
const eventId = event.getAttribute('data-event-id');
|
||||
if (eventId && this.matchingEventIds.has(eventId)) {
|
||||
event.setAttribute('data-matches', 'true');
|
||||
} else {
|
||||
event.removeAttribute('data-matches');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
layer.removeAttribute('data-filter-active');
|
||||
|
||||
// Remove all match attributes
|
||||
const events = layer.querySelectorAll('swp-event');
|
||||
events.forEach(event => {
|
||||
event.removeAttribute('data-matches');
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an event matches the current filter
|
||||
*/
|
||||
public eventMatchesFilter(eventId: string): boolean {
|
||||
if (!this.isFilterActive) {
|
||||
return true; // No filter active, all events match
|
||||
}
|
||||
return this.matchingEventIds.has(eventId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current filter state
|
||||
*/
|
||||
public getFilterState(): { active: boolean; matchingIds: string[] } {
|
||||
return {
|
||||
active: this.isFilterActive,
|
||||
matchingIds: Array.from(this.matchingEventIds)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up
|
||||
*/
|
||||
public destroy(): void {
|
||||
// Note: We can't easily remove anonymous event listeners
|
||||
// In production, we'd store references to the bound functions
|
||||
|
||||
if (this.frameRequest) {
|
||||
cancelAnimationFrame(this.frameRequest);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -39,6 +39,12 @@ export class NavigationManager {
|
|||
console.log('NavigationManager: Received CALENDAR_INITIALIZED, updating week info');
|
||||
this.updateWeekInfo();
|
||||
});
|
||||
|
||||
// Listen for filter changes and apply to pre-rendered grids
|
||||
this.eventBus.on(CoreEvents.FILTER_CHANGED, (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
this.navigationRenderer.applyFilterToPreRenderedGrids(detail);
|
||||
});
|
||||
|
||||
// Listen for navigation button clicks
|
||||
document.addEventListener('click', (e) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue