Skip to main content
Glama
northernvariables

FedMCP - Federal Parliamentary Information

MentionAutocomplete.tsx14.3 kB
'use client'; import React, { useState, useRef, useEffect, } from 'react'; import { FileText, User, Vote, Users, MessageSquare, FileSignature, Search, ChevronRight, X, Loader2, } from 'lucide-react'; import { useMentionSearch } from '@/hooks/useMentionSearch'; /** * Entity types that can be mentioned */ export type MentionEntityType = | 'bill' | 'mp' | 'committee' | 'vote' | 'debate' | 'petition'; /** * Mention suggestion item */ export interface MentionSuggestion { /** Entity type */ type: MentionEntityType; /** Unique identifier */ id: string; /** Display label */ label: string; /** Secondary text (e.g., description, subtitle) */ secondary?: string; /** Full mention string to insert (e.g., "@bill:c-234:s2.1") */ mentionString: string; /** URL to navigate to when clicked */ url?: string; /** Additional metadata */ metadata?: Record<string, unknown>; } /** * Props for MentionAutocomplete component */ interface MentionAutocompleteProps { /** Callback when a mention is selected */ onSelect: (suggestion: MentionSuggestion) => void; /** Callback when autocomplete is cancelled */ onCancel: () => void; /** Current search query (text after @) */ query: string; /** Position for the autocomplete popup */ position?: { top: number; left: number }; /** Whether the autocomplete is visible */ isVisible: boolean; /** Current locale */ locale?: string; /** Optional filter to specific entity types */ allowedTypes?: MentionEntityType[]; /** Maximum suggestions to show */ maxSuggestions?: number; } /** * Icon component for entity types */ const EntityIcon: React.FC<{ type: MentionEntityType; className?: string }> = ({ type, className = 'h-4 w-4', }) => { const icons: Record<MentionEntityType, React.ReactNode> = { bill: <FileText className={className} />, mp: <User className={className} />, committee: <Users className={className} />, vote: <Vote className={className} />, debate: <MessageSquare className={className} />, petition: <FileSignature className={className} />, }; return <>{icons[type]}</>; }; /** * Entity type labels */ const getEntityTypeLabel = ( type: MentionEntityType, locale: string ): string => { const labels: Record<MentionEntityType, { en: string; fr: string }> = { bill: { en: 'Bill', fr: 'Projet de loi' }, mp: { en: 'MP', fr: 'Depute' }, committee: { en: 'Committee', fr: 'Comite' }, vote: { en: 'Vote', fr: 'Vote' }, debate: { en: 'Debate', fr: 'Debat' }, petition: { en: 'Petition', fr: 'Petition' }, }; return locale === 'fr' ? labels[type].fr : labels[type].en; }; /** * Entity type colors */ const getEntityTypeColor = (type: MentionEntityType): string => { const colors: Record<MentionEntityType, string> = { bill: 'text-blue-600 bg-blue-100 dark:text-blue-400 dark:bg-blue-900/30', mp: 'text-green-600 bg-green-100 dark:text-green-400 dark:bg-green-900/30', committee: 'text-purple-600 bg-purple-100 dark:text-purple-400 dark:bg-purple-900/30', vote: 'text-orange-600 bg-orange-100 dark:text-orange-400 dark:bg-orange-900/30', debate: 'text-red-600 bg-red-100 dark:text-red-400 dark:bg-red-900/30', petition: 'text-teal-600 bg-teal-100 dark:text-teal-400 dark:bg-teal-900/30', }; return colors[type]; }; // Query parsing is now handled in useMentionSearch hook /** * Suggestion item component */ const SuggestionItem: React.FC<{ suggestion: MentionSuggestion; isSelected: boolean; onClick: () => void; locale: string; }> = ({ suggestion, isSelected, onClick, locale }) => { const colorClass = getEntityTypeColor(suggestion.type); return ( <button onClick={onClick} className={` w-full flex items-center gap-3 px-3 py-2 text-left transition-colors duration-100 ${ isSelected ? 'bg-blue-50 dark:bg-blue-900/30' : 'hover:bg-gray-50 dark:hover:bg-gray-800' } `} role="option" aria-selected={isSelected} > {/* Entity type icon */} <span className={`p-1.5 rounded ${colorClass}`}> <EntityIcon type={suggestion.type} className="h-4 w-4" /> </span> {/* Content */} <div className="flex-1 min-w-0"> <div className="flex items-center gap-2"> <span className="font-medium text-gray-900 dark:text-gray-100 truncate"> {suggestion.label} </span> <span className={`text-xs px-1.5 py-0.5 rounded ${colorClass} opacity-75`} > {getEntityTypeLabel(suggestion.type, locale)} </span> </div> {suggestion.secondary && ( <p className="text-sm text-gray-500 dark:text-gray-400 truncate"> {suggestion.secondary} </p> )} </div> {/* Arrow indicator */} <ChevronRight className="h-4 w-4 text-gray-400 dark:text-gray-500 flex-shrink-0" /> </button> ); }; /** * Entity type filter buttons */ const TypeFilter: React.FC<{ types: MentionEntityType[]; activeType: MentionEntityType | null; onTypeSelect: (type: MentionEntityType | null) => void; locale: string; }> = ({ types, activeType, onTypeSelect, locale }) => { return ( <div className="flex items-center gap-1 px-2 py-1.5 border-b border-gray-200 dark:border-gray-700 overflow-x-auto"> <button onClick={() => onTypeSelect(null)} className={` px-2 py-1 text-xs font-medium rounded-md whitespace-nowrap transition-colors ${ activeType === null ? 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-gray-100' : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800' } `} > {locale === 'fr' ? 'Tous' : 'All'} </button> {types.map((type) => ( <button key={type} onClick={() => onTypeSelect(type)} className={` flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-md whitespace-nowrap transition-colors ${ activeType === type ? `${getEntityTypeColor(type)}` : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800' } `} > <EntityIcon type={type} className="h-3 w-3" /> {getEntityTypeLabel(type, locale)} </button> ))} </div> ); }; /** * MentionAutocomplete - Universal autocomplete for @mentions * * Features: * - Multi-entity search (bills, MPs, committees, votes, debates, petitions) * - Type filtering with keyboard shortcuts * - Keyboard navigation (up/down, enter, escape) * - Debounced search * - Position-aware popup */ export const MentionAutocomplete: React.FC<MentionAutocompleteProps> = ({ onSelect, onCancel, query, position, isVisible, locale = 'en', allowedTypes, maxSuggestions = 8, }) => { const [selectedIndex, setSelectedIndex] = useState(0); const [activeTypeFilter, setActiveTypeFilter] = useState<MentionEntityType | null>(null); const containerRef = useRef<HTMLDivElement>(null); // Available entity types const availableTypes: MentionEntityType[] = allowedTypes || ['bill', 'mp', 'committee', 'vote', 'debate', 'petition']; // Determine types to search based on filter const typesToSearch = activeTypeFilter ? [activeTypeFilter] : availableTypes; // Use the real search hook const { suggestions, loading } = useMentionSearch({ query: isVisible && query.length >= 1 ? query : '', types: typesToSearch, locale, maxResults: maxSuggestions, }); // Reset selection when suggestions change useEffect(() => { setSelectedIndex(0); }, [suggestions]); // Keyboard navigation useEffect(() => { if (!isVisible) return; const handleKeyDown = (e: KeyboardEvent) => { switch (e.key) { case 'ArrowDown': e.preventDefault(); setSelectedIndex((prev) => prev < suggestions.length - 1 ? prev + 1 : 0 ); break; case 'ArrowUp': e.preventDefault(); setSelectedIndex((prev) => prev > 0 ? prev - 1 : suggestions.length - 1 ); break; case 'Enter': e.preventDefault(); if (suggestions[selectedIndex]) { onSelect(suggestions[selectedIndex]); } break; case 'Escape': e.preventDefault(); onCancel(); break; case 'Tab': // Allow tab to cycle through type filters if (e.shiftKey) { const currentIndex = activeTypeFilter ? availableTypes.indexOf(activeTypeFilter) : -1; const newIndex = currentIndex <= 0 ? availableTypes.length - 1 : currentIndex - 1; setActiveTypeFilter(availableTypes[newIndex]); } else { const currentIndex = activeTypeFilter ? availableTypes.indexOf(activeTypeFilter) : -1; if (currentIndex >= availableTypes.length - 1) { setActiveTypeFilter(null); } else { setActiveTypeFilter(availableTypes[currentIndex + 1]); } } e.preventDefault(); break; } }; document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [ isVisible, suggestions, selectedIndex, onSelect, onCancel, activeTypeFilter, availableTypes, ]); // Click outside to close useEffect(() => { if (!isVisible) return; const handleClickOutside = (e: MouseEvent) => { if ( containerRef.current && !containerRef.current.contains(e.target as Node) ) { onCancel(); } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, [isVisible, onCancel]); if (!isVisible) return null; const style: React.CSSProperties = position ? { position: 'absolute', top: position.top, left: position.left, } : {}; return ( <div ref={containerRef} style={style} className=" z-50 w-80 max-h-96 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg overflow-hidden " role="listbox" aria-label={locale === 'fr' ? 'Suggestions de mention' : 'Mention suggestions'} > {/* Header */} <div className="flex items-center justify-between px-3 py-2 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800"> <div className="flex items-center gap-2"> <Search className="h-4 w-4 text-gray-400" /> <span className="text-sm text-gray-600 dark:text-gray-300"> {query || (locale === 'fr' ? 'Rechercher...' : 'Search...')} </span> </div> <button onClick={onCancel} className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300" aria-label={locale === 'fr' ? 'Fermer' : 'Close'} > <X className="h-4 w-4" /> </button> </div> {/* Type filters */} <TypeFilter types={availableTypes} activeType={activeTypeFilter} onTypeSelect={setActiveTypeFilter} locale={locale} /> {/* Suggestions list */} <div className="max-h-64 overflow-y-auto"> {loading ? ( <div className="px-4 py-6 text-center text-gray-500 dark:text-gray-400"> <Loader2 className="h-8 w-8 mx-auto mb-2 animate-spin opacity-50" /> <p className="text-sm"> {locale === 'fr' ? 'Recherche...' : 'Searching...'} </p> </div> ) : suggestions.length === 0 ? ( <div className="px-4 py-6 text-center text-gray-500 dark:text-gray-400"> <Search className="h-8 w-8 mx-auto mb-2 opacity-50" /> <p className="text-sm"> {locale === 'fr' ? 'Aucun resultat trouve' : 'No results found'} </p> <p className="text-xs mt-1 opacity-75"> {locale === 'fr' ? 'Essayez un autre terme de recherche' : 'Try a different search term'} </p> </div> ) : ( suggestions.map((suggestion, index) => ( <SuggestionItem key={`${suggestion.type}-${suggestion.id}`} suggestion={suggestion} isSelected={index === selectedIndex} onClick={() => onSelect(suggestion)} locale={locale} /> )) )} </div> {/* Footer hint */} <div className="px-3 py-1.5 bg-gray-50 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700"> <p className="text-xs text-gray-500 dark:text-gray-400 text-center"> {locale === 'fr' ? ( <> <kbd className="px-1 bg-gray-200 dark:bg-gray-700 rounded"> Tab </kbd>{' '} filtrer{' '} <kbd className="px-1 bg-gray-200 dark:bg-gray-700 rounded"> Entree </kbd>{' '} selectionner{' '} <kbd className="px-1 bg-gray-200 dark:bg-gray-700 rounded"> Echap </kbd>{' '} fermer </> ) : ( <> <kbd className="px-1 bg-gray-200 dark:bg-gray-700 rounded"> Tab </kbd>{' '} filter{' '} <kbd className="px-1 bg-gray-200 dark:bg-gray-700 rounded"> Enter </kbd>{' '} select{' '} <kbd className="px-1 bg-gray-200 dark:bg-gray-700 rounded"> Esc </kbd>{' '} close </> )} </p> </div> </div> ); }; export default MentionAutocomplete;

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/northernvariables/FedMCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server