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
195
docs/fuzzy-search-filter-system.md
Normal file
195
docs/fuzzy-search-filter-system.md
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
# Fuzzy Search Filter System
|
||||
|
||||
## Overview
|
||||
|
||||
The calendar includes a powerful fuzzy search filter system that allows users to quickly find events by typing partial matches of event titles or descriptions. The system dims non-matching events while keeping matching events fully visible, providing instant visual feedback.
|
||||
|
||||
## Features
|
||||
|
||||
- **Real-time fuzzy search** using Fuse.js library
|
||||
- **Visual filtering** with opacity-based dimming (0.2 for non-matches, 1.0 for matches)
|
||||
- **Minimum character threshold** (2 characters) handled by Fuse.js configuration
|
||||
- **Cross-navigation persistence** - filter applies to pre-rendered grids during navigation
|
||||
- **Escape key support** for quick filter clearing
|
||||
- **Performance optimized** with requestAnimationFrame debouncing
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Components
|
||||
|
||||
#### EventFilterManager (`src/managers/EventFilterManager.ts`)
|
||||
Central component responsible for:
|
||||
- Managing search input event listeners
|
||||
- Performing fuzzy search with Fuse.js
|
||||
- Coordinating visual updates across all event containers
|
||||
- Emitting filter change events via EventBus
|
||||
|
||||
#### CSS-based Visual System
|
||||
```css
|
||||
/* Dim all events when filter is active */
|
||||
swp-events-layer[data-filter-active="true"] swp-event {
|
||||
opacity: 0.2;
|
||||
transition: opacity 200ms ease;
|
||||
}
|
||||
|
||||
/* Keep matching events fully visible */
|
||||
swp-events-layer[data-filter-active="true"] swp-event[data-matches="true"] {
|
||||
opacity: 1;
|
||||
}
|
||||
```
|
||||
|
||||
#### Pre-rendered Grid Support
|
||||
- NavigationManager listens for `FILTER_CHANGED` events
|
||||
- NavigationRenderer applies filter state to all grid containers
|
||||
- Ensures filter persists when navigating between time periods
|
||||
|
||||
### Event Flow
|
||||
|
||||
1. **User Input** → Search field receives input
|
||||
2. **Debounced Processing** → RequestAnimationFrame prevents excessive filtering
|
||||
3. **Fuzzy Search** → Fuse.js performs search against event titles/descriptions
|
||||
4. **DOM Updates** → Matching events marked with `data-matches="true"`
|
||||
5. **CSS Application** → Non-matching events automatically dimmed via CSS rules
|
||||
6. **Cross-Grid Sync** → Filter applied to all pre-rendered grids
|
||||
|
||||
## Configuration
|
||||
|
||||
### Fuse.js Settings
|
||||
```typescript
|
||||
const fuseOptions = {
|
||||
keys: ['title', 'description'], // Search in title and description
|
||||
threshold: 0.3, // 0 = exact match, 1 = match anything
|
||||
includeScore: true, // Include relevance scores
|
||||
minMatchCharLength: 2, // Minimum 2 characters for match
|
||||
shouldSort: true, // Sort by relevance
|
||||
ignoreLocation: true // Search anywhere in string
|
||||
};
|
||||
```
|
||||
|
||||
### CSS Variables
|
||||
```css
|
||||
:root {
|
||||
--filter-dimmed-opacity: 0.2; /* Opacity for non-matching events */
|
||||
--filter-transition: 200ms ease; /* Smooth opacity transitions */
|
||||
}
|
||||
```
|
||||
|
||||
## Integration Points
|
||||
|
||||
### CalendarManager
|
||||
```typescript
|
||||
// EventFilterManager is initialized in CalendarManager constructor
|
||||
this.eventFilterManager = new EventFilterManager();
|
||||
```
|
||||
|
||||
### EventRenderer
|
||||
```typescript
|
||||
// Events emit EVENTS_RENDERED for filter system to track
|
||||
this.eventBus.emit(CoreEvents.EVENTS_RENDERED, {
|
||||
events: events,
|
||||
container: context.container
|
||||
});
|
||||
```
|
||||
|
||||
### HTML Structure
|
||||
```html
|
||||
<swp-search-container>
|
||||
<input type="search" placeholder="Search events..." />
|
||||
</swp-search-container>
|
||||
```
|
||||
|
||||
## User Experience
|
||||
|
||||
### Search Behavior
|
||||
- **No input**: All events visible at full opacity
|
||||
- **1 character**: Filter active, but Fuse.js won't return matches (by design)
|
||||
- **2+ characters**: Real-time fuzzy matching with visual feedback
|
||||
- **Empty field**: Filter cleared, all events return to normal
|
||||
- **Escape key**: Immediate filter clearing
|
||||
|
||||
### Visual Feedback
|
||||
- **Search field styling**: Border color changes when filter is active
|
||||
- **Event dimming**: Non-matching events fade to 20% opacity
|
||||
- **Smooth transitions**: 200ms ease transitions for polished feel
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Optimization Strategies
|
||||
1. **RequestAnimationFrame debouncing** prevents excessive search operations
|
||||
2. **CSS-based visual updates** leverage browser's rendering pipeline
|
||||
3. **Event delegation** avoids per-event listener overhead
|
||||
4. **Efficient DOM queries** using attribute selectors
|
||||
|
||||
### Memory Management
|
||||
- Event listeners properly scoped to component lifecycle
|
||||
- Fuse.js instance recreated only when event data changes
|
||||
- Minimal DOM manipulation through attribute-based CSS rules
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Graceful Degradation
|
||||
- System continues to function if Fuse.js fails to load
|
||||
- Search input remains functional for basic text entry
|
||||
- Console warnings for debugging (removed in production)
|
||||
|
||||
### Edge Cases
|
||||
- **No events to search**: Filter system remains inactive
|
||||
- **Empty search results**: All events dimmed (expected behavior)
|
||||
- **DOM timing issues**: Waits for DOM readiness before initialization
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Potential Improvements
|
||||
- **Search result highlighting** within event text
|
||||
- **Advanced filters** (date range, event type, etc.)
|
||||
- **Search history** with dropdown suggestions
|
||||
- **Keyboard navigation** through search results
|
||||
- **Search analytics** for usage insights
|
||||
|
||||
### Performance Optimizations
|
||||
- **Virtual scrolling** for large event sets
|
||||
- **Search index caching** for repeated queries
|
||||
- **Web Workers** for heavy search operations
|
||||
|
||||
## Dependencies
|
||||
|
||||
### External Libraries
|
||||
- **Fuse.js** (v7.x) - Fuzzy search library
|
||||
- License: Apache 2.0
|
||||
- File: `wwwroot/js/lib/fuse.min.mjs`
|
||||
- Import: ES module format
|
||||
|
||||
### Internal Dependencies
|
||||
- **EventBus** - Inter-component communication
|
||||
- **CoreEvents** - Centralized event type definitions
|
||||
- **CalendarEvent** - Event data type definitions
|
||||
|
||||
## Testing Considerations
|
||||
|
||||
### Test Scenarios
|
||||
1. **Basic search functionality** - Verify fuzzy matching works
|
||||
2. **Performance testing** - Large event sets (1000+ events)
|
||||
3. **Cross-navigation** - Filter persistence during navigation
|
||||
4. **Edge cases** - Empty results, special characters, very long queries
|
||||
5. **Accessibility** - Screen reader compatibility, keyboard navigation
|
||||
|
||||
### Browser Compatibility
|
||||
- **Modern browsers** with ES modules support
|
||||
- **CSS custom properties** support required
|
||||
- **requestAnimationFrame** API required
|
||||
|
||||
## Maintenance Notes
|
||||
|
||||
### Code Organization
|
||||
- **Single responsibility** - EventFilterManager handles only filtering
|
||||
- **Event-driven** - Loose coupling via EventBus
|
||||
- **CSS-first** - Visual logic in stylesheets, not JavaScript
|
||||
- **Type-safe** - Full TypeScript implementation
|
||||
|
||||
### Common Issues
|
||||
- **Timing**: Ensure EventFilterManager initializes after DOM ready
|
||||
- **Event data**: Verify EVENTS_RENDERED events are emitted correctly
|
||||
- **CSS specificity**: Filter styles must override existing event styles
|
||||
- **Memory leaks**: Properly clean up event listeners in destroy()
|
||||
|
||||
This fuzzy search filter system provides an intuitive and performant way for users to quickly locate specific events within the calendar interface.
|
||||
|
|
@ -37,7 +37,13 @@ export const CoreEvents = {
|
|||
|
||||
// System events (2)
|
||||
ERROR: 'system:error',
|
||||
REFRESH_REQUESTED: 'system:refresh'
|
||||
REFRESH_REQUESTED: 'system:refresh',
|
||||
|
||||
// Filter events (1)
|
||||
FILTER_CHANGED: 'filter:changed',
|
||||
|
||||
// Rendering events (1)
|
||||
EVENTS_RENDERED: 'events:rendered'
|
||||
} as const;
|
||||
|
||||
// Type for the event values
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -40,6 +40,12 @@ export class NavigationManager {
|
|||
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) => {
|
||||
const target = e.target as HTMLElement;
|
||||
|
|
|
|||
|
|
@ -53,6 +53,12 @@ export class EventRenderingService {
|
|||
this.strategy.renderEvents(events, context.container, calendarConfig);
|
||||
|
||||
console.log(` ✅ Rendered ${events.length} events successfully`);
|
||||
|
||||
// Emit EVENTS_RENDERED event for filtering system
|
||||
this.eventBus.emit(CoreEvents.EVENTS_RENDERED, {
|
||||
events: events,
|
||||
container: context.container
|
||||
});
|
||||
}
|
||||
|
||||
private setupEventListeners(): void {
|
||||
|
|
|
|||
|
|
@ -49,6 +49,46 @@ export class NavigationRenderer {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply filter state to pre-rendered grids
|
||||
*/
|
||||
public applyFilterToPreRenderedGrids(filterState: { active: boolean; matchingIds: string[] }): void {
|
||||
// Find all grid containers (including pre-rendered ones)
|
||||
const allGridContainers = document.querySelectorAll('swp-grid-container');
|
||||
|
||||
allGridContainers.forEach(container => {
|
||||
const eventsLayers = container.querySelectorAll('swp-events-layer');
|
||||
|
||||
eventsLayers.forEach(layer => {
|
||||
if (filterState.active) {
|
||||
// Apply filter active state
|
||||
layer.setAttribute('data-filter-active', 'true');
|
||||
|
||||
// Mark matching events in this layer
|
||||
const events = layer.querySelectorAll('swp-event');
|
||||
events.forEach(event => {
|
||||
const eventId = event.getAttribute('data-event-id');
|
||||
if (eventId && filterState.matchingIds.includes(eventId)) {
|
||||
event.setAttribute('data-matches', 'true');
|
||||
} else {
|
||||
event.removeAttribute('data-matches');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Remove filter state
|
||||
layer.removeAttribute('data-filter-active');
|
||||
|
||||
// Remove all match attributes
|
||||
const events = layer.querySelectorAll('swp-event');
|
||||
events.forEach(event => {
|
||||
event.removeAttribute('data-matches');
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a complete container with content and events
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -198,6 +198,12 @@ swp-search-container {
|
|||
}
|
||||
}
|
||||
|
||||
/* Visual indication when filter is active */
|
||||
swp-search-container.filter-active input {
|
||||
border-color: var(--color-primary);
|
||||
background: rgba(33, 150, 243, 0.05);
|
||||
}
|
||||
|
||||
/* Calendar search active state */
|
||||
swp-calendar[data-searching="true"] {
|
||||
swp-event {
|
||||
|
|
|
|||
|
|
@ -193,3 +193,15 @@ swp-event-preview {
|
|||
top: calc(var(--preview-start) * var(--minute-height));
|
||||
height: calc(var(--preview-duration) * var(--minute-height));
|
||||
}
|
||||
|
||||
/* Event filtering styles */
|
||||
/* When filter is active, all events are dimmed by default */
|
||||
swp-events-layer[data-filter-active="true"] swp-event {
|
||||
opacity: 0.2;
|
||||
transition: opacity 200ms ease;
|
||||
}
|
||||
|
||||
/* Events that match the filter stay normal */
|
||||
swp-events-layer[data-filter-active="true"] swp-event[data-matches="true"] {
|
||||
opacity: 1;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue