
Implementing Search Filter
- 58 installs
- 426 repo stars
- Updated December 11, 2025
- ancoleman/ai-design-components
implementing-search-filter is a Claude Code skill that implements full-stack search and filter interfaces with React/TypeScript UIs and Python query backends including Elasticsearch.
About
This skill implements search and filter interfaces across the full stack. On the frontend it covers debounced search inputs, autocomplete, and filter UIs in React and TypeScript; on the backend it covers dynamic query building with SQLAlchemy or Django and Elasticsearch integration. Developers use it when adding search, building faceted filters, or optimizing search performance, with attention to accessibility and URL-synced filter state.
- Search and filter interfaces for frontend (React/TS) and backend (Python)
- Covers debounced inputs, autocomplete, faceted search, and filter UIs
- Includes SQLAlchemy/Django query building and Elasticsearch integration
Implementing Search Filter by the numbers
- 58 all-time installs (skills.sh)
- Ranked #1,226 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
implementing-search-filter capabilities & compatibility
- Capabilities
- search input · autocomplete · faceted search · query optimization
- Works with
- elasticsearch · postgres
- Use cases
- frontend · ui design · database
- Pricing
- Free
What implementing-search-filter says it does
Implements search and filter interfaces for both frontend (React/TypeScript) and backend (Python) with debouncing, query management, and database integration.
Implement 300ms debounce for performance
npx skills add https://github.com/ancoleman/ai-design-components --skill implementing-search-filterAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 58 |
|---|---|
| repo stars | ★ 426 |
| Last updated | December 11, 2025 |
| Repository | ancoleman/ai-design-components ↗ |
What it does
Adding search, autocomplete, and faceted filter UIs with optimized SQLAlchemy/Django/Elasticsearch queries.
Who is it for?
Product search, faceted filters, and autocomplete in full-stack React plus Python apps.
Skip if: Apps with no user-facing search or filtering needs.
When should I use this skill?
You are adding search functionality, building filter UIs, implementing faceted search, or optimizing search performance.
What you get
Accessible, debounced search and filter UIs backed by optimized, safe database or Elasticsearch queries.
- Debounced search input
- Autocomplete component
- Filter UI components
By the numbers
- 300ms debounce recommended
- 1000-item threshold for client vs server search
Files
Search & Filter Implementation
Implement search and filter interfaces with comprehensive frontend components and backend query optimization.
Purpose
This skill provides production-ready patterns for implementing search and filtering functionality across the full stack. It covers React/TypeScript components for the frontend (search inputs, filter UIs, autocomplete) and Python patterns for the backend (SQLAlchemy queries, Elasticsearch integration, API design). The skill emphasizes performance optimization, accessibility, and user experience.
When to Use
- Building product search with category and price filters
- Implementing autocomplete/typeahead search
- Creating faceted search interfaces with dynamic counts
- Adding search to data tables or lists
- Building advanced boolean search for power users
- Implementing backend search with SQLAlchemy or Django ORM
- Integrating Elasticsearch for full-text search
- Optimizing search performance with debouncing and caching
- Creating accessible search experiences
Core Components
Frontend Search Patterns
Search Input with Debouncing
- Implement 300ms debounce for performance
- Show loading states during search
- Clear button (X) for resetting
- Keyboard shortcuts (Cmd/Ctrl+K)
- See
references/search-input-patterns.md
Autocomplete/Typeahead
- Suggestion dropdown with keyboard navigation
- Highlight matched text in suggestions
- Recent searches and popular items
- Prevent request flooding with debouncing
- See
references/autocomplete-patterns.md
Filter UI Components
- Checkbox filters for multi-select
- Range sliders for numerical values
- Dropdown filters for single selection
- Filter chips showing active selections
- See
references/filter-ui-patterns.md
Backend Query Patterns
Database Query Building
- Dynamic query construction with SQLAlchemy
- Django ORM filter chaining
- Index optimization for search columns
- Full-text search in PostgreSQL
- See
references/database-querying.md
Elasticsearch Integration
- Document indexing strategies
- Query DSL for complex searches
- Faceted aggregations
- Relevance scoring and boosting
- See
references/elasticsearch-integration.md
API Design
- RESTful search endpoints
- Query parameter validation
- Pagination with cursor/offset
- Response caching strategies
- See
references/api-design.md
Implementation Workflows
Client-Side Search (<1000 items)
1. Load data into memory 2. Implement filter functions in JavaScript 3. Apply debounced search on text input 4. Update results instantly 5. Maintain filter state in React
Server-Side Search (>1000 items)
1. Design search API endpoint 2. Validate and sanitize query parameters 3. Build database query dynamically 4. Apply pagination 5. Return results with metadata 6. Cache frequent queries
Hybrid Approach
1. Use client-side filtering for immediate feedback 2. Fetch server results in background 3. Merge and deduplicate results 4. Update UI progressively 5. Cache recent searches locally
Performance Optimization
Frontend Optimization
Debouncing Implementation
- Use
debouncefrom lodash or custom implementation - Cancel pending requests on new input
- Show skeleton loaders during fetch
- Script:
scripts/debounce_calculator.js
Query Parameter Management
- Sync filters with URL for shareable searches
- Use React Router or Next.js for URL state
- Compress complex queries
- See
references/query-parameter-management.md
Backend Optimization
Query Optimization
- Create appropriate database indexes
- Use query analyzers to identify bottlenecks
- Implement query result caching
- Script:
scripts/generate_filter_query.py
Validation & Security
- Sanitize all search inputs
- Prevent SQL injection
- Rate limit search endpoints
- Script:
scripts/validate_search_params.py
Accessibility Requirements
ARIA Patterns
- Use
role="search"for search regions - Implement
aria-livefor result updates - Provide clear labels for filters
- Support keyboard-only navigation
Keyboard Support
- Tab through all interactive elements
- Arrow keys for autocomplete navigation
- Escape to close dropdowns
- Enter to select/submit
Technology Stack
Frontend Libraries
Primary: Downshift (Autocomplete)
- Accessible autocomplete primitives
- Headless/unstyled for flexibility
- WAI-ARIA compliant
- Install:
npm install downshift
Alternative: React Select
- Full-featured select/filter component
- Built-in async search
- Multi-select support
Backend Technologies
Python/SQLAlchemy
- Dynamic query building
- Relationship loading optimization
- Query result pagination
Python/Django
- Django Filter backend
- Django REST Framework filters
- Full-text search with PostgreSQL
Elasticsearch (Python)
- elasticsearch-py client
- elasticsearch-dsl for query building
Bundled Resources
References
references/search-input-patterns.md- Input implementationsreferences/autocomplete-patterns.md- Typeahead patternsreferences/filter-ui-patterns.md- Filter componentsreferences/database-querying.md- SQL query patternsreferences/elasticsearch-integration.md- Elasticsearch setupreferences/api-design.md- API endpoint patternsreferences/performance-optimization.md- Performance tipsreferences/library-comparison.md- Library evaluation
Scripts
scripts/generate_filter_query.py- Build SQL/ES queriesscripts/validate_search_params.py- Validate inputsscripts/debounce_calculator.js- Calculate debounce timing
Examples
examples/product-search.tsx- E-commerce searchexamples/autocomplete-search.tsx- Autocomplete implementationexamples/sqlalchemy_search.py- SQLAlchemy patternsexamples/fastapi_search.py- FastAPI search endpointexamples/django_filter_backend.py- Django filters
Assets
assets/filter-config-schema.json- Filter configurationassets/search-api-spec.json- OpenAPI specification
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Search Filter Configuration",
"description": "Configuration schema for search and filter interfaces",
"type": "object",
"properties": {
"searchConfig": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"default": true,
"description": "Enable search functionality"
},
"debounceMs": {
"type": "integer",
"minimum": 0,
"maximum": 2000,
"default": 300,
"description": "Debounce delay in milliseconds"
},
"minChars": {
"type": "integer",
"minimum": 0,
"maximum": 10,
"default": 2,
"description": "Minimum characters before triggering search"
},
"maxLength": {
"type": "integer",
"minimum": 10,
"maximum": 500,
"default": 200,
"description": "Maximum search query length"
},
"placeholder": {
"type": "string",
"default": "Search...",
"description": "Search input placeholder text"
},
"searchFields": {
"type": "array",
"items": {
"type": "string"
},
"default": ["title", "description", "tags"],
"description": "Fields to search in"
},
"enableAutocomplete": {
"type": "boolean",
"default": true,
"description": "Enable autocomplete suggestions"
},
"autocompleteLimit": {
"type": "integer",
"minimum": 1,
"maximum": 50,
"default": 10,
"description": "Maximum autocomplete suggestions"
}
}
},
"filters": {
"type": "array",
"items": {
"$ref": "#/definitions/filterDefinition"
},
"description": "List of available filters"
},
"facets": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"default": true,
"description": "Enable faceted search"
},
"showCounts": {
"type": "boolean",
"default": true,
"description": "Show result counts for each facet"
},
"dynamicCounts": {
"type": "boolean",
"default": true,
"description": "Update counts dynamically as filters change"
},
"collapsible": {
"type": "boolean",
"default": true,
"description": "Allow facet sections to be collapsed"
}
}
},
"sorting": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"default": true,
"description": "Enable sorting options"
},
"defaultSort": {
"type": "string",
"default": "relevance",
"description": "Default sort order"
},
"options": {
"type": "array",
"items": {
"$ref": "#/definitions/sortOption"
},
"description": "Available sort options"
}
}
},
"pagination": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"default": true,
"description": "Enable pagination"
},
"defaultPerPage": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"default": 20,
"description": "Default results per page"
},
"perPageOptions": {
"type": "array",
"items": {
"type": "integer"
},
"default": [10, 20, 50, 100],
"description": "Available per-page options"
},
"maxPages": {
"type": "integer",
"minimum": 1,
"maximum": 1000,
"default": 100,
"description": "Maximum number of pages"
},
"showInfo": {
"type": "boolean",
"default": true,
"description": "Show pagination info (e.g., 'Showing 1-20 of 100')"
}
}
},
"ui": {
"type": "object",
"properties": {
"layout": {
"type": "string",
"enum": ["sidebar", "top", "modal", "drawer"],
"default": "sidebar",
"description": "Filter panel layout"
},
"mobileLayout": {
"type": "string",
"enum": ["drawer", "modal", "accordion"],
"default": "drawer",
"description": "Mobile filter layout"
},
"showActiveFilters": {
"type": "boolean",
"default": true,
"description": "Display active filter chips"
},
"showClearAll": {
"type": "boolean",
"default": true,
"description": "Show 'Clear all' button"
},
"animations": {
"type": "boolean",
"default": true,
"description": "Enable UI animations"
},
"theme": {
"type": "string",
"enum": ["light", "dark", "auto"],
"default": "auto",
"description": "UI theme"
}
}
},
"performance": {
"type": "object",
"properties": {
"caching": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"default": true,
"description": "Enable result caching"
},
"ttl": {
"type": "integer",
"minimum": 0,
"maximum": 3600,
"default": 300,
"description": "Cache TTL in seconds"
},
"maxSize": {
"type": "integer",
"minimum": 0,
"maximum": 1000,
"default": 100,
"description": "Maximum cache entries"
}
}
},
"virtualization": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"default": false,
"description": "Enable virtual scrolling for large result sets"
},
"threshold": {
"type": "integer",
"minimum": 100,
"maximum": 10000,
"default": 1000,
"description": "Item count threshold for virtualization"
}
}
},
"lazyLoading": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"default": true,
"description": "Enable lazy loading of results"
},
"imageLoading": {
"type": "string",
"enum": ["eager", "lazy", "auto"],
"default": "lazy",
"description": "Image loading strategy"
}
}
}
}
},
"api": {
"type": "object",
"properties": {
"searchEndpoint": {
"type": "string",
"format": "uri",
"description": "Search API endpoint"
},
"autocompleteEndpoint": {
"type": "string",
"format": "uri",
"description": "Autocomplete API endpoint"
},
"method": {
"type": "string",
"enum": ["GET", "POST"],
"default": "POST",
"description": "HTTP method for search requests"
},
"headers": {
"type": "object",
"additionalProperties": {
"type": "string"
},
"description": "Additional headers for API requests"
},
"timeout": {
"type": "integer",
"minimum": 1000,
"maximum": 60000,
"default": 10000,
"description": "API request timeout in milliseconds"
},
"retries": {
"type": "integer",
"minimum": 0,
"maximum": 5,
"default": 3,
"description": "Number of retry attempts on failure"
}
},
"required": ["searchEndpoint"]
}
},
"definitions": {
"filterDefinition": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "Unique filter identifier"
},
"label": {
"type": "string",
"description": "Display label for the filter"
},
"type": {
"type": "string",
"enum": ["checkbox", "radio", "range", "dropdown", "date", "boolean"],
"description": "Filter UI type"
},
"field": {
"type": "string",
"description": "Field name in data/API"
},
"dataType": {
"type": "string",
"enum": ["string", "number", "boolean", "date"],
"description": "Data type of the field"
},
"options": {
"type": "array",
"items": {
"type": "object",
"properties": {
"value": {
"type": ["string", "number", "boolean"]
},
"label": {
"type": "string"
},
"count": {
"type": "integer",
"minimum": 0
}
},
"required": ["value", "label"]
},
"description": "Predefined options for select-type filters"
},
"range": {
"type": "object",
"properties": {
"min": {
"type": "number"
},
"max": {
"type": "number"
},
"step": {
"type": "number"
},
"prefix": {
"type": "string"
},
"suffix": {
"type": "string"
}
},
"description": "Configuration for range filters"
},
"multiple": {
"type": "boolean",
"default": true,
"description": "Allow multiple selections"
},
"required": {
"type": "boolean",
"default": false,
"description": "Is this filter required"
},
"defaultValue": {
"description": "Default filter value"
},
"placeholder": {
"type": "string",
"description": "Placeholder text for input filters"
},
"validation": {
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "Regex pattern for validation"
},
"min": {
"type": "number",
"description": "Minimum value"
},
"max": {
"type": "number",
"description": "Maximum value"
},
"minLength": {
"type": "integer",
"description": "Minimum string length"
},
"maxLength": {
"type": "integer",
"description": "Maximum string length"
}
}
}
},
"required": ["id", "label", "type", "field"]
},
"sortOption": {
"type": "object",
"properties": {
"value": {
"type": "string",
"description": "Sort value sent to API"
},
"label": {
"type": "string",
"description": "Display label"
},
"field": {
"type": "string",
"description": "Field to sort by"
},
"order": {
"type": "string",
"enum": ["asc", "desc"],
"description": "Sort order"
}
},
"required": ["value", "label", "field", "order"]
}
},
"required": ["api"]
}{
"openapi": "3.0.3",
"info": {
"title": "Search & Filter API",
"description": "RESTful API specification for search and filter functionality",
"version": "1.0.0"
},
"servers": [
{
"url": "https://api.example.com/v1",
"description": "Production server"
},
{
"url": "http://localhost:8000/v1",
"description": "Development server"
}
],
"paths": {
"/search": {
"get": {
"summary": "Search with query parameters",
"description": "Perform search using URL query parameters. Suitable for simple searches.",
"operationId": "searchGet",
"tags": ["Search"],
"parameters": [
{
"name": "q",
"in": "query",
"description": "Search query text",
"required": false,
"schema": {
"type": "string",
"minLength": 1,
"maxLength": 200
},
"example": "laptop"
},
{
"name": "category",
"in": "query",
"description": "Filter by categories",
"required": false,
"style": "form",
"explode": true,
"schema": {
"type": "array",
"items": {
"type": "string"
}
}
},
{
"name": "brand",
"in": "query",
"description": "Filter by brands",
"required": false,
"style": "form",
"explode": true,
"schema": {
"type": "array",
"items": {
"type": "string"
}
}
},
{
"name": "min_price",
"in": "query",
"description": "Minimum price filter",
"required": false,
"schema": {
"type": "number",
"minimum": 0
}
},
{
"name": "max_price",
"in": "query",
"description": "Maximum price filter",
"required": false,
"schema": {
"type": "number",
"minimum": 0
}
},
{
"name": "in_stock",
"in": "query",
"description": "Filter for in-stock items only",
"required": false,
"schema": {
"type": "boolean"
}
},
{
"name": "sort",
"in": "query",
"description": "Sort order",
"required": false,
"schema": {
"type": "string",
"enum": ["relevance", "price_asc", "price_desc", "newest", "rating"],
"default": "relevance"
}
},
{
"name": "page",
"in": "query",
"description": "Page number",
"required": false,
"schema": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"default": 1
}
},
{
"name": "per_page",
"in": "query",
"description": "Results per page",
"required": false,
"schema": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"default": 20
}
}
],
"responses": {
"200": {
"description": "Successful search",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SearchResponse"
}
}
}
},
"400": {
"description": "Invalid parameters",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"429": {
"description": "Rate limit exceeded",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"500": {
"description": "Internal server error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
},
"post": {
"summary": "Search with request body",
"description": "Perform search using JSON request body. Suitable for complex searches.",
"operationId": "searchPost",
"tags": ["Search"],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SearchRequest"
}
}
}
},
"responses": {
"200": {
"description": "Successful search",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SearchResponse"
}
}
}
},
"400": {
"description": "Invalid request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"429": {
"description": "Rate limit exceeded",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"500": {
"description": "Internal server error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/autocomplete": {
"get": {
"summary": "Get search suggestions",
"description": "Returns autocomplete suggestions based on query prefix",
"operationId": "autocomplete",
"tags": ["Search"],
"parameters": [
{
"name": "q",
"in": "query",
"description": "Query prefix",
"required": true,
"schema": {
"type": "string",
"minLength": 2,
"maxLength": 50
}
},
{
"name": "limit",
"in": "query",
"description": "Maximum suggestions",
"required": false,
"schema": {
"type": "integer",
"minimum": 1,
"maximum": 20,
"default": 10
}
},
{
"name": "type",
"in": "query",
"description": "Suggestion type filter",
"required": false,
"schema": {
"type": "string",
"enum": ["all", "products", "categories", "brands"]
}
}
],
"responses": {
"200": {
"description": "Suggestions found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AutocompleteResponse"
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"SearchRequest": {
"type": "object",
"properties": {
"query": {
"type": "string",
"minLength": 1,
"maxLength": 200,
"description": "Search query text"
},
"filters": {
"$ref": "#/components/schemas/SearchFilters"
},
"sort_by": {
"type": "string",
"enum": ["relevance", "price_asc", "price_desc", "newest", "rating"],
"default": "relevance",
"description": "Sort order"
},
"page": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"default": 1,
"description": "Page number"
},
"per_page": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"default": 20,
"description": "Results per page"
},
"include_facets": {
"type": "boolean",
"default": true,
"description": "Include facet counts in response"
}
}
},
"SearchFilters": {
"type": "object",
"properties": {
"categories": {
"type": "array",
"items": {
"type": "string"
},
"maxItems": 20,
"description": "Category filters"
},
"brands": {
"type": "array",
"items": {
"type": "string"
},
"maxItems": 20,
"description": "Brand filters"
},
"min_price": {
"type": "number",
"minimum": 0,
"description": "Minimum price"
},
"max_price": {
"type": "number",
"minimum": 0,
"description": "Maximum price"
},
"in_stock": {
"type": "boolean",
"description": "Only in-stock items"
},
"min_rating": {
"type": "number",
"minimum": 1,
"maximum": 5,
"description": "Minimum rating"
},
"tags": {
"type": "array",
"items": {
"type": "string"
},
"description": "Tag filters"
}
}
},
"SearchResponse": {
"type": "object",
"required": ["products", "total", "page", "per_page", "total_pages"],
"properties": {
"products": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Product"
},
"description": "Search results"
},
"total": {
"type": "integer",
"minimum": 0,
"description": "Total number of results"
},
"page": {
"type": "integer",
"minimum": 1,
"description": "Current page"
},
"per_page": {
"type": "integer",
"minimum": 1,
"description": "Results per page"
},
"total_pages": {
"type": "integer",
"minimum": 0,
"description": "Total number of pages"
},
"facets": {
"$ref": "#/components/schemas/Facets"
},
"query_time_ms": {
"type": "number",
"description": "Query execution time in milliseconds"
},
"cached": {
"type": "boolean",
"description": "Whether result was served from cache"
}
}
},
"Product": {
"type": "object",
"required": ["id", "title", "price", "category", "brand"],
"properties": {
"id": {
"type": "string",
"description": "Unique product identifier"
},
"title": {
"type": "string",
"description": "Product title"
},
"description": {
"type": "string",
"description": "Product description"
},
"price": {
"type": "number",
"minimum": 0,
"description": "Product price"
},
"category": {
"type": "string",
"description": "Product category"
},
"brand": {
"type": "string",
"description": "Product brand"
},
"rating": {
"type": "number",
"minimum": 0,
"maximum": 5,
"description": "Average rating"
},
"review_count": {
"type": "integer",
"minimum": 0,
"description": "Number of reviews"
},
"in_stock": {
"type": "boolean",
"description": "Stock availability"
},
"image_url": {
"type": "string",
"format": "uri",
"description": "Product image URL"
},
"tags": {
"type": "array",
"items": {
"type": "string"
},
"description": "Product tags"
}
}
},
"Facets": {
"type": "object",
"properties": {
"categories": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Facet"
}
},
"brands": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Facet"
}
},
"price_ranges": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PriceRangeFacet"
}
},
"ratings": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Facet"
}
}
}
},
"Facet": {
"type": "object",
"required": ["value", "count"],
"properties": {
"value": {
"type": "string",
"description": "Facet value"
},
"count": {
"type": "integer",
"minimum": 0,
"description": "Number of items"
}
}
},
"PriceRangeFacet": {
"type": "object",
"required": ["min", "max", "count", "label"],
"properties": {
"min": {
"type": "number",
"description": "Minimum price in range"
},
"max": {
"type": "number",
"description": "Maximum price in range (null for open-ended)"
},
"label": {
"type": "string",
"description": "Display label"
},
"count": {
"type": "integer",
"minimum": 0,
"description": "Number of items in range"
}
}
},
"AutocompleteResponse": {
"type": "object",
"required": ["query", "suggestions"],
"properties": {
"query": {
"type": "string",
"description": "Original query"
},
"suggestions": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Suggestion"
}
}
}
},
"Suggestion": {
"type": "object",
"required": ["text", "type"],
"properties": {
"text": {
"type": "string",
"description": "Suggestion text"
},
"type": {
"type": "string",
"enum": ["product", "category", "brand", "query"],
"description": "Suggestion type"
},
"category": {
"type": "string",
"description": "Associated category"
},
"product_id": {
"type": "string",
"description": "Product ID (for product suggestions)"
},
"count": {
"type": "integer",
"description": "Result count for this suggestion"
}
}
},
"ErrorResponse": {
"type": "object",
"required": ["error", "message", "timestamp"],
"properties": {
"error": {
"type": "string",
"description": "Error code"
},
"message": {
"type": "string",
"description": "Error message"
},
"details": {
"type": "object",
"description": "Additional error details"
},
"timestamp": {
"type": "string",
"format": "date-time",
"description": "Error timestamp"
}
}
}
}
}
}/**
* Advanced autocomplete search implementation with Downshift
*
* Features:
* - Accessible autocomplete with keyboard navigation
* - Debounced API calls
* - Recent searches and suggestions
* - Highlighting of matched text
* - Loading states and error handling
*/
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useCombobox } from 'downshift';
import { Search, Clock, TrendingUp, X, Loader2 } from 'lucide-react';
import { useDebounce } from '../hooks/useDebounce';
// Types
interface Suggestion {
id: string;
text: string;
type: 'product' | 'category' | 'brand' | 'recent' | 'trending';
category?: string;
count?: number;
metadata?: any;
}
interface AutocompleteProps {
onSearch: (query: string) => void;
onSelect: (item: Suggestion) => void;
placeholder?: string;
minChars?: number;
debounceMs?: number;
maxSuggestions?: number;
}
export function AutocompleteSearch({
onSearch,
onSelect,
placeholder = 'Search products, categories, brands...',
minChars = 2,
debounceMs = 300,
maxSuggestions = 10
}: AutocompleteProps) {
// State
const [inputValue, setInputValue] = useState('');
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
const [recentSearches, setRecentSearches] = useState<Suggestion[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Refs
const abortControllerRef = useRef<AbortController | null>(null);
// Debounced search value
const debouncedSearchTerm = useDebounce(inputValue, debounceMs);
// Load recent searches from localStorage
useEffect(() => {
const stored = localStorage.getItem('recentSearches');
if (stored) {
try {
const parsed = JSON.parse(stored);
setRecentSearches(parsed.slice(0, 5));
} catch (e) {
console.error('Failed to parse recent searches:', e);
}
}
}, []);
// Fetch suggestions
const fetchSuggestions = useCallback(async (query: string) => {
// Cancel previous request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Don't search if query too short
if (query.length < minChars) {
setSuggestions([]);
return;
}
// Create new abort controller
abortControllerRef.current = new AbortController();
setIsLoading(true);
setError(null);
try {
const response = await fetch(`/api/autocomplete?q=${encodeURIComponent(query)}&limit=${maxSuggestions}`, {
signal: abortControllerRef.current.signal
});
if (!response.ok) {
throw new Error('Failed to fetch suggestions');
}
const data = await response.json();
setSuggestions(data.suggestions);
} catch (err) {
if (err.name === 'AbortError') {
// Request was cancelled, ignore
return;
}
setError('Failed to load suggestions');
setSuggestions([]);
} finally {
setIsLoading(false);
}
}, [minChars, maxSuggestions]);
// Fetch suggestions when debounced value changes
useEffect(() => {
if (debouncedSearchTerm) {
fetchSuggestions(debouncedSearchTerm);
} else {
setSuggestions([]);
}
}, [debouncedSearchTerm, fetchSuggestions]);
// Save to recent searches
const saveToRecent = useCallback((text: string) => {
const newRecent: Suggestion = {
id: `recent-${Date.now()}`,
text,
type: 'recent'
};
const updated = [
newRecent,
...recentSearches.filter(r => r.text !== text)
].slice(0, 5);
setRecentSearches(updated);
localStorage.setItem('recentSearches', JSON.stringify(updated));
}, [recentSearches]);
// Get display items (suggestions or recent/trending)
const displayItems = inputValue.length >= minChars
? suggestions
: recentSearches;
// Setup Downshift
const {
isOpen,
getMenuProps,
getInputProps,
highlightedIndex,
getItemProps,
selectedItem,
reset
} = useCombobox({
items: displayItems,
inputValue,
onInputValueChange: ({ inputValue: newValue }) => {
setInputValue(newValue || '');
},
onSelectedItemChange: ({ selectedItem }) => {
if (selectedItem) {
setInputValue(selectedItem.text);
onSelect(selectedItem);
saveToRecent(selectedItem.text);
reset();
}
},
itemToString: (item) => item?.text || ''
});
// Handle form submission
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (inputValue.trim()) {
onSearch(inputValue);
saveToRecent(inputValue);
reset();
}
};
// Clear input
const handleClear = () => {
setInputValue('');
setSuggestions([]);
reset();
};
return (
<div className="autocomplete-search">
<form onSubmit={handleSubmit} className="search-form">
<div className="search-input-wrapper">
<Search className="search-icon" size={20} />
<input
{...getInputProps()}
className="search-input"
placeholder={placeholder}
aria-label="Search"
aria-autocomplete="list"
aria-describedby="search-instructions"
/>
<span id="search-instructions" className="sr-only">
Type to search. Use arrow keys to navigate suggestions.
</span>
{/* Loading indicator */}
{isLoading && (
<div className="loading-indicator">
<Loader2 className="animate-spin" size={16} />
</div>
)}
{/* Clear button */}
{inputValue && (
<button
type="button"
onClick={handleClear}
className="clear-button"
aria-label="Clear search"
>
<X size={16} />
</button>
)}
</div>
</form>
{/* Suggestions dropdown */}
<div {...getMenuProps()} className="suggestions-dropdown">
{isOpen && displayItems.length > 0 && (
<>
{/* Show section header for recent searches */}
{inputValue.length < minChars && recentSearches.length > 0 && (
<div className="suggestions-section">
<div className="section-header">
<Clock size={14} />
<span>Recent Searches</span>
<button
onClick={() => {
setRecentSearches([]);
localStorage.removeItem('recentSearches');
}}
className="clear-recent"
>
Clear
</button>
</div>
</div>
)}
{/* Render suggestions */}
{displayItems.map((item, index) => (
<SuggestionItem
key={item.id}
item={item}
isHighlighted={highlightedIndex === index}
query={inputValue}
{...getItemProps({ item, index })}
/>
))}
</>
)}
{/* No results message */}
{isOpen && inputValue.length >= minChars && !isLoading && suggestions.length === 0 && (
<div className="no-suggestions">
No suggestions found for "{inputValue}"
</div>
)}
{/* Error message */}
{error && (
<div className="suggestions-error">
{error}
</div>
)}
</div>
</div>
);
}
// Suggestion Item Component
interface SuggestionItemProps {
item: Suggestion;
isHighlighted: boolean;
query: string;
}
function SuggestionItem({
item,
isHighlighted,
query,
...props
}: SuggestionItemProps & React.HTMLAttributes<HTMLLIElement>) {
return (
<li
className={`suggestion-item ${isHighlighted ? 'highlighted' : ''} ${item.type}`}
{...props}
>
<div className="suggestion-content">
{/* Icon based on type */}
<div className="suggestion-icon">
{item.type === 'recent' && <Clock size={16} />}
{item.type === 'trending' && <TrendingUp size={16} />}
{item.type === 'product' && <Search size={16} />}
</div>
{/* Main text with highlighting */}
<div className="suggestion-text">
<HighlightText text={item.text} highlight={query} />
{/* Additional metadata */}
{item.category && (
<span className="suggestion-category">in {item.category}</span>
)}
</div>
{/* Result count */}
{item.count !== undefined && (
<span className="suggestion-count">{item.count}</span>
)}
</div>
</li>
);
}
// Highlight matching text
interface HighlightTextProps {
text: string;
highlight: string;
}
function HighlightText({ text, highlight }: HighlightTextProps) {
if (!highlight.trim()) {
return <span>{text}</span>;
}
const regex = new RegExp(`(${highlight})`, 'gi');
const parts = text.split(regex);
return (
<span>
{parts.map((part, index) =>
regex.test(part) ? (
<mark key={index} className="highlight">
{part}
</mark>
) : (
<span key={index}>{part}</span>
)
)}
</span>
);
}
// Custom debounce hook
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
// Styles (CSS-in-JS or separate stylesheet)
const styles = `
.autocomplete-search {
position: relative;
width: 100%;
max-width: 600px;
}
.search-form {
width: 100%;
}
.search-input-wrapper {
position: relative;
display: flex;
align-items: center;
background: var(--search-input-bg);
border: 1px solid var(--search-input-border);
border-radius: var(--search-border-radius);
padding: 0 12px;
transition: all 0.2s ease;
}
.search-input-wrapper:focus-within {
border-color: var(--search-input-focus-border);
box-shadow: 0 0 0 3px var(--search-input-focus-ring);
}
.search-icon {
color: var(--search-icon-color);
flex-shrink: 0;
}
.search-input {
flex: 1;
border: none;
background: none;
padding: 12px;
font-size: 16px;
outline: none;
}
.loading-indicator {
margin-left: 8px;
}
.clear-button {
margin-left: 8px;
padding: 4px;
background: none;
border: none;
cursor: pointer;
color: var(--color-text-secondary);
transition: color 0.2s;
}
.clear-button:hover {
color: var(--color-text-primary);
}
.suggestions-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 4px;
background: var(--color-white);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
max-height: 400px;
overflow-y: auto;
z-index: 1000;
}
.suggestions-section {
padding: 8px 12px;
border-bottom: 1px solid var(--color-border);
}
.section-header {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--color-text-secondary);
}
.clear-recent {
margin-left: auto;
background: none;
border: none;
color: var(--color-primary);
cursor: pointer;
font-size: 12px;
}
.suggestion-item {
padding: 12px;
cursor: pointer;
transition: background 0.1s;
}
.suggestion-item:hover,
.suggestion-item.highlighted {
background: var(--color-gray-50);
}
.suggestion-content {
display: flex;
align-items: center;
gap: 12px;
}
.suggestion-icon {
color: var(--color-text-secondary);
flex-shrink: 0;
}
.suggestion-text {
flex: 1;
}
.suggestion-category {
margin-left: 8px;
font-size: 12px;
color: var(--color-text-secondary);
}
.suggestion-count {
font-size: 12px;
color: var(--color-text-secondary);
background: var(--color-gray-100);
padding: 2px 8px;
border-radius: var(--radius-sm);
}
.highlight {
background: var(--result-highlight-bg);
color: var(--result-highlight-text);
font-weight: 500;
}
.no-suggestions,
.suggestions-error {
padding: 16px;
text-align: center;
color: var(--color-text-secondary);
}
.suggestions-error {
color: var(--color-error);
}
.animate-spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
`;"""
Django REST Framework filter backend implementation.
This example demonstrates advanced filtering with Django REST Framework,
including custom filter backends, faceted search, and query optimization.
"""
from django.db import models
from django.db.models import Q, Count, Avg, F, Value, CharField
from django.contrib.postgres.search import SearchVector, SearchQuery, SearchRank
from django.contrib.postgres.aggregates import ArrayAgg
from rest_framework import viewsets, filters, status
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.pagination import PageNumberPagination
from django_filters import rest_framework as df
from django.core.cache import cache
from typing import Dict, List, Any
import json
import hashlib
# Models
class Product(models.Model):
"""Product model with search-optimized fields."""
title = models.CharField(max_length=200, db_index=True)
description = models.TextField()
category = models.ForeignKey('Category', on_delete=models.CASCADE, related_name='products')
brand = models.ForeignKey('Brand', on_delete=models.CASCADE, related_name='products')
price = models.DecimalField(max_digits=10, decimal_places=2, db_index=True)
rating = models.DecimalField(max_digits=3, decimal_places=2, null=True, blank=True)
in_stock = models.BooleanField(default=True, db_index=True)
tags = models.ManyToManyField('Tag', related_name='products')
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
updated_at = models.DateTimeField(auto_now=True)
# PostgreSQL specific: Full-text search vector
search_vector = SearchVector('title', weight='A') + SearchVector('description', weight='B')
class Meta:
indexes = [
models.Index(fields=['category', 'brand']),
models.Index(fields=['price', '-created_at']),
models.Index(fields=['-rating', 'in_stock']),
]
def __str__(self):
return self.title
class Category(models.Model):
name = models.CharField(max_length=50, unique=True)
parent = models.ForeignKey('self', null=True, blank=True, on_delete=models.CASCADE)
slug = models.SlugField(unique=True)
class Meta:
verbose_name_plural = 'Categories'
class Brand(models.Model):
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
class Tag(models.Model):
name = models.CharField(max_length=30, unique=True)
# Custom Filter Backend
class FacetedSearchBackend(filters.BaseFilterBackend):
"""Custom filter backend for faceted search with dynamic counts."""
def filter_queryset(self, request, queryset, view):
"""Apply filters while maintaining facet counts."""
# Get filter parameters
params = request.query_params
# Text search
query = params.get('q', '').strip()
if query:
queryset = self._apply_text_search(queryset, query)
# Category filter
categories = params.getlist('category')
if categories:
queryset = queryset.filter(category__slug__in=categories)
# Brand filter
brands = params.getlist('brand')
if brands:
queryset = queryset.filter(brand__slug__in=brands)
# Price range
min_price = params.get('min_price')
max_price = params.get('max_price')
if min_price:
queryset = queryset.filter(price__gte=min_price)
if max_price:
queryset = queryset.filter(price__lte=max_price)
# Stock filter
in_stock = params.get('in_stock')
if in_stock:
queryset = queryset.filter(in_stock=in_stock.lower() == 'true')
# Rating filter
min_rating = params.get('min_rating')
if min_rating:
queryset = queryset.filter(rating__gte=min_rating)
return queryset
def _apply_text_search(self, queryset, query):
"""Apply PostgreSQL full-text search."""
from django.db import connection
if connection.vendor == 'postgresql':
# Use PostgreSQL full-text search
search_query = SearchQuery(query, config='english')
search_vector = SearchVector('title', weight='A') + \
SearchVector('description', weight='B')
queryset = queryset.annotate(
search=search_vector,
rank=SearchRank(search_vector, search_query)
).filter(search=search_query).order_by('-rank')
else:
# Fallback to LIKE queries
queryset = queryset.filter(
Q(title__icontains=query) |
Q(description__icontains=query)
)
return queryset
# Django Filter
class ProductFilter(df.FilterSet):
"""Product filter using django-filter."""
q = df.CharFilter(method='search')
category = df.ModelMultipleChoiceFilter(
field_name='category__slug',
to_field_name='slug',
queryset=Category.objects.all()
)
brand = df.ModelMultipleChoiceFilter(
field_name='brand__slug',
to_field_name='slug',
queryset=Brand.objects.all()
)
min_price = df.NumberFilter(field_name='price', lookup_expr='gte')
max_price = df.NumberFilter(field_name='price', lookup_expr='lte')
in_stock = df.BooleanFilter()
min_rating = df.NumberFilter(field_name='rating', lookup_expr='gte')
tags = df.ModelMultipleChoiceFilter(
field_name='tags__name',
to_field_name='name',
queryset=Tag.objects.all()
)
# Date filters
created_after = df.DateFilter(field_name='created_at', lookup_expr='gte')
created_before = df.DateFilter(field_name='created_at', lookup_expr='lte')
# Ordering
o = df.OrderingFilter(
fields=(
('price', 'price'),
('rating', 'rating'),
('created_at', 'newest'),
),
field_labels={
'price': 'Price',
'-price': 'Price (high to low)',
'rating': 'Rating (low to high)',
'-rating': 'Rating (high to low)',
'-created_at': 'Newest first',
}
)
def search(self, queryset, name, value):
"""Custom search method with relevance scoring."""
if not value:
return queryset
# PostgreSQL full-text search
from django.db import connection
if connection.vendor == 'postgresql':
search_query = SearchQuery(value, config='english')
search_vector = SearchVector('title', weight='A') + \
SearchVector('description', weight='B')
return queryset.annotate(
rank=SearchRank(search_vector, search_query)
).filter(
Q(title__icontains=value) |
Q(description__icontains=value)
).order_by('-rank')
# Fallback for other databases
return queryset.filter(
Q(title__icontains=value) |
Q(description__icontains=value)
)
class Meta:
model = Product
fields = ['q', 'category', 'brand', 'min_price', 'max_price',
'in_stock', 'min_rating', 'tags']
# Serializers
from rest_framework import serializers
class ProductSerializer(serializers.ModelSerializer):
"""Product serializer with nested relationships."""
category = serializers.StringRelatedField()
brand = serializers.StringRelatedField()
tags = serializers.StringRelatedField(many=True)
class Meta:
model = Product
fields = ['id', 'title', 'description', 'category', 'brand',
'price', 'rating', 'in_stock', 'tags', 'created_at']
class FacetSerializer(serializers.Serializer):
"""Facet serializer for filter options."""
value = serializers.CharField()
label = serializers.CharField()
count = serializers.IntegerField()
class SearchResultSerializer(serializers.Serializer):
"""Search result with facets."""
products = ProductSerializer(many=True)
facets = serializers.DictField(child=FacetSerializer(many=True))
total = serializers.IntegerField()
page = serializers.IntegerField()
page_size = serializers.IntegerField()
# Custom Pagination
class SearchPagination(PageNumberPagination):
"""Custom pagination for search results."""
page_size = 20
page_size_query_param = 'page_size'
max_page_size = 100
def get_paginated_response(self, data):
"""Include additional metadata in response."""
return Response({
'products': data,
'pagination': {
'total': self.page.paginator.count,
'page': self.page.number,
'page_size': self.get_page_size(self.request),
'total_pages': self.page.paginator.num_pages,
'next': self.get_next_link(),
'previous': self.get_previous_link()
}
})
# ViewSet
class ProductViewSet(viewsets.ReadOnlyModelViewSet):
"""Product search and filter viewset."""
queryset = Product.objects.all()
serializer_class = ProductSerializer
filter_backends = [
FacetedSearchBackend,
df.DjangoFilterBackend,
filters.OrderingFilter
]
filterset_class = ProductFilter
pagination_class = SearchPagination
ordering_fields = ['price', 'rating', 'created_at']
ordering = ['-created_at']
def get_queryset(self):
"""Optimize queryset with select/prefetch related."""
queryset = super().get_queryset()
# Optimize database queries
queryset = queryset.select_related('category', 'brand')
queryset = queryset.prefetch_related('tags')
# Add annotations for computed fields
queryset = queryset.annotate(
review_count=Count('reviews', distinct=True),
avg_rating=Avg('reviews__rating')
)
return queryset
def list(self, request, *args, **kwargs):
"""Override list to include facets."""
# Check cache
cache_key = self._get_cache_key(request)
cached_result = cache.get(cache_key)
if cached_result:
return Response(cached_result)
# Get filtered queryset
queryset = self.filter_queryset(self.get_queryset())
# Get facets before pagination
facets = self._get_facets(queryset, request)
# Paginate
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
response = self.get_paginated_response(serializer.data)
response.data['facets'] = facets
# Cache result
cache.set(cache_key, response.data, 300) # 5 minutes
return response
serializer = self.get_serializer(queryset, many=True)
return Response({
'products': serializer.data,
'facets': facets
})
def _get_facets(self, queryset, request):
"""Generate facet counts for filters."""
facets = {}
# Category facets
category_facets = queryset.values('category__name', 'category__slug')\
.annotate(count=Count('id'))\
.order_by('-count')[:20]
facets['categories'] = [
{
'value': item['category__slug'],
'label': item['category__name'],
'count': item['count']
}
for item in category_facets
]
# Brand facets
brand_facets = queryset.values('brand__name', 'brand__slug')\
.annotate(count=Count('id'))\
.order_by('-count')[:20]
facets['brands'] = [
{
'value': item['brand__slug'],
'label': item['brand__name'],
'count': item['count']
}
for item in brand_facets
]
# Price range facets
price_ranges = [
(0, 50, 'Under $50'),
(50, 100, '$50-$100'),
(100, 200, '$100-$200'),
(200, 500, '$200-$500'),
(500, None, 'Over $500')
]
price_facets = []
for min_price, max_price, label in price_ranges:
count_query = queryset.filter(price__gte=min_price)
if max_price:
count_query = count_query.filter(price__lt=max_price)
count = count_query.count()
if count > 0:
price_facets.append({
'value': f'{min_price}-{max_price or "inf"}',
'label': label,
'count': count
})
facets['price_ranges'] = price_facets
# In stock count
in_stock_count = queryset.filter(in_stock=True).count()
out_of_stock_count = queryset.filter(in_stock=False).count()
facets['availability'] = [
{'value': 'true', 'label': 'In Stock', 'count': in_stock_count},
{'value': 'false', 'label': 'Out of Stock', 'count': out_of_stock_count}
]
return facets
def _get_cache_key(self, request):
"""Generate cache key from request parameters."""
params = dict(request.query_params)
# Sort for consistent hashing
params_str = json.dumps(params, sort_keys=True)
return f'search:{hashlib.md5(params_str.encode()).hexdigest()}'
@action(detail=False, methods=['get'])
def autocomplete(self, request):
"""Autocomplete endpoint for search suggestions."""
query = request.query_params.get('q', '').strip()
if len(query) < 2:
return Response({'suggestions': []})
# Get suggestions from products
suggestions = Product.objects.filter(
Q(title__icontains=query) |
Q(brand__name__icontains=query) |
Q(category__name__icontains=query)
).values('title').distinct()[:10]
# Format response
return Response({
'query': query,
'suggestions': [
{
'text': item['title'],
'type': 'product'
}
for item in suggestions
]
})
@action(detail=False, methods=['get'])
def export(self, request):
"""Export search results to CSV."""
queryset = self.filter_queryset(self.get_queryset())
# Limit export size
queryset = queryset[:1000]
import csv
from django.http import HttpResponse
response = HttpResponse(content_type='text/csv')
response['Content-Disposition'] = 'attachment; filename="products.csv"'
writer = csv.writer(response)
writer.writerow(['ID', 'Title', 'Category', 'Brand', 'Price', 'In Stock'])
for product in queryset:
writer.writerow([
product.id,
product.title,
product.category.name,
product.brand.name,
product.price,
product.in_stock
])
return response
# Management Command for Search Index
from django.core.management.base import BaseCommand
class Command(BaseCommand):
"""Management command to rebuild search index."""
help = 'Rebuild PostgreSQL search index'
def handle(self, *args, **options):
from django.db import connection
with connection.cursor() as cursor:
# Create GIN index for full-text search
cursor.execute("""
CREATE INDEX IF NOT EXISTS products_search_vector_idx
ON products_product
USING GIN(
to_tsvector('english',
COALESCE(title, '') || ' ' ||
COALESCE(description, '')
)
)
""")
self.stdout.write(
self.style.SUCCESS('Successfully created search index')
)
# URLs
from django.urls import path, include
from rest_framework.routers import DefaultRouter
router = DefaultRouter()
router.register('products', ProductViewSet)
urlpatterns = [
path('api/', include(router.urls)),
]"""
FastAPI search endpoint implementation with validation and caching.
This example shows how to build a production-ready search API with FastAPI,
including request validation, response caching, and error handling.
"""
from fastapi import FastAPI, Query, HTTPException, Depends, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field, validator
from typing import Optional, List, Dict, Any
from datetime import datetime, timedelta
from enum import Enum
import asyncio
import hashlib
import json
import time
from functools import lru_cache
app = FastAPI(title="Product Search API", version="1.0.0")
# Enums and Models
class SortOrder(str, Enum):
"""Available sort orders."""
relevance = "relevance"
price_asc = "price_asc"
price_desc = "price_desc"
newest = "newest"
oldest = "oldest"
rating = "rating"
class SearchFilters(BaseModel):
"""Search filter model with validation."""
categories: Optional[List[str]] = Field(None, max_items=20, description="Product categories")
brands: Optional[List[str]] = Field(None, max_items=20, description="Product brands")
min_price: Optional[float] = Field(None, ge=0, le=1000000, description="Minimum price")
max_price: Optional[float] = Field(None, ge=0, le=1000000, description="Maximum price")
in_stock: Optional[bool] = Field(None, description="Only show in-stock items")
min_rating: Optional[float] = Field(None, ge=1, le=5, description="Minimum rating")
@validator('max_price')
def validate_price_range(cls, v, values):
"""Ensure max_price >= min_price."""
if v is not None and 'min_price' in values and values['min_price'] is not None:
if v < values['min_price']:
raise ValueError('max_price must be greater than or equal to min_price')
return v
class SearchRequest(BaseModel):
"""Search request model for POST endpoint."""
query: Optional[str] = Field(None, min_length=1, max_length=200, description="Search query")
filters: Optional[SearchFilters] = Field(None, description="Search filters")
sort_by: SortOrder = Field(SortOrder.relevance, description="Sort order")
page: int = Field(1, ge=1, le=100, description="Page number")
per_page: int = Field(20, ge=1, le=100, description="Results per page")
include_facets: bool = Field(True, description="Include facet counts")
class Product(BaseModel):
"""Product model."""
id: str
title: str
description: Optional[str]
category: str
brand: str
price: float
rating: Optional[float]
in_stock: bool
image_url: Optional[str]
created_at: datetime
class Facet(BaseModel):
"""Facet item model."""
value: str
count: int
label: Optional[str] = None
class SearchResponse(BaseModel):
"""Search response model."""
products: List[Product]
total: int
page: int
per_page: int
total_pages: int
facets: Optional[Dict[str, List[Facet]]] = None
query_time_ms: float
cached: bool = False
# Cache Implementation
class SearchCache:
"""Simple in-memory cache for search results."""
def __init__(self, ttl: int = 300, max_size: int = 100):
self.cache: Dict[str, tuple] = {}
self.ttl = ttl
self.max_size = max_size
def get_key(self, params: dict) -> str:
"""Generate cache key from search parameters."""
# Sort keys for consistent hashing
sorted_params = json.dumps(params, sort_keys=True, default=str)
return hashlib.md5(sorted_params.encode()).hexdigest()
def get(self, params: dict) -> Optional[dict]:
"""Get cached result if available and not expired."""
key = self.get_key(params)
if key in self.cache:
result, timestamp = self.cache[key]
if time.time() - timestamp < self.ttl:
return result
# Expired, remove from cache
del self.cache[key]
return None
def set(self, params: dict, result: dict):
"""Cache search result."""
# Check cache size limit
if len(self.cache) >= self.max_size:
# Remove oldest entry (simple FIFO)
oldest_key = next(iter(self.cache))
del self.cache[oldest_key]
key = self.get_key(params)
self.cache[key] = (result, time.time())
def clear(self):
"""Clear all cached results."""
self.cache.clear()
# Initialize cache
search_cache = SearchCache(ttl=300, max_size=100)
# Dependency Injection
async def get_search_service():
"""Dependency to get search service."""
# In production, this would return your actual search service
# For example, database session, Elasticsearch client, etc.
return MockSearchService()
# Mock Search Service (replace with actual implementation)
class MockSearchService:
"""Mock search service for demonstration."""
async def search(self, request: SearchRequest) -> Dict[str, Any]:
"""Perform mock search."""
# Simulate some processing time
await asyncio.sleep(0.1)
# Mock products
products = [
{
"id": f"prod_{i}",
"title": f"Product {i}",
"description": f"Description for product {i}",
"category": "Electronics",
"brand": "BrandX",
"price": 100.0 + i * 10,
"rating": 4.5,
"in_stock": True,
"image_url": f"https://example.com/product_{i}.jpg",
"created_at": datetime.utcnow()
}
for i in range(1, 21)
]
# Mock facets
facets = {
"categories": [
{"value": "Electronics", "count": 150},
{"value": "Computers", "count": 75},
{"value": "Accessories", "count": 50}
],
"brands": [
{"value": "BrandX", "count": 100},
{"value": "BrandY", "count": 80},
{"value": "BrandZ", "count": 45}
],
"price_ranges": [
{"value": "0-50", "label": "Under $50", "count": 30},
{"value": "50-100", "label": "$50-$100", "count": 45},
{"value": "100-200", "label": "$100-$200", "count": 60},
{"value": "200-inf", "label": "Over $200", "count": 40}
]
}
return {
"products": products,
"total": 175,
"facets": facets if request.include_facets else None
}
# API Endpoints
@app.get("/api/v1/search", response_model=SearchResponse, summary="Search products (GET)")
async def search_get(
q: Optional[str] = Query(None, min_length=1, max_length=200, description="Search query"),
category: Optional[List[str]] = Query(None, description="Filter by categories"),
brand: Optional[List[str]] = Query(None, description="Filter by brands"),
min_price: Optional[float] = Query(None, ge=0, description="Minimum price"),
max_price: Optional[float] = Query(None, ge=0, description="Maximum price"),
in_stock: Optional[bool] = Query(None, description="Only in-stock items"),
sort: SortOrder = Query(SortOrder.relevance, description="Sort order"),
page: int = Query(1, ge=1, le=100, description="Page number"),
per_page: int = Query(20, ge=1, le=100, description="Results per page"),
include_facets: bool = Query(True, description="Include facet counts"),
service: MockSearchService = Depends(get_search_service)
):
"""
Search products using query parameters.
This endpoint is suitable for simple searches that can be expressed in URL parameters.
"""
start_time = time.time()
# Build search request
search_request = SearchRequest(
query=q,
filters=SearchFilters(
categories=category,
brands=brand,
min_price=min_price,
max_price=max_price,
in_stock=in_stock
),
sort_by=sort,
page=page,
per_page=per_page,
include_facets=include_facets
)
# Check cache
cache_params = search_request.dict()
cached_result = search_cache.get(cache_params)
if cached_result:
query_time = (time.time() - start_time) * 1000
return SearchResponse(
**cached_result,
query_time_ms=query_time,
cached=True
)
# Perform search
try:
results = await service.search(search_request)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Search failed: {str(e)}")
# Calculate pagination
total_pages = (results['total'] + per_page - 1) // per_page
# Build response
response_data = {
"products": results['products'],
"total": results['total'],
"page": page,
"per_page": per_page,
"total_pages": total_pages,
"facets": results.get('facets')
}
# Cache result
search_cache.set(cache_params, response_data)
query_time = (time.time() - start_time) * 1000
return SearchResponse(
**response_data,
query_time_ms=query_time,
cached=False
)
@app.post("/api/v1/search", response_model=SearchResponse, summary="Search products (POST)")
async def search_post(
request: SearchRequest,
service: MockSearchService = Depends(get_search_service)
):
"""
Search products using request body.
This endpoint is suitable for complex searches with many filters or when the query
might exceed URL length limits.
"""
start_time = time.time()
# Check cache
cache_params = request.dict()
cached_result = search_cache.get(cache_params)
if cached_result:
query_time = (time.time() - start_time) * 1000
return SearchResponse(
**cached_result,
query_time_ms=query_time,
cached=True
)
# Perform search
try:
results = await service.search(request)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Search failed: {str(e)}")
# Calculate pagination
total_pages = (results['total'] + request.per_page - 1) // request.per_page
# Build response
response_data = {
"products": results['products'],
"total": results['total'],
"page": request.page,
"per_page": request.per_page,
"total_pages": total_pages,
"facets": results.get('facets')
}
# Cache result
search_cache.set(cache_params, response_data)
query_time = (time.time() - start_time) * 1000
return SearchResponse(
**response_data,
query_time_ms=query_time,
cached=False
)
@app.get("/api/v1/autocomplete", summary="Get search suggestions")
async def autocomplete(
q: str = Query(..., min_length=2, max_length=50, description="Query prefix"),
limit: int = Query(10, ge=1, le=20, description="Number of suggestions"),
service: MockSearchService = Depends(get_search_service)
):
"""
Get autocomplete suggestions for search input.
Returns suggestions based on the provided query prefix.
"""
# Mock autocomplete suggestions
suggestions = [
{
"text": f"{q} suggestion {i}",
"category": "Electronics" if i % 2 == 0 else "Computers",
"type": "product" if i < 5 else "category"
}
for i in range(1, min(limit + 1, 11))
]
return {
"query": q,
"suggestions": suggestions
}
@app.delete("/api/v1/cache", summary="Clear search cache")
async def clear_cache():
"""
Clear the search result cache.
This endpoint should be protected in production.
"""
search_cache.clear()
return {"message": "Cache cleared successfully"}
@app.get("/api/v1/health", summary="Health check")
async def health_check():
"""
Check if the search service is healthy.
"""
return {
"status": "healthy",
"timestamp": datetime.utcnow(),
"cache_size": len(search_cache.cache),
"version": "1.0.0"
}
# Error Handlers
@app.exception_handler(ValueError)
async def value_error_handler(request: Request, exc: ValueError):
"""Handle validation errors."""
return JSONResponse(
status_code=400,
content={
"error": "Invalid input",
"message": str(exc),
"timestamp": datetime.utcnow().isoformat()
}
)
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
"""Handle HTTP exceptions."""
return JSONResponse(
status_code=exc.status_code,
content={
"error": "Request failed",
"message": exc.detail,
"timestamp": datetime.utcnow().isoformat()
}
)
# Middleware for logging
@app.middleware("http")
async def log_requests(request: Request, call_next):
"""Log all search requests."""
start_time = time.time()
# Process request
response = await call_next(request)
# Log request details
process_time = time.time() - start_time
# In production, use proper logging
if request.url.path.startswith("/api/v1/search"):
print(f"Search request: {request.url.path}")
print(f"Query params: {request.url.query}")
print(f"Process time: {process_time:.3f}s")
return response
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)/**
* Complete e-commerce product search implementation with filters
*
* Features:
* - Search input with debouncing
* - Multiple filter types (category, price, brand)
* - URL state management
* - Faceted search with counts
* - Responsive design
*/
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useDebounce } from '../hooks/useDebounce';
import { Search, X, Filter, ChevronDown } from 'lucide-react';
// Types
interface Product {
id: string;
title: string;
description: string;
price: number;
category: string;
brand: string;
rating: number;
imageUrl: string;
inStock: boolean;
}
interface SearchFilters {
query?: string;
categories?: string[];
brands?: string[];
minPrice?: number;
maxPrice?: number;
inStock?: boolean;
sortBy?: string;
}
interface Facet {
value: string;
count: number;
}
interface SearchResults {
products: Product[];
facets: {
categories: Facet[];
brands: Facet[];
priceRanges: Facet[];
};
total: number;
page: number;
totalPages: number;
}
// Main Component
export function ProductSearch() {
const [searchParams, setSearchParams] = useSearchParams();
const [results, setResults] = useState<SearchResults | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isMobileFilterOpen, setIsMobileFilterOpen] = useState(false);
// Parse filters from URL
const filters = useMemo<SearchFilters>(() => {
return {
query: searchParams.get('q') || undefined,
categories: searchParams.getAll('category'),
brands: searchParams.getAll('brand'),
minPrice: searchParams.get('min_price')
? parseFloat(searchParams.get('min_price')!)
: undefined,
maxPrice: searchParams.get('max_price')
? parseFloat(searchParams.get('max_price')!)
: undefined,
inStock: searchParams.get('in_stock') === 'true',
sortBy: searchParams.get('sort') || 'relevance'
};
}, [searchParams]);
// Update URL with new filters
const updateFilters = useCallback((newFilters: Partial<SearchFilters>) => {
const params = new URLSearchParams();
// Merge with existing filters
const merged = { ...filters, ...newFilters };
// Build URL params
if (merged.query) params.set('q', merged.query);
merged.categories?.forEach(cat => params.append('category', cat));
merged.brands?.forEach(brand => params.append('brand', brand));
if (merged.minPrice) params.set('min_price', merged.minPrice.toString());
if (merged.maxPrice) params.set('max_price', merged.maxPrice.toString());
if (merged.inStock) params.set('in_stock', 'true');
if (merged.sortBy && merged.sortBy !== 'relevance') {
params.set('sort', merged.sortBy);
}
setSearchParams(params);
}, [filters, setSearchParams]);
// Perform search
const performSearch = useCallback(async (searchFilters: SearchFilters) => {
setIsLoading(true);
try {
const response = await fetch('/api/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(searchFilters)
});
if (!response.ok) throw new Error('Search failed');
const data = await response.json();
setResults(data);
} catch (error) {
console.error('Search error:', error);
// Handle error - show toast, etc.
} finally {
setIsLoading(false);
}
}, []);
// Search when filters change
useEffect(() => {
performSearch(filters);
}, [filters, performSearch]);
return (
<div className="product-search">
{/* Search Header */}
<SearchHeader
query={filters.query}
onSearch={(query) => updateFilters({ query })}
resultCount={results?.total}
isLoading={isLoading}
/>
<div className="search-body">
{/* Mobile Filter Toggle */}
<button
className="mobile-filter-toggle"
onClick={() => setIsMobileFilterOpen(true)}
>
<Filter size={20} />
Filters
{getActiveFilterCount(filters) > 0 && (
<span className="filter-badge">{getActiveFilterCount(filters)}</span>
)}
</button>
{/* Desktop Filters Sidebar */}
<aside className="filters-sidebar desktop-only">
<FilterPanel
filters={filters}
facets={results?.facets}
onFilterChange={updateFilters}
onClearAll={() => setSearchParams(new URLSearchParams())}
/>
</aside>
{/* Results Area */}
<main className="results-area">
{/* Active Filters */}
<ActiveFilters
filters={filters}
onRemove={(key, value) => {
const newFilters = { ...filters };
if (key === 'query') {
delete newFilters.query;
} else if (Array.isArray(newFilters[key])) {
newFilters[key] = newFilters[key].filter(v => v !== value);
} else {
delete newFilters[key];
}
updateFilters(newFilters);
}}
onClearAll={() => setSearchParams(new URLSearchParams())}
/>
{/* Sort Bar */}
<SortBar
value={filters.sortBy || 'relevance'}
onChange={(sortBy) => updateFilters({ sortBy })}
resultCount={results?.total}
/>
{/* Product Grid */}
{isLoading ? (
<LoadingGrid />
) : results && results.products.length > 0 ? (
<ProductGrid products={results.products} />
) : (
<NoResults query={filters.query} />
)}
{/* Pagination */}
{results && results.totalPages > 1 && (
<Pagination
currentPage={results.page}
totalPages={results.totalPages}
onPageChange={(page) => updateFilters({ page })}
/>
)}
</main>
</div>
{/* Mobile Filter Drawer */}
{isMobileFilterOpen && (
<MobileFilterDrawer
filters={filters}
facets={results?.facets}
onFilterChange={updateFilters}
onClose={() => setIsMobileFilterOpen(false)}
/>
)}
</div>
);
}
// Search Header Component
function SearchHeader({ query, onSearch, resultCount, isLoading }) {
const [localQuery, setLocalQuery] = useState(query || '');
const debouncedQuery = useDebounce(localQuery, 300);
useEffect(() => {
if (debouncedQuery !== query) {
onSearch(debouncedQuery);
}
}, [debouncedQuery, query, onSearch]);
return (
<header className="search-header">
<div className="search-input-container">
<Search className="search-icon" size={20} />
<input
type="search"
value={localQuery}
onChange={(e) => setLocalQuery(e.target.value)}
placeholder="Search products..."
className="search-input"
aria-label="Search products"
/>
{localQuery && (
<button
onClick={() => {
setLocalQuery('');
onSearch('');
}}
className="clear-button"
aria-label="Clear search"
>
<X size={16} />
</button>
)}
</div>
{resultCount !== undefined && (
<div className="result-count">
{isLoading ? (
<span className="loading">Searching...</span>
) : (
<span>{resultCount.toLocaleString()} results</span>
)}
</div>
)}
</header>
);
}
// Filter Panel Component
function FilterPanel({ filters, facets, onFilterChange, onClearAll }) {
const hasActiveFilters = getActiveFilterCount(filters) > 0;
return (
<div className="filter-panel">
<div className="filter-header">
<h2>Filters</h2>
{hasActiveFilters && (
<button onClick={onClearAll} className="clear-all">
Clear all
</button>
)}
</div>
{/* Category Filter */}
<FilterSection title="Category">
{facets?.categories.map(facet => (
<CheckboxFilter
key={facet.value}
label={facet.value}
count={facet.count}
checked={filters.categories?.includes(facet.value)}
onChange={(checked) => {
const categories = filters.categories || [];
onFilterChange({
categories: checked
? [...categories, facet.value]
: categories.filter(c => c !== facet.value)
});
}}
/>
))}
</FilterSection>
{/* Price Range */}
<FilterSection title="Price">
<PriceRangeFilter
min={filters.minPrice}
max={filters.maxPrice}
onChange={(min, max) => {
onFilterChange({ minPrice: min, maxPrice: max });
}}
/>
</FilterSection>
{/* Brand Filter */}
<FilterSection title="Brand">
{facets?.brands.map(facet => (
<CheckboxFilter
key={facet.value}
label={facet.value}
count={facet.count}
checked={filters.brands?.includes(facet.value)}
onChange={(checked) => {
const brands = filters.brands || [];
onFilterChange({
brands: checked
? [...brands, facet.value]
: brands.filter(b => b !== facet.value)
});
}}
/>
))}
</FilterSection>
{/* Stock Filter */}
<FilterSection title="Availability">
<CheckboxFilter
label="In Stock Only"
checked={filters.inStock}
onChange={(checked) => onFilterChange({ inStock: checked })}
/>
</FilterSection>
</div>
);
}
// Helper Components
function FilterSection({ title, children }) {
const [isOpen, setIsOpen] = useState(true);
return (
<div className="filter-section">
<button
className="filter-section-header"
onClick={() => setIsOpen(!isOpen)}
>
<span>{title}</span>
<ChevronDown
size={16}
className={`chevron ${isOpen ? 'open' : ''}`}
/>
</button>
{isOpen && (
<div className="filter-section-content">
{children}
</div>
)}
</div>
);
}
function CheckboxFilter({ label, count, checked, onChange }) {
return (
<label className="checkbox-filter">
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
/>
<span className="label">{label}</span>
{count !== undefined && (
<span className="count">({count})</span>
)}
</label>
);
}
function PriceRangeFilter({ min, max, onChange }) {
const [localMin, setLocalMin] = useState(min || '');
const [localMax, setLocalMax] = useState(max || '');
const handleApply = () => {
onChange(
localMin ? parseFloat(localMin) : undefined,
localMax ? parseFloat(localMax) : undefined
);
};
return (
<div className="price-range-filter">
<div className="price-inputs">
<input
type="number"
placeholder="Min"
value={localMin}
onChange={(e) => setLocalMin(e.target.value)}
onBlur={handleApply}
/>
<span>to</span>
<input
type="number"
placeholder="Max"
value={localMax}
onChange={(e) => setLocalMax(e.target.value)}
onBlur={handleApply}
/>
</div>
</div>
);
}
// Utility Functions
function getActiveFilterCount(filters: SearchFilters): number {
let count = 0;
if (filters.query) count++;
if (filters.categories?.length) count += filters.categories.length;
if (filters.brands?.length) count += filters.brands.length;
if (filters.minPrice || filters.maxPrice) count++;
if (filters.inStock) count++;
return count;
}
// Additional components would include:
// - ActiveFilters
// - SortBar
// - ProductGrid
// - LoadingGrid
// - NoResults
// - Pagination
// - MobileFilterDrawer
// These follow similar patterns and would be implemented based on specific UI requirements"""
SQLAlchemy search implementation with dynamic filtering and pagination.
This example demonstrates building complex search queries with SQLAlchemy,
including full-text search, faceted filtering, and performance optimization.
"""
from sqlalchemy import create_engine, Column, Integer, String, Float, Boolean, DateTime, Text, Index, func, and_, or_
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker, Query
from sqlalchemy.dialects.postgresql import TSVECTOR
from typing import Dict, List, Any, Optional, Tuple
from datetime import datetime
Base = declarative_base()
class Product(Base):
"""Product model with search-optimized fields."""
__tablename__ = 'products'
id = Column(Integer, primary_key=True)
title = Column(String(200), nullable=False)
description = Column(Text)
category = Column(String(50), index=True)
brand = Column(String(50), index=True)
price = Column(Float, index=True)
rating = Column(Float)
in_stock = Column(Boolean, default=True, index=True)
created_at = Column(DateTime, default=datetime.utcnow, index=True)
tags = Column(Text) # Comma-separated tags
# PostgreSQL full-text search vector (optional)
search_vector = Column(TSVECTOR)
# Composite indexes for common filter combinations
__table_args__ = (
Index('idx_category_brand', 'category', 'brand'),
Index('idx_price_category', 'price', 'category'),
Index('idx_created_desc', created_at.desc()),
)
class ProductSearcher:
"""Advanced product search with SQLAlchemy."""
def __init__(self, session):
self.session = session
def search(
self,
query: Optional[str] = None,
filters: Optional[Dict[str, Any]] = None,
sort_by: str = 'relevance',
page: int = 1,
per_page: int = 20,
include_facets: bool = True
) -> Dict[str, Any]:
"""
Perform product search with filters and facets.
Args:
query: Search query text
filters: Dictionary of filters to apply
sort_by: Sort order (relevance, price_asc, price_desc, newest, rating)
page: Page number (1-based)
per_page: Results per page
include_facets: Whether to include facet counts
Returns:
Dictionary with results, facets, and metadata
"""
filters = filters or {}
# Build base query
base_query = self.session.query(Product)
# Apply text search
if query:
base_query = self._add_text_search(base_query, query)
# Apply filters
base_query = self._apply_filters(base_query, filters)
# Get total count before pagination
total_count = base_query.count()
# Apply sorting
sorted_query = self._apply_sorting(base_query, sort_by, bool(query))
# Apply pagination
paginated_query = self._apply_pagination(sorted_query, page, per_page)
# Execute query
results = paginated_query.all()
# Get facets if requested
facets = {}
if include_facets:
facets = self._get_facets(base_query, filters)
return {
'results': [self._serialize_product(p) for p in results],
'total': total_count,
'page': page,
'per_page': per_page,
'total_pages': (total_count + per_page - 1) // per_page,
'facets': facets
}
def _add_text_search(self, query: Query, search_term: str) -> Query:
"""Add full-text search to query."""
# Check if using PostgreSQL
if self.session.bind.dialect.name == 'postgresql':
# Use PostgreSQL full-text search
search_query = func.plainto_tsquery('english', search_term)
# Create search vector from multiple fields
search_vector = func.to_tsvector(
'english',
func.coalesce(Product.title, '') + ' ' +
func.coalesce(Product.description, '') + ' ' +
func.coalesce(Product.tags, '')
)
# Add search condition and ranking
query = query.filter(search_vector.match(search_query))
# Add relevance score for sorting
query = query.add_columns(
func.ts_rank(search_vector, search_query).label('relevance')
)
else:
# Fallback to LIKE for other databases
search_pattern = f'%{search_term}%'
query = query.filter(
or_(
Product.title.ilike(search_pattern),
Product.description.ilike(search_pattern),
Product.tags.ilike(search_pattern)
)
)
return query
def _apply_filters(self, query: Query, filters: Dict[str, Any]) -> Query:
"""Apply filters to query."""
# Category filter
if 'categories' in filters and filters['categories']:
query = query.filter(Product.category.in_(filters['categories']))
# Brand filter
if 'brands' in filters and filters['brands']:
query = query.filter(Product.brand.in_(filters['brands']))
# Price range filter
if 'min_price' in filters:
query = query.filter(Product.price >= filters['min_price'])
if 'max_price' in filters:
query = query.filter(Product.price <= filters['max_price'])
# Stock filter
if filters.get('in_stock'):
query = query.filter(Product.in_stock == True)
# Rating filter
if 'min_rating' in filters:
query = query.filter(Product.rating >= filters['min_rating'])
# Date range filter
if 'date_from' in filters:
query = query.filter(Product.created_at >= filters['date_from'])
if 'date_to' in filters:
query = query.filter(Product.created_at <= filters['date_to'])
return query
def _apply_sorting(self, query: Query, sort_by: str, has_search: bool) -> Query:
"""Apply sorting to query."""
sort_options = {
'price_asc': Product.price.asc(),
'price_desc': Product.price.desc(),
'newest': Product.created_at.desc(),
'oldest': Product.created_at.asc(),
'rating': Product.rating.desc(),
}
if sort_by == 'relevance' and has_search:
# Sort by relevance if text search was performed
if self.session.bind.dialect.name == 'postgresql':
query = query.order_by(text('relevance DESC'))
else:
# Fallback to newest for non-PostgreSQL
query = query.order_by(Product.created_at.desc())
elif sort_by in sort_options:
query = query.order_by(sort_options[sort_by])
else:
# Default sort
query = query.order_by(Product.created_at.desc())
return query
def _apply_pagination(self, query: Query, page: int, per_page: int) -> Query:
"""Apply pagination to query."""
offset = (page - 1) * per_page
return query.offset(offset).limit(per_page)
def _get_facets(self, base_query: Query, active_filters: Dict[str, Any]) -> Dict[str, List[Dict]]:
"""Get facet counts for filters."""
facets = {}
# Category facets
category_query = self._get_base_facet_query(base_query, active_filters, 'categories')
category_facets = category_query.with_entities(
Product.category,
func.count(Product.id).label('count')
).group_by(Product.category).all()
facets['categories'] = [
{'value': cat, 'count': count}
for cat, count in category_facets if cat
]
# Brand facets
brand_query = self._get_base_facet_query(base_query, active_filters, 'brands')
brand_facets = brand_query.with_entities(
Product.brand,
func.count(Product.id).label('count')
).group_by(Product.brand).all()
facets['brands'] = [
{'value': brand, 'count': count}
for brand, count in brand_facets if brand
]
# Price range facets
facets['price_ranges'] = self._get_price_range_facets(base_query, active_filters)
# In stock count
in_stock_query = self._get_base_facet_query(base_query, active_filters, 'in_stock')
in_stock_count = in_stock_query.filter(Product.in_stock == True).count()
facets['availability'] = [
{'value': 'in_stock', 'count': in_stock_count}
]
return facets
def _get_base_facet_query(
self,
base_query: Query,
active_filters: Dict[str, Any],
exclude_filter: str
) -> Query:
"""
Get base query for facet counting, excluding the current filter.
This ensures facet counts show what would be available if that filter was removed.
"""
# Clone the base query
facet_query = base_query
# Apply all filters except the one we're counting
filters_to_apply = {k: v for k, v in active_filters.items() if k != exclude_filter}
return self._apply_filters(self.session.query(Product), filters_to_apply)
def _get_price_range_facets(self, base_query: Query, active_filters: Dict[str, Any]) -> List[Dict]:
"""Calculate price range facets."""
# Define price ranges
ranges = [
(0, 50, 'Under $50'),
(50, 100, '$50 - $100'),
(100, 200, '$100 - $200'),
(200, 500, '$200 - $500'),
(500, None, 'Over $500')
]
# Get base query without price filters
query_without_price = self._get_base_facet_query(
base_query,
active_filters,
'price'
)
facets = []
for min_price, max_price, label in ranges:
range_query = query_without_price
range_query = range_query.filter(Product.price >= min_price)
if max_price:
range_query = range_query.filter(Product.price < max_price)
count = range_query.count()
if count > 0:
facets.append({
'value': f'{min_price}-{max_price or "inf"}',
'label': label,
'count': count
})
return facets
def _serialize_product(self, product: Product) -> Dict:
"""Serialize product for API response."""
return {
'id': product.id,
'title': product.title,
'description': product.description,
'category': product.category,
'brand': product.brand,
'price': product.price,
'rating': product.rating,
'in_stock': product.in_stock,
'created_at': product.created_at.isoformat() if product.created_at else None,
'tags': product.tags.split(',') if product.tags else []
}
class SearchOptimizer:
"""Query optimization utilities."""
@staticmethod
def explain_query(session, query: Query) -> str:
"""Get query execution plan (PostgreSQL)."""
if session.bind.dialect.name != 'postgresql':
return "EXPLAIN only available for PostgreSQL"
sql = str(query.statement.compile(compile_kwargs={"literal_binds": True}))
result = session.execute(f"EXPLAIN ANALYZE {sql}")
return '\n'.join([row[0] for row in result])
@staticmethod
def add_search_indexes(engine):
"""Create optimized indexes for search."""
with engine.connect() as conn:
# Full-text search index (PostgreSQL)
if engine.dialect.name == 'postgresql':
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_product_search_vector
ON products
USING GIN(to_tsvector('english',
COALESCE(title, '') || ' ' ||
COALESCE(description, '') || ' ' ||
COALESCE(tags, '')
))
""")
# Standard indexes for filtering
conn.execute("CREATE INDEX IF NOT EXISTS idx_products_category ON products(category)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_products_brand ON products(brand)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_products_price ON products(price)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_products_in_stock ON products(in_stock)")
conn.commit()
# Usage Example
if __name__ == '__main__':
# Setup database
engine = create_engine('postgresql://user:pass@localhost/shop')
Session = sessionmaker(bind=engine)
session = Session()
# Create tables and indexes
Base.metadata.create_all(engine)
SearchOptimizer.add_search_indexes(engine)
# Initialize searcher
searcher = ProductSearcher(session)
# Perform search
results = searcher.search(
query='laptop',
filters={
'categories': ['Electronics', 'Computers'],
'min_price': 500,
'max_price': 2000,
'in_stock': True
},
sort_by='price_asc',
page=1,
per_page=20,
include_facets=True
)
print(f"Found {results['total']} products")
print(f"Page {results['page']} of {results['total_pages']}")
print(f"Facets: {results['facets']}")skill: "implementing-search-filter"
version: "1.0"
domain: "frontend"
base_outputs:
- path: "src/components/Search*.{tsx,jsx,ts,js}"
must_contain:
- "search"
- "debounce"
- "onChange"
description: "Search input component with debouncing and clear functionality"
- path: "src/components/*Filter*.{tsx,jsx,ts,js}"
must_contain:
- "filter"
- "onChange"
description: "Filter components (checkbox, range, dropdown, etc.)"
- path: "src/hooks/useDebounce.{ts,tsx,js}"
must_contain:
- "useEffect"
- "setTimeout"
- "debounce"
description: "Custom debounce hook for search input optimization"
- path: "src/hooks/useSearch*.{ts,tsx,js}"
must_contain:
- "search"
- "filter"
- "useState"
description: "Search and filter state management hook"
conditional_outputs:
maturity:
starter:
- path: "src/components/SimpleSearch.{tsx,jsx}"
must_contain:
- "input"
- "search"
- "onChange"
description: "Basic search input with minimal features"
- path: "src/components/BasicFilters.{tsx,jsx}"
must_contain:
- "checkbox"
- "filter"
description: "Simple checkbox or dropdown filters"
intermediate:
- path: "src/components/SearchBar.{tsx,jsx}"
must_contain:
- "debounce"
- "loading"
- "clear"
description: "Search bar with debouncing, loading states, and clear button"
- path: "src/components/FilterPanel.{tsx,jsx}"
must_contain:
- "filter"
- "facet"
- "count"
description: "Filter panel with facet counts and multiple filter types"
- path: "src/components/ActiveFilters.{tsx,jsx}"
must_contain:
- "chip"
- "badge"
- "remove"
description: "Active filter chips/badges with remove functionality"
advanced:
- path: "src/components/AdvancedSearch.{tsx,jsx}"
must_contain:
- "autocomplete"
- "suggestion"
- "downshift"
description: "Advanced search with autocomplete/typeahead suggestions"
- path: "src/components/FacetedSearch.{tsx,jsx}"
must_contain:
- "facet"
- "aggregation"
- "count"
description: "Faceted search with dynamic counts and aggregations"
- path: "src/components/SearchResults.{tsx,jsx}"
must_contain:
- "result"
- "highlight"
- "pagination"
description: "Search results with highlighting and pagination"
- path: "src/utils/searchParams.{ts,js}"
must_contain:
- "URLSearchParams"
- "serialize"
- "deserialize"
description: "URL parameter management for shareable search state"
frontend_framework:
react:
- path: "src/components/*Search*.{tsx,jsx}"
must_contain:
- "useState"
- "useEffect"
description: "React-based search components"
- path: "src/hooks/useSearch.{ts,tsx}"
must_contain:
- "useState"
- "useCallback"
- "useMemo"
description: "React hooks for search state management"
vue:
- path: "src/components/*Search*.vue"
must_contain:
- "ref"
- "computed"
- "watch"
description: "Vue-based search components"
- path: "src/composables/useSearch.{ts,js}"
must_contain:
- "ref"
- "computed"
description: "Vue composables for search functionality"
angular:
- path: "src/app/components/*-search/*.component.ts"
must_contain:
- "Component"
- "OnInit"
- "FormControl"
description: "Angular search components"
- path: "src/app/services/search.service.ts"
must_contain:
- "Injectable"
- "Observable"
- "HttpClient"
description: "Angular search service"
state_management:
redux:
- path: "src/store/search/searchSlice.{ts,js}"
must_contain:
- "createSlice"
- "reducer"
- "actions"
description: "Redux slice for search state"
- path: "src/store/search/searchThunks.{ts,js}"
must_contain:
- "createAsyncThunk"
- "async"
description: "Redux thunks for async search operations"
zustand:
- path: "src/stores/searchStore.{ts,js}"
must_contain:
- "create"
- "set"
- "get"
description: "Zustand store for search state"
context:
- path: "src/contexts/SearchContext.{tsx,jsx}"
must_contain:
- "createContext"
- "Provider"
- "useContext"
description: "React Context for search state management"
styling:
tailwind:
- path: "src/components/*Search*.{tsx,jsx}"
must_contain:
- "className"
- "flex"
description: "Components styled with Tailwind CSS utility classes"
css_modules:
- path: "src/components/*Search*.module.css"
must_contain:
- ".search"
- ".filter"
description: "CSS modules for search component styling"
styled_components:
- path: "src/components/*Search*.styled.{ts,tsx}"
must_contain:
- "styled"
- "css"
description: "Styled-components for search UI"
backend_integration:
rest_api:
- path: "src/api/search.{ts,js}"
must_contain:
- "fetch"
- "api/search"
- "params"
description: "REST API client for search endpoints"
- path: "backend/api/search.{py,js,ts}"
must_contain:
- "search"
- "filter"
- "query"
description: "Backend search API endpoint"
graphql:
- path: "src/graphql/queries/search.{ts,js}"
must_contain:
- "query"
- "search"
- "filter"
description: "GraphQL queries for search"
elasticsearch:
- path: "backend/search/elasticsearch.{py,js}"
must_contain:
- "elasticsearch"
- "query"
- "index"
description: "Elasticsearch integration for full-text search"
database:
sqlalchemy:
- path: "backend/queries/search_queries.py"
must_contain:
- "select"
- "filter"
- "query"
description: "SQLAlchemy search query builders"
django_orm:
- path: "backend/views/search_views.py"
must_contain:
- "filter"
- "Q"
- "queryset"
description: "Django ORM search views with filters"
prisma:
- path: "backend/services/search.{ts,js}"
must_contain:
- "prisma"
- "findMany"
- "where"
description: "Prisma-based search service"
scaffolding:
- path: "src/types/search.{ts,d.ts}"
reason: "TypeScript type definitions for search interfaces, filters, and API responses"
- path: "src/config/searchConfig.{ts,js,json}"
reason: "Search configuration including debounce timing, pagination defaults, API endpoints"
- path: "src/utils/queryBuilder.{ts,js}"
reason: "Query string builder for converting filters to URL parameters"
- path: "src/utils/filterHelpers.{ts,js}"
reason: "Helper functions for filter validation, transformation, and sanitization"
- path: "tests/search.test.{ts,tsx,js,jsx}"
reason: "Test suite for search functionality, debouncing, and filter logic"
- path: "tests/filters.test.{ts,tsx,js,jsx}"
reason: "Test suite for filter components and state management"
metadata:
primary_blueprints:
- "dashboard"
- "crud-api"
- "frontend"
- "data-pipeline"
secondary_blueprints:
- "api-first"
- "observability"
contributes_to:
- "Search input components with debouncing"
- "Autocomplete/typeahead interfaces"
- "Filter panels (checkbox, range, dropdown)"
- "Faceted search with dynamic counts"
- "Active filter chips/badges"
- "URL-based filter state management"
- "Backend search APIs (REST/GraphQL)"
- "Database query optimization"
- "Elasticsearch integration"
- "Search result highlighting"
- "Pagination and sorting"
- "Mobile-responsive filter drawers"
- "Accessible search experiences (ARIA, keyboard navigation)"
common_patterns:
- "Debounced search input (300ms default)"
- "Search state in URL parameters"
- "Client-side filtering for <1000 items"
- "Server-side search for >1000 items"
- "Hybrid approach with optimistic updates"
- "Request cancellation for pending searches"
- "Loading states and skeleton loaders"
- "Empty state handling"
- "Error handling with retry logic"
- "Result caching (300s TTL default)"
key_libraries:
frontend:
- name: "downshift"
purpose: "Accessible autocomplete primitives"
install: "npm install downshift"
- name: "react-select"
purpose: "Full-featured select/filter component"
install: "npm install react-select"
- name: "lodash.debounce"
purpose: "Debounce utility"
install: "npm install lodash.debounce"
backend_python:
- name: "elasticsearch"
purpose: "Elasticsearch client"
install: "pip install elasticsearch"
- name: "django-filter"
purpose: "Django REST Framework filters"
install: "pip install django-filter"
- name: "sqlalchemy"
purpose: "SQL query builder"
install: "pip install sqlalchemy"
backend_nodejs:
- name: "@elastic/elasticsearch"
purpose: "Elasticsearch client for Node.js"
install: "npm install @elastic/elasticsearch"
- name: "express-validator"
purpose: "Request validation middleware"
install: "npm install express-validator"
accessibility_requirements:
- "role=\"search\" for search regions"
- "aria-live regions for result updates"
- "aria-label on filter controls"
- "Keyboard navigation (Tab, Arrow keys, Enter, Escape)"
- "Focus management in autocomplete"
- "Screen reader announcements for filter changes"
performance_considerations:
- "Debounce search input (300ms recommended)"
- "Cancel pending requests on new input"
- "Index optimization for search columns"
- "Query result caching (5-minute TTL)"
- "Pagination for large result sets"
- "Virtual scrolling for >1000 items"
- "Lazy loading of images"
- "Compression for complex URL state"
example_use_cases:
- "E-commerce product search with category/price filters"
- "Data table search and column filtering"
- "Document search with full-text indexing"
- "User directory with multi-criteria filters"
- "Job board with location/salary/skills filters"
- "Real estate search with map integration"
- "Content management system search"
- "Log viewer with advanced query syntax"
Filter UI Patterns
Table of Contents
- Checkbox Filters
- Basic Multi-Select Filter
- Collapsible Filter Groups
- Range Filters
- Price Range Slider
- Date Range Picker
- Dropdown Filters
- Single Select Dropdown
- Searchable Dropdown with Downshift
- Filter Chips
- Active Filter Display
- Faceted Search
- Dynamic Count Updates
- Mobile Filter Patterns
- Filter Drawer
- Sort Options
- Sort Dropdown
- Filter State Management
- Using URL Parameters
- Accessibility Considerations
- Filter Region ARIA
- Keyboard Navigation
Checkbox Filters
Basic Multi-Select Filter
interface FilterOption {
id: string;
label: string;
count?: number;
}
interface CheckboxFilterProps {
title: string;
options: FilterOption[];
selected: string[];
onChange: (selected: string[]) => void;
}
export function CheckboxFilter({
title,
options,
selected,
onChange
}: CheckboxFilterProps) {
const handleToggle = (optionId: string) => {
if (selected.includes(optionId)) {
onChange(selected.filter(id => id !== optionId));
} else {
onChange([...selected, optionId]);
}
};
const handleSelectAll = () => {
if (selected.length === options.length) {
onChange([]);
} else {
onChange(options.map(opt => opt.id));
}
};
return (
<div className="filter-group">
<h3>{title}</h3>
<button
onClick={handleSelectAll}
className="select-all-btn"
>
{selected.length === options.length ? 'Clear all' : 'Select all'}
</button>
{options.map(option => (
<label key={option.id} className="checkbox-label">
<input
type="checkbox"
checked={selected.includes(option.id)}
onChange={() => handleToggle(option.id)}
aria-label={`Filter by ${option.label}`}
/>
<span>{option.label}</span>
{option.count !== undefined && (
<span className="count">({option.count})</span>
)}
</label>
))}
</div>
);
}Collapsible Filter Groups
import { ChevronDown, ChevronUp } from 'lucide-react';
function CollapsibleFilter({ title, children, defaultOpen = true }) {
const [isOpen, setIsOpen] = useState(defaultOpen);
return (
<div className="filter-section">
<button
className="filter-header"
onClick={() => setIsOpen(!isOpen)}
aria-expanded={isOpen}
aria-controls={`filter-${title}`}
>
<span>{title}</span>
{isOpen ? <ChevronUp /> : <ChevronDown />}
</button>
{isOpen && (
<div id={`filter-${title}`} className="filter-content">
{children}
</div>
)}
</div>
);
}Range Filters
Price Range Slider
interface RangeFilterProps {
min: number;
max: number;
value: [number, number];
onChange: (value: [number, number]) => void;
step?: number;
prefix?: string;
}
export function RangeFilter({
min,
max,
value,
onChange,
step = 1,
prefix = '$'
}: RangeFilterProps) {
const [localValue, setLocalValue] = useState(value);
useEffect(() => {
const timeoutId = setTimeout(() => {
onChange(localValue);
}, 500); // Debounce
return () => clearTimeout(timeoutId);
}, [localValue]);
return (
<div className="range-filter">
<div className="range-inputs">
<input
type="number"
value={localValue[0]}
onChange={(e) => setLocalValue([+e.target.value, localValue[1]])}
min={min}
max={localValue[1]}
aria-label="Minimum price"
/>
<span>to</span>
<input
type="number"
value={localValue[1]}
onChange={(e) => setLocalValue([localValue[0], +e.target.value])}
min={localValue[0]}
max={max}
aria-label="Maximum price"
/>
</div>
<div className="range-slider">
<input
type="range"
min={min}
max={max}
value={localValue[0]}
onChange={(e) => setLocalValue([+e.target.value, localValue[1]])}
step={step}
/>
<input
type="range"
min={min}
max={max}
value={localValue[1]}
onChange={(e) => setLocalValue([localValue[0], +e.target.value])}
step={step}
/>
</div>
<div className="range-labels">
<span>{prefix}{min}</span>
<span>{prefix}{max}</span>
</div>
</div>
);
}Date Range Picker
import { Calendar } from 'lucide-react';
function DateRangeFilter({ value, onChange }) {
const [startDate, endDate] = value;
return (
<div className="date-range-filter">
<div className="date-input">
<Calendar size={16} />
<input
type="date"
value={startDate}
onChange={(e) => onChange([e.target.value, endDate])}
aria-label="Start date"
/>
</div>
<span className="separator">to</span>
<div className="date-input">
<Calendar size={16} />
<input
type="date"
value={endDate}
onChange={(e) => onChange([startDate, e.target.value])}
min={startDate}
aria-label="End date"
/>
</div>
</div>
);
}Dropdown Filters
Single Select Dropdown
interface DropdownFilterProps {
label: string;
options: { value: string; label: string }[];
value: string;
onChange: (value: string) => void;
placeholder?: string;
}
export function DropdownFilter({
label,
options,
value,
onChange,
placeholder = 'Select...'
}: DropdownFilterProps) {
return (
<div className="dropdown-filter">
<label htmlFor={`filter-${label}`}>{label}</label>
<select
id={`filter-${label}`}
value={value}
onChange={(e) => onChange(e.target.value)}
>
<option value="">{placeholder}</option>
{options.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
);
}Searchable Dropdown with Downshift
import { useCombobox } from 'downshift';
function SearchableDropdown({ items, onSelect, placeholder }) {
const [inputItems, setInputItems] = useState(items);
const {
isOpen,
getToggleButtonProps,
getLabelProps,
getMenuProps,
getInputProps,
highlightedIndex,
getItemProps,
selectedItem,
} = useCombobox({
items: inputItems,
onInputValueChange: ({ inputValue }) => {
setInputItems(
items.filter(item =>
item.toLowerCase().includes(inputValue.toLowerCase())
)
);
},
onSelectedItemChange: ({ selectedItem }) => {
onSelect(selectedItem);
},
});
return (
<div className="searchable-dropdown">
<label {...getLabelProps()}>Choose an element:</label>
<div className="input-wrapper">
<input
{...getInputProps()}
placeholder={placeholder}
/>
<button
type="button"
{...getToggleButtonProps()}
aria-label="toggle menu"
>
↓
</button>
</div>
<ul {...getMenuProps()} className="dropdown-menu">
{isOpen &&
inputItems.map((item, index) => (
<li
className={highlightedIndex === index ? 'highlighted' : ''}
key={`${item}${index}`}
{...getItemProps({ item, index })}
>
{item}
</li>
))}
</ul>
</div>
);
}Filter Chips
Active Filter Display
import { X } from 'lucide-react';
interface FilterChip {
id: string;
label: string;
value: string;
}
interface ActiveFiltersProps {
filters: FilterChip[];
onRemove: (filterId: string) => void;
onClearAll: () => void;
}
export function ActiveFilters({
filters,
onRemove,
onClearAll
}: ActiveFiltersProps) {
if (filters.length === 0) return null;
return (
<div className="active-filters">
<span className="filters-label">Active filters:</span>
{filters.map(filter => (
<div key={filter.id} className="filter-chip">
<span>{filter.label}: {filter.value}</span>
<button
onClick={() => onRemove(filter.id)}
aria-label={`Remove ${filter.label} filter`}
>
<X size={14} />
</button>
</div>
))}
<button
onClick={onClearAll}
className="clear-all-btn"
>
Clear all
</button>
</div>
);
}Faceted Search
Dynamic Count Updates
interface FacetedSearchProps {
facets: {
category: string;
options: Array<{
value: string;
label: string;
count: number;
disabled?: boolean;
}>;
}[];
selected: Record<string, string[]>;
onChange: (category: string, values: string[]) => void;
}
export function FacetedSearch({
facets,
selected,
onChange
}: FacetedSearchProps) {
return (
<div className="faceted-search">
{facets.map(facet => (
<div key={facet.category} className="facet-group">
<h3>{facet.category}</h3>
{facet.options.map(option => (
<label
key={option.value}
className={`facet-option ${option.disabled ? 'disabled' : ''}`}
>
<input
type="checkbox"
checked={selected[facet.category]?.includes(option.value)}
onChange={(e) => {
const current = selected[facet.category] || [];
if (e.target.checked) {
onChange(facet.category, [...current, option.value]);
} else {
onChange(
facet.category,
current.filter(v => v !== option.value)
);
}
}}
disabled={option.disabled}
/>
<span className="facet-label">{option.label}</span>
<span className="facet-count">({option.count})</span>
</label>
))}
</div>
))}
</div>
);
}Mobile Filter Patterns
Filter Drawer
import { Filter, X } from 'lucide-react';
function MobileFilterDrawer({ children, filterCount = 0 }) {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button
onClick={() => setIsOpen(true)}
className="filter-trigger"
>
<Filter />
Filters
{filterCount > 0 && (
<span className="filter-badge">{filterCount}</span>
)}
</button>
{isOpen && (
<>
<div
className="drawer-overlay"
onClick={() => setIsOpen(false)}
/>
<div className="filter-drawer">
<div className="drawer-header">
<h2>Filters</h2>
<button onClick={() => setIsOpen(false)}>
<X />
</button>
</div>
<div className="drawer-content">
{children}
</div>
<div className="drawer-footer">
<button onClick={() => setIsOpen(false)}>
Apply Filters
</button>
</div>
</div>
</>
)}
</>
);
}Sort Options
Sort Dropdown
interface SortOption {
value: string;
label: string;
}
const sortOptions: SortOption[] = [
{ value: 'relevance', label: 'Most Relevant' },
{ value: 'price-asc', label: 'Price: Low to High' },
{ value: 'price-desc', label: 'Price: High to Low' },
{ value: 'rating', label: 'Highest Rated' },
{ value: 'newest', label: 'Newest First' },
];
export function SortDropdown({ value, onChange }) {
return (
<div className="sort-dropdown">
<label htmlFor="sort">Sort by:</label>
<select
id="sort"
value={value}
onChange={(e) => onChange(e.target.value)}
>
{sortOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
);
}Filter State Management
Using URL Parameters
import { useSearchParams } from 'react-router-dom';
function useFilterState() {
const [searchParams, setSearchParams] = useSearchParams();
const getFilters = () => {
const filters: Record<string, string[]> = {};
searchParams.forEach((value, key) => {
if (!filters[key]) {
filters[key] = [];
}
filters[key].push(value);
});
return filters;
};
const updateFilter = (key: string, values: string[]) => {
const newParams = new URLSearchParams(searchParams);
// Remove existing
newParams.delete(key);
// Add new values
values.forEach(value => {
newParams.append(key, value);
});
setSearchParams(newParams);
};
const clearFilters = () => {
setSearchParams(new URLSearchParams());
};
return {
filters: getFilters(),
updateFilter,
clearFilters,
};
}Accessibility Considerations
Filter Region ARIA
<div
role="region"
aria-label="Product filters"
className="filter-panel"
>
<h2 id="filter-heading">Filter Products</h2>
<div
role="group"
aria-labelledby="filter-heading"
>
{/* Filter groups */}
</div>
<div aria-live="polite" aria-atomic="true">
{resultCount} products found
</div>
</div>Keyboard Navigation
// Ensure all interactive elements are keyboard accessible
// Tab order should be logical
// Provide skip links for long filter lists
<a href="#results" className="skip-link">
Skip to results
</a>Related skills
FAQ
When should search run client-side vs server-side?
The skill recommends client-side filtering for under 1000 items and server-side search with pagination and caching above 1000 items, plus a hybrid approach.
Which frontend libraries does it use?
It uses Downshift for accessible autocomplete primitives and React Select as a full-featured alternative for select/filter components.