diff --git a/app/src/sections/plots-gallery/FilterBar.tsx b/app/src/sections/plots-gallery/FilterBar.tsx deleted file mode 100644 index c3efcb32d9..0000000000 --- a/app/src/sections/plots-gallery/FilterBar.tsx +++ /dev/null @@ -1,881 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; - -import AddIcon from '@mui/icons-material/Add'; -import CloseIcon from '@mui/icons-material/Close'; -import SearchIcon from '@mui/icons-material/Search'; -import Box from '@mui/material/Box'; -import Chip from '@mui/material/Chip'; -import Divider from '@mui/material/Divider'; -import InputBase from '@mui/material/InputBase'; -import ListItemText from '@mui/material/ListItemText'; -import Menu from '@mui/material/Menu'; -import MenuItem from '@mui/material/MenuItem'; -import { useTheme } from '@mui/material/styles'; -import Tooltip from '@mui/material/Tooltip'; -import Typography from '@mui/material/Typography'; -import useMediaQuery from '@mui/material/useMediaQuery'; - -import type { ImageSize } from 'src/constants'; -import { ToolbarActions } from 'src/sections/plots-gallery/ToolbarActions'; -import { colors, fontSize, semanticColors, typography } from 'src/theme'; -import type { ActiveFilters, FilterCategory, FilterCounts } from 'src/types'; -import { FILTER_CATEGORIES, FILTER_LABELS, FILTER_TOOLTIPS } from 'src/types'; -import { - getAvailableValues, - getAvailableValuesForGroup, - getSearchResults, - type SearchResult, -} from 'src/utils'; - -interface FilterBarProps { - activeFilters: ActiveFilters; - filterCounts: FilterCounts | null; // Contextual counts (for AND additions) - orCounts: Record[]; // Per-group counts for OR additions - specTitles: Record; // Mapping spec_id -> title for search/tooltips - currentTotal: number; // Total number of filtered images - displayedCount: number; // Currently displayed images - randomAnimation: { index: number; phase: 'out' | 'in'; oldLabel?: string } | null; - searchInputRef?: React.RefObject; - imageSize: ImageSize; - onImageSizeChange: (size: ImageSize) => void; - onAddFilter: (category: FilterCategory, value: string) => void; - onAddValueToGroup: (groupIndex: number, value: string) => void; - onRemoveFilter: (groupIndex: number, value: string) => void; - onRemoveGroup: (groupIndex: number) => void; - onTrackEvent: (event: string, props?: Record) => void; -} - -export function FilterBar({ - activeFilters, - filterCounts, - orCounts, - specTitles, - currentTotal, - displayedCount, - randomAnimation, - searchInputRef, - imageSize, - onImageSizeChange, - onAddFilter, - onAddValueToGroup, - onRemoveFilter, - onRemoveGroup, - onTrackEvent, -}: FilterBarProps) { - const theme = useTheme(); - const isMobile = useMediaQuery(theme.breakpoints.down('md')); - - // Scroll percentage and sticky detection - const [scrollPercent, setScrollPercent] = useState(0); - const [isSticky, setIsSticky] = useState(false); - const filterBarRef = useRef(null); - - useEffect(() => { - const calculatePercent = () => { - const scrollY = window.scrollY; - const docHeight = document.documentElement.scrollHeight; - const windowHeight = window.innerHeight; - - // Estimate total height based on ratio of loaded vs total plots - const loadRatio = displayedCount > 0 && currentTotal > 0 ? currentTotal / displayedCount : 1; - const estimatedTotalHeight = (docHeight - windowHeight) * loadRatio; - - const percent = Math.round((scrollY / estimatedTotalHeight) * 100); - setScrollPercent(Math.min(100, Math.max(0, percent || 0))); - - // Detect if bar is in sticky mode (scrolled past threshold). - // On /plots the masthead+navbar flow with content (~120px), so the FilterBar - // starts sticking shortly after that. 60px is a conservative trigger. - setIsSticky(scrollY > 60); - }; - calculatePercent(); - // passive: true — scroll handler doesn't preventDefault, so let the - // browser scroll without waiting for our handler to ack. Matters on - // /plots, the busiest scroll path in the app. - window.addEventListener('scroll', calculatePercent, { passive: true }); - const resizeObserver = new ResizeObserver(calculatePercent); - resizeObserver.observe(document.body); - return () => { - window.removeEventListener('scroll', calculatePercent); - resizeObserver.disconnect(); - }; - }, [displayedCount, currentTotal]); - - // Search/dropdown state - const [searchQuery, setSearchQuery] = useState(''); - const [dropdownAnchor, setDropdownAnchor] = useState(null); - const [selectedCategory, setSelectedCategory] = useState(null); - const [isSearchManuallyExpanded, setIsSearchManuallyExpanded] = useState(false); - const searchContainerRef = useRef(null); - const localInputRef = useRef(null); - const inputRef = searchInputRef || localInputRef; - - // Search is expanded when: no filters OR manually expanded - const isSearchExpanded = activeFilters.length === 0 || isSearchManuallyExpanded; - - // Chip menu state - const [chipMenuAnchor, setChipMenuAnchor] = useState(null); - const [activeGroupIndex, setActiveGroupIndex] = useState(null); - - // Dropdown keyboard navigation - const [highlightedIndex, setHighlightedIndex] = useState(-1); - - // Expand and open dropdown - const handleSearchExpand = useCallback(() => { - setIsSearchManuallyExpanded(true); - setDropdownAnchor(searchContainerRef.current); - setTimeout(() => inputRef.current?.focus(), 0); - }, [inputRef]); - - // Collapse when empty and loses focus (only if there are filters) - const handleSearchBlur = useCallback(() => { - // Delay to allow click events on dropdown to fire first - setTimeout(() => { - if (!searchQuery && !selectedCategory && !dropdownAnchor && activeFilters.length > 0) { - setIsSearchManuallyExpanded(false); - } - }, 200); - }, [searchQuery, selectedCategory, dropdownAnchor, activeFilters.length]); - - // Close dropdown and collapse if empty - const handleDropdownClose = useCallback(() => { - setDropdownAnchor(null); - setSelectedCategory(null); - setSearchQuery(''); - setHighlightedIndex(-1); - setIsSearchManuallyExpanded(false); - }, []); - - // Select category from dropdown - const handleCategorySelect = useCallback( - (category: FilterCategory) => { - setSelectedCategory(category); - setSearchQuery(''); - setHighlightedIndex(-1); - setTimeout(() => inputRef.current?.focus(), 50); - }, - [inputRef] - ); - - // Select value (add new filter group) - const handleValueSelect = useCallback( - (category: FilterCategory, value: string) => { - onAddFilter(category, value); - // Track search if query was used (filter changes tracked via pageview) - if (searchQuery.trim()) { - onTrackEvent('search', { query: searchQuery.trim(), category }); - } - setSelectedCategory(null); - setSearchQuery(''); - setHighlightedIndex(-1); - // Keep expanded and focused for next filter - setIsSearchManuallyExpanded(true); - setTimeout(() => { - setDropdownAnchor(searchContainerRef.current); - inputRef.current?.focus(); - }, 50); - }, - [onAddFilter, onTrackEvent, searchQuery, inputRef] - ); - - // Chip click - open chip menu - const handleChipClick = useCallback( - (event: React.MouseEvent, groupIndex: number) => { - setChipMenuAnchor(event.currentTarget); - setActiveGroupIndex(groupIndex); - }, - [] - ); - - // Remove single value from group - const handleRemoveValue = useCallback( - (value: string) => { - if (activeGroupIndex !== null) { - onRemoveFilter(activeGroupIndex, value); - } - setChipMenuAnchor(null); - setActiveGroupIndex(null); - }, - [activeGroupIndex, onRemoveFilter] - ); - - // Remove entire group - const handleRemoveGroup = useCallback(() => { - if (activeGroupIndex !== null) { - onRemoveGroup(activeGroupIndex); - } - setChipMenuAnchor(null); - setActiveGroupIndex(null); - }, [activeGroupIndex, onRemoveGroup]); - - // Add value to existing group (OR) - const handleAddValueToExistingGroup = useCallback( - (value: string) => { - if (activeGroupIndex !== null) { - onAddValueToGroup(activeGroupIndex, value); - } - setChipMenuAnchor(null); - setActiveGroupIndex(null); - }, - [activeGroupIndex, onAddValueToGroup] - ); - - // Memoize search results to avoid recalculating on every render - const searchResults = useMemo( - () => getSearchResults(filterCounts, activeFilters, searchQuery, selectedCategory, specTitles), - [filterCounts, activeFilters, searchQuery, selectedCategory, specTitles] - ); - - // Track searches with no results (debounced, to discover missing specs) - const lastTrackedQueryRef = useRef(''); - useEffect(() => { - const query = searchQuery.trim(); - // Only track if: query >= 2 chars, no results, not already tracked this query - if (query.length >= 2 && searchResults.length === 0 && query !== lastTrackedQueryRef.current) { - const timer = setTimeout(() => { - onTrackEvent('search_no_results', { query }); - lastTrackedQueryRef.current = query; - }, 200); - return () => clearTimeout(timer); - } - }, [searchQuery, searchResults.length, onTrackEvent]); - - // Reset tracked query when dropdown closes - useEffect(() => { - if (!dropdownAnchor) { - lastTrackedQueryRef.current = ''; - } - }, [dropdownAnchor]); - - // Only open if anchor is valid and in document - const isDropdownOpen = Boolean(dropdownAnchor) && document.body.contains(dropdownAnchor); - const hasQuery = searchQuery.trim().length > 0; - const maxFiltersReached = activeFilters.length >= 5; - - // Get dropdown items for keyboard navigation - const getDropdownItems = useCallback(() => { - if (!selectedCategory && !hasQuery) { - // Categories list - return FILTER_CATEGORIES.filter(cat => { - const available = getAvailableValues(filterCounts, activeFilters, cat); - return available.length > 0; - }).map(cat => ({ type: 'category' as const, category: cat })); - } else if (selectedCategory && !hasQuery) { - // Category selected but no query - show all available values for this category - const available = getAvailableValues(filterCounts, activeFilters, selectedCategory); - return available.map(([value, count]) => ({ - type: 'value' as const, - category: selectedCategory, - value, - count, - matchType: 'exact' as const, - })); - } else { - // Search results (with query) - return searchResults.map(r => ({ type: 'value' as const, ...r })); - } - }, [selectedCategory, hasQuery, filterCounts, activeFilters, searchResults]); - - const dropdownItems = getDropdownItems(); - - // Handle keyboard navigation - const handleKeyDown = useCallback( - (event: React.KeyboardEvent) => { - if (event.key === 'ArrowDown') { - event.preventDefault(); - setHighlightedIndex(prev => Math.min(prev + 1, dropdownItems.length - 1)); - } else if (event.key === 'ArrowUp') { - event.preventDefault(); - setHighlightedIndex(prev => Math.max(prev - 1, -1)); - } else if (event.key === 'Enter') { - event.preventDefault(); - const item = dropdownItems[highlightedIndex] || dropdownItems[0]; - if (item) { - if (item.type === 'category') { - handleCategorySelect(item.category); - setHighlightedIndex(-1); - } else { - handleValueSelect(item.category, item.value); - } - } - } else if (event.key === 'Escape') { - handleDropdownClose(); - inputRef.current?.blur(); - } - }, - [ - dropdownItems, - highlightedIndex, - handleCategorySelect, - handleValueSelect, - handleDropdownClose, - inputRef, - ] - ); - - // Get active group for chip menu - const activeGroup = activeGroupIndex !== null ? activeFilters[activeGroupIndex] : null; - const availableValuesForActiveGroup = - activeGroupIndex !== null - ? getAvailableValuesForGroup(activeGroupIndex, activeFilters, orCounts, currentTotal) - : []; - - return ( - - {/* Filter chips row */} - - {/* Progress counter - absolute left (desktop only) */} - {!isMobile && currentTotal > 0 && ( - - {scrollPercent}% · {currentTotal} - - )} - {/* Toolbar actions - absolute right (desktop only) */} - {!isMobile && ( - - - - )} - {/* Active filter chips */} - {activeFilters.map((group, index) => { - const isAnimating = randomAnimation?.index === index; - const animationClass = isAnimating ? `chip-blur-${randomAnimation.phase}` : undefined; - // Show old label during 'out' phase, new label during 'in' phase - const displayLabel = - isAnimating && randomAnimation.phase === 'out' && randomAnimation.oldLabel - ? randomAnimation.oldLabel - : `${group.category}:${group.values.join(',')}`; - - return ( - handleChipClick(e, index)} - onDelete={() => onRemoveGroup(index)} - deleteIcon={} - sx={{ - fontFamily: typography.fontFamily, - fontSize: fontSize.base, - height: 32, - bgcolor: 'var(--bg-surface)', - border: `1px solid ${colors.primary}`, - color: 'var(--ink-soft)', - cursor: 'pointer', - '&:hover': { bgcolor: 'var(--bg-elevated)' }, - '& .MuiChip-deleteIcon': { - color: 'var(--ink-muted)', - '&:hover': { color: colors.primary }, - }, - ...(animationClass === 'chip-blur-out' && { - animation: 'chip-roll-out 0.5s ease-in forwards', - }), - ...(animationClass === 'chip-blur-in' && { - animation: 'chip-roll-in 0.5s ease-out forwards', - }), - '@keyframes chip-roll-out': { - '0%': { transform: 'perspective(200px) rotateX(0deg)' }, - '100%': { transform: 'perspective(200px) rotateX(180deg)' }, - }, - '@keyframes chip-roll-in': { - '0%': { transform: 'perspective(200px) rotateX(180deg)' }, - '100%': { transform: 'perspective(200px) rotateX(360deg)' }, - }, - }} - /> - ); - })} - - {/* Search input - collapsed icon or expanded input */} - {!maxFiltersReached && ( - { - if (!isSearchExpanded && (e.key === 'Enter' || e.key === ' ')) { - e.preventDefault(); - handleSearchExpand(); - } - }} - sx={{ - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - gap: 0.5, - px: isSearchExpanded ? 1.5 : 0, - height: 32, - width: isSearchExpanded ? { xs: 220, sm: 220, md: 'auto' } : 32, - minWidth: isSearchExpanded ? { xs: 220, sm: 220, md: 200 } : 32, - border: isSearchExpanded ? '1px dashed var(--ink-muted)' : 'none', - borderRadius: '16px', - bgcolor: isDropdownOpen ? 'var(--bg-elevated)' : 'transparent', - cursor: 'pointer', - transition: 'all 0.2s ease', - '&:hover': { - borderColor: isSearchExpanded ? colors.primary : undefined, - bgcolor: isSearchExpanded ? 'var(--bg-elevated)' : undefined, - }, - '&:hover .search-icon': { - color: colors.primary, - }, - '&:focus': isSearchExpanded - ? {} - : { outline: `2px solid ${colors.primary}`, outlineOffset: 2 }, - }} - > - - - - - { - setSearchQuery(e.target.value); - setHighlightedIndex(-1); - if (!dropdownAnchor) { - setDropdownAnchor(searchContainerRef.current); - } - }} - onFocus={() => { - if (!isSearchManuallyExpanded && activeFilters.length > 0) { - setIsSearchManuallyExpanded(true); - } - setDropdownAnchor(searchContainerRef.current); - setHighlightedIndex(-1); - }} - onBlur={handleSearchBlur} - onKeyDown={handleKeyDown} - sx={{ - flex: isSearchExpanded ? 1 : 0, - width: isSearchExpanded ? 'auto' : 0, - opacity: isSearchExpanded ? 1 : 0, - transition: 'all 0.2s ease', - fontFamily: typography.fontFamily, - fontSize: fontSize.base, - color: 'var(--ink)', - '& input': { - padding: 0, - fontFamily: typography.fontFamily, - fontSize: fontSize.base, - color: 'var(--ink)', - '&::placeholder': { - color: semanticColors.mutedText, - opacity: 1, - }, - }, - }} - /> - {isSearchExpanded && (searchQuery || selectedCategory) && ( - { - e.stopPropagation(); - setSearchQuery(''); - setSelectedCategory(null); - }} - sx={{ - color: 'var(--ink-muted)', - fontSize: fontSize.lg, - cursor: 'pointer', - '&:hover': { color: 'var(--ink-soft)' }, - }} - /> - )} - - )} - - - {/* Counter and toggle row (mobile only) */} - {isMobile && ( - - {currentTotal > 0 ? ( - - {scrollPercent}% · {currentTotal} - - ) : ( - - )} - - - )} - - {/* Dropdown menu */} - - {!selectedCategory && !hasQuery - ? // Show categories - FILTER_CATEGORIES.map(category => { - const availableVals = getAvailableValues(filterCounts, activeFilters, category); - if (availableVals.length === 0) return null; - // Calculate actual index among visible items - const visibleIdx = dropdownItems.findIndex( - item => item.type === 'category' && item.category === category - ); - return ( - - handleCategorySelect(category)} - selected={visibleIdx === highlightedIndex} - sx={{ fontFamily: typography.fontFamily }} - > - - - - ); - }) - : // Show search results or category values - [ - ...(selectedCategory - ? [ - { - setSelectedCategory(null); - setSearchQuery(''); - }} - sx={{ fontFamily: typography.fontFamily, color: semanticColors.mutedText }} - > - ← {FILTER_LABELS[selectedCategory]} - , - , - ] - : []), - ...(() => { - // Use searchResults if query exists, otherwise show all available values for selected category - const resultsToShow: SearchResult[] = hasQuery - ? searchResults - : selectedCategory - ? getAvailableValues(filterCounts, activeFilters, selectedCategory).map( - ([value, count]) => ({ - category: selectedCategory, - value, - count, - matchType: 'exact' as const, - }) - ) - : []; - - if (resultsToShow.length > 0) { - // Split results into exact and fuzzy matches - const exactResults = resultsToShow.filter(r => r.matchType === 'exact'); - const fuzzyResults = resultsToShow.filter(r => r.matchType === 'fuzzy'); - - const renderMenuItem = (result: SearchResult, idx: number) => { - const { category, value, count } = result; - const specTitle = category === 'spec' ? specTitles[value] : undefined; - const menuItem = ( - handleValueSelect(category, value)} - selected={idx === highlightedIndex} - sx={{ fontFamily: typography.fontFamily }} - > - - - ({count}) - - - ); - return specTitle ? ( - - {menuItem} - - ) : ( - menuItem - ); - }; - - const items: React.ReactNode[] = []; - // Add exact matches - exactResults.forEach((result, i) => { - items.push(renderMenuItem(result, i)); - }); - // Add fuzzy label/divider if there are fuzzy results - if (fuzzyResults.length > 0) { - items.push( - - - fuzzy - - - ); - } - // Add fuzzy matches - fuzzyResults.forEach((result, i) => { - items.push(renderMenuItem(result, exactResults.length + i)); - }); - return items; - } else { - return [ - - - results.empty() - - , - ]; - } - })(), - ]} - - - {/* Chip action menu */} - { - setChipMenuAnchor(null); - setActiveGroupIndex(null); - }} - slotProps={{ - paper: { - sx: { - minWidth: 180, - maxHeight: 350, - }, - }, - }} - > - {activeGroup && [ - // Add value (OR) - submenu with available values - ...(availableValuesForActiveGroup.length > 0 - ? [ - - add (or) - , - ...availableValuesForActiveGroup.map(([value, count]) => ( - handleAddValueToExistingGroup(value)} - sx={{ fontFamily: typography.fontFamily, py: 0.5 }} - > - - {value} - - ({count}) - - - )), - , - ] - : []), - // Remove individual values - ...activeGroup.values.map(value => ( - handleRemoveValue(value)} - sx={{ fontFamily: typography.fontFamily }} - > - - {value} - - )), - // Remove all (only if more than 1 value) - ...(activeGroup.values.length > 1 - ? [ - , - - - remove all - , - ] - : []), - ]} - - - ); -} diff --git a/app/src/sections/plots-gallery/FilterBar/FilterChips.test.tsx b/app/src/sections/plots-gallery/FilterBar/FilterChips.test.tsx new file mode 100644 index 0000000000..a45e359552 --- /dev/null +++ b/app/src/sections/plots-gallery/FilterBar/FilterChips.test.tsx @@ -0,0 +1,123 @@ +import { fireEvent } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + FilterChips, + type FilterChipsProps, +} from 'src/sections/plots-gallery/FilterBar/FilterChips'; +import { render, screen } from 'src/test-utils'; + +const callbacks = { + onChipClick: vi.fn(), + onChipMenuClose: vi.fn(), + onRemoveValue: vi.fn(), + onRemoveActiveGroup: vi.fn(), + onAddValueToActiveGroup: vi.fn(), + onRemoveGroup: vi.fn(), +}; + +function renderChips(overrides: Partial = {}) { + return render( + + ); +} + +describe('FilterChips', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders one chip per group with category:values label', () => { + renderChips({ + activeFilters: [ + { category: 'lib', values: ['matplotlib', 'seaborn'] }, + { category: 'plot', values: ['scatter'] }, + ], + }); + expect(screen.getByText('lib:matplotlib,seaborn')).toBeInTheDocument(); + expect(screen.getByText('plot:scatter')).toBeInTheDocument(); + }); + + it('shows the old label during the out phase of the random animation', () => { + renderChips({ + randomAnimation: { index: 0, phase: 'out', oldLabel: 'lib:plotly' }, + }); + expect(screen.getByText('lib:plotly')).toBeInTheDocument(); + expect(screen.queryByText('lib:matplotlib')).not.toBeInTheDocument(); + }); + + it('shows the new label during the in phase of the random animation', () => { + renderChips({ + randomAnimation: { index: 0, phase: 'in', oldLabel: 'lib:plotly' }, + }); + expect(screen.getByText('lib:matplotlib')).toBeInTheDocument(); + }); + + it('calls onChipClick with the group index when a chip is clicked', () => { + renderChips(); + fireEvent.click(screen.getByText('lib:matplotlib')); + expect(callbacks.onChipClick).toHaveBeenCalledWith(expect.anything(), 0); + }); + + it('calls onRemoveGroup when the chip delete icon is clicked', () => { + renderChips(); + const deleteIcon = document.querySelector('.MuiChip-deleteIcon'); + expect(deleteIcon).toBeTruthy(); + fireEvent.click(deleteIcon as Element); + expect(callbacks.onRemoveGroup).toHaveBeenCalledWith(0); + }); + + describe('chip action menu', () => { + const menuProps: Partial = { + activeFilters: [{ category: 'lib', values: ['matplotlib', 'seaborn'] }], + orCounts: [{ plotly: 20 }], + currentTotal: 100, + chipMenuAnchor: document.createElement('div'), + activeGroupIndex: 0, + }; + + beforeEach(() => { + document.body.appendChild(menuProps.chipMenuAnchor as HTMLElement); + }); + + it('offers OR additions with preview counts', () => { + renderChips(menuProps); + expect(screen.getByText('add (or)')).toBeInTheDocument(); + expect(screen.getByText('plotly')).toBeInTheDocument(); + expect(screen.getByText('(120)')).toBeInTheDocument(); // currentTotal + orCount + + fireEvent.click(screen.getByText('plotly')); + expect(callbacks.onAddValueToActiveGroup).toHaveBeenCalledWith('plotly'); + }); + + it('offers removing individual values', () => { + renderChips(menuProps); + fireEvent.click(screen.getByText('matplotlib')); + expect(callbacks.onRemoveValue).toHaveBeenCalledWith('matplotlib'); + }); + + it('offers remove all only for multi-value groups', () => { + renderChips(menuProps); + fireEvent.click(screen.getByText('remove all')); + expect(callbacks.onRemoveActiveGroup).toHaveBeenCalled(); + }); + + it('hides remove all for single-value groups', () => { + renderChips({ + ...menuProps, + activeFilters: [{ category: 'lib', values: ['matplotlib'] }], + orCounts: [{}], + }); + expect(screen.queryByText('remove all')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/app/src/sections/plots-gallery/FilterBar/FilterChips.tsx b/app/src/sections/plots-gallery/FilterBar/FilterChips.tsx new file mode 100644 index 0000000000..77cf2b48cb --- /dev/null +++ b/app/src/sections/plots-gallery/FilterBar/FilterChips.tsx @@ -0,0 +1,190 @@ +import AddIcon from '@mui/icons-material/Add'; +import CloseIcon from '@mui/icons-material/Close'; +import Chip from '@mui/material/Chip'; +import Divider from '@mui/material/Divider'; +import Menu from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import Typography from '@mui/material/Typography'; + +import { colors, fontSize, semanticColors, typography } from 'src/theme'; +import type { ActiveFilters } from 'src/types'; +import { getAvailableValuesForGroup } from 'src/utils'; + +/** Random-button chip animation state (owned by PlotsPage). */ +export interface RandomAnimation { + index: number; + phase: 'out' | 'in'; + oldLabel?: string; +} + +export interface FilterChipsProps { + activeFilters: ActiveFilters; + randomAnimation: RandomAnimation | null; + orCounts: Record[]; // Per-group counts for OR additions + currentTotal: number; // Total number of filtered images + chipMenuAnchor: HTMLElement | null; + activeGroupIndex: number | null; + onChipClick: (event: React.MouseEvent, groupIndex: number) => void; + onChipMenuClose: () => void; + onRemoveValue: (value: string) => void; // Remove one value from the active group + onRemoveActiveGroup: () => void; // Remove the entire active group (via chip menu) + onAddValueToActiveGroup: (value: string) => void; // Add value to active group (OR) + onRemoveGroup: (groupIndex: number) => void; // Remove a group directly (chip delete icon) +} + +/** + * Active-filter chips with roll animation plus the per-chip action menu + * (add OR value, remove single value, remove all). + */ +export function FilterChips({ + activeFilters, + randomAnimation, + orCounts, + currentTotal, + chipMenuAnchor, + activeGroupIndex, + onChipClick, + onChipMenuClose, + onRemoveValue, + onRemoveActiveGroup, + onAddValueToActiveGroup, + onRemoveGroup, +}: FilterChipsProps) { + // Get active group for chip menu + const activeGroup = activeGroupIndex !== null ? activeFilters[activeGroupIndex] : null; + const availableValuesForActiveGroup = + activeGroupIndex !== null + ? getAvailableValuesForGroup(activeGroupIndex, activeFilters, orCounts, currentTotal) + : []; + + return ( + <> + {/* Active filter chips */} + {activeFilters.map((group, index) => { + const isAnimating = randomAnimation?.index === index; + const animationClass = isAnimating ? `chip-blur-${randomAnimation.phase}` : undefined; + // Show old label during 'out' phase, new label during 'in' phase + const displayLabel = + isAnimating && randomAnimation.phase === 'out' && randomAnimation.oldLabel + ? randomAnimation.oldLabel + : `${group.category}:${group.values.join(',')}`; + + return ( + onChipClick(e, index)} + onDelete={() => onRemoveGroup(index)} + deleteIcon={} + sx={{ + fontFamily: typography.fontFamily, + fontSize: fontSize.base, + height: 32, + bgcolor: 'var(--bg-surface)', + border: `1px solid ${colors.primary}`, + color: 'var(--ink-soft)', + cursor: 'pointer', + '&:hover': { bgcolor: 'var(--bg-elevated)' }, + '& .MuiChip-deleteIcon': { + color: 'var(--ink-muted)', + '&:hover': { color: colors.primary }, + }, + ...(animationClass === 'chip-blur-out' && { + animation: 'chip-roll-out 0.5s ease-in forwards', + }), + ...(animationClass === 'chip-blur-in' && { + animation: 'chip-roll-in 0.5s ease-out forwards', + }), + '@keyframes chip-roll-out': { + '0%': { transform: 'perspective(200px) rotateX(0deg)' }, + '100%': { transform: 'perspective(200px) rotateX(180deg)' }, + }, + '@keyframes chip-roll-in': { + '0%': { transform: 'perspective(200px) rotateX(180deg)' }, + '100%': { transform: 'perspective(200px) rotateX(360deg)' }, + }, + }} + /> + ); + })} + + {/* Chip action menu */} + + {activeGroup && [ + // Add value (OR) - submenu with available values + ...(availableValuesForActiveGroup.length > 0 + ? [ + + add (or) + , + ...availableValuesForActiveGroup.map(([value, count]) => ( + onAddValueToActiveGroup(value)} + sx={{ fontFamily: typography.fontFamily, py: 0.5 }} + > + + {value} + + ({count}) + + + )), + , + ] + : []), + // Remove individual values + ...activeGroup.values.map(value => ( + onRemoveValue(value)} + sx={{ fontFamily: typography.fontFamily }} + > + + {value} + + )), + // Remove all (only if more than 1 value) + ...(activeGroup.values.length > 1 + ? [ + , + + + remove all + , + ] + : []), + ]} + + + ); +} diff --git a/app/src/sections/plots-gallery/FilterBar/FilterMenu.test.tsx b/app/src/sections/plots-gallery/FilterBar/FilterMenu.test.tsx new file mode 100644 index 0000000000..6c64ae383a --- /dev/null +++ b/app/src/sections/plots-gallery/FilterBar/FilterMenu.test.tsx @@ -0,0 +1,122 @@ +import { fireEvent } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + type DropdownItem, + FilterMenu, + type FilterMenuProps, +} from 'src/sections/plots-gallery/FilterBar/FilterMenu'; +import { render, screen } from 'src/test-utils'; +import type { FilterCounts } from 'src/types'; +import type { SearchResult } from 'src/utils'; + +const filterCounts: FilterCounts = { + lang: { python: 180 }, + lib: { matplotlib: 100, seaborn: 80 }, + spec: {}, + plot: { scatter: 10 }, + data: {}, + dom: {}, + feat: {}, + dep: {}, + tech: {}, + pat: {}, + prep: {}, + style: {}, +}; + +const categoryItems: DropdownItem[] = [ + { type: 'category', category: 'lang' }, + { type: 'category', category: 'lib' }, + { type: 'category', category: 'plot' }, +]; + +const callbacks = { + onClose: vi.fn(), + onCategorySelect: vi.fn(), + onValueSelect: vi.fn(), + onBack: vi.fn(), +}; + +function renderMenu(overrides: Partial = {}) { + const anchor = document.createElement('div'); + document.body.appendChild(anchor); + return render( + + ); +} + +describe('FilterMenu', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('lists available categories with option counts', () => { + renderMenu(); + expect(screen.getByText('language')).toBeInTheDocument(); + expect(screen.getByText('library')).toBeInTheDocument(); + expect(screen.getByText('type')).toBeInTheDocument(); + expect(screen.getByText('2 options')).toBeInTheDocument(); // lib has 2 values + // Categories without values are skipped + expect(screen.queryByText('field')).not.toBeInTheDocument(); + }); + + it('calls onCategorySelect when a category is clicked', () => { + renderMenu(); + fireEvent.click(screen.getByText('library')); + expect(callbacks.onCategorySelect).toHaveBeenCalledWith('lib'); + }); + + it('marks the highlighted item as selected', () => { + renderMenu({ highlightedIndex: 1 }); + expect(screen.getByText('library').closest('li')).toHaveClass('Mui-selected'); + expect(screen.getByText('language').closest('li')).not.toHaveClass('Mui-selected'); + }); + + it('shows category values with a back item when a category is selected', () => { + renderMenu({ selectedCategory: 'lib' }); + expect(screen.getByText('← library')).toBeInTheDocument(); + expect(screen.getByText('matplotlib')).toBeInTheDocument(); + expect(screen.getByText('(100)')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('matplotlib')); + expect(callbacks.onValueSelect).toHaveBeenCalledWith('lib', 'matplotlib'); + + fireEvent.click(screen.getByText('← library')); + expect(callbacks.onBack).toHaveBeenCalled(); + }); + + it('splits search results into exact and fuzzy sections', () => { + const searchResults: SearchResult[] = [ + { category: 'plot', value: 'scatter', count: 10, matchType: 'exact' }, + { category: 'lib', value: 'seaborn', count: 80, matchType: 'fuzzy' }, + ]; + const dropdownItems: DropdownItem[] = searchResults.map(r => ({ type: 'value', ...r })); + renderMenu({ hasQuery: true, searchResults, dropdownItems }); + + expect(screen.getByText('scatter')).toBeInTheDocument(); + expect(screen.getByText('seaborn')).toBeInTheDocument(); + expect(screen.getByText('fuzzy')).toBeInTheDocument(); // divider label + + fireEvent.click(screen.getByText('seaborn')); + expect(callbacks.onValueSelect).toHaveBeenCalledWith('lib', 'seaborn'); + }); + + it('shows the empty state when a query has no results', () => { + renderMenu({ hasQuery: true, searchResults: [], dropdownItems: [] }); + expect(screen.getByLabelText('No matches')).toBeInTheDocument(); + }); +}); diff --git a/app/src/sections/plots-gallery/FilterBar/FilterMenu.tsx b/app/src/sections/plots-gallery/FilterBar/FilterMenu.tsx new file mode 100644 index 0000000000..9d613c9cbc --- /dev/null +++ b/app/src/sections/plots-gallery/FilterBar/FilterMenu.tsx @@ -0,0 +1,252 @@ +import Divider from '@mui/material/Divider'; +import ListItemText from '@mui/material/ListItemText'; +import Menu from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import Tooltip from '@mui/material/Tooltip'; +import Typography from '@mui/material/Typography'; + +import { fontSize, semanticColors, typography } from 'src/theme'; +import type { ActiveFilters, FilterCategory, FilterCounts } from 'src/types'; +import { FILTER_CATEGORIES, FILTER_LABELS, FILTER_TOOLTIPS } from 'src/types'; +import { getAvailableValues, type SearchResult } from 'src/utils'; + +/** A keyboard-navigable entry of the dropdown: a category or a concrete value. */ +export type DropdownItem = + | { type: 'category'; category: FilterCategory } + | ({ type: 'value' } & SearchResult); + +export interface FilterMenuProps { + anchorEl: HTMLElement | null; + open: boolean; + filterCounts: FilterCounts | null; + activeFilters: ActiveFilters; + specTitles: Record; // Mapping spec_id -> title for tooltips + selectedCategory: FilterCategory | null; + hasQuery: boolean; + searchResults: SearchResult[]; + dropdownItems: DropdownItem[]; // Visible items, for keyboard-highlight mapping + highlightedIndex: number; + onClose: () => void; + onCategorySelect: (category: FilterCategory) => void; + onValueSelect: (category: FilterCategory, value: string) => void; + onBack: () => void; // Back from a selected category to the category list +} + +/** + * Category dropdown of the filter search: lists categories, drills into a + * category's values, and shows exact/fuzzy search results. + */ +export function FilterMenu({ + anchorEl, + open, + filterCounts, + activeFilters, + specTitles, + selectedCategory, + hasQuery, + searchResults, + dropdownItems, + highlightedIndex, + onClose, + onCategorySelect, + onValueSelect, + onBack, +}: FilterMenuProps) { + return ( + + {!selectedCategory && !hasQuery + ? // Show categories + FILTER_CATEGORIES.map(category => { + const availableVals = getAvailableValues(filterCounts, activeFilters, category); + if (availableVals.length === 0) return null; + // Calculate actual index among visible items + const visibleIdx = dropdownItems.findIndex( + item => item.type === 'category' && item.category === category + ); + return ( + + onCategorySelect(category)} + selected={visibleIdx === highlightedIndex} + sx={{ fontFamily: typography.fontFamily }} + > + + + + ); + }) + : // Show search results or category values + [ + ...(selectedCategory + ? [ + + ← {FILTER_LABELS[selectedCategory]} + , + , + ] + : []), + ...(() => { + // Use searchResults if query exists, otherwise show all available values for selected category + const resultsToShow: SearchResult[] = hasQuery + ? searchResults + : selectedCategory + ? getAvailableValues(filterCounts, activeFilters, selectedCategory).map( + ([value, count]) => ({ + category: selectedCategory, + value, + count, + matchType: 'exact' as const, + }) + ) + : []; + + if (resultsToShow.length > 0) { + // Split results into exact and fuzzy matches + const exactResults = resultsToShow.filter(r => r.matchType === 'exact'); + const fuzzyResults = resultsToShow.filter(r => r.matchType === 'fuzzy'); + + const renderMenuItem = (result: SearchResult, idx: number) => { + const { category, value, count } = result; + const specTitle = category === 'spec' ? specTitles[value] : undefined; + const menuItem = ( + onValueSelect(category, value)} + selected={idx === highlightedIndex} + sx={{ fontFamily: typography.fontFamily }} + > + + + ({count}) + + + ); + return specTitle ? ( + + {menuItem} + + ) : ( + menuItem + ); + }; + + const items: React.ReactNode[] = []; + // Add exact matches + exactResults.forEach((result, i) => { + items.push(renderMenuItem(result, i)); + }); + // Add fuzzy label/divider if there are fuzzy results + if (fuzzyResults.length > 0) { + items.push( + + + fuzzy + + + ); + } + // Add fuzzy matches + fuzzyResults.forEach((result, i) => { + items.push(renderMenuItem(result, exactResults.length + i)); + }); + return items; + } else { + return [ + + + results.empty() + + , + ]; + } + })(), + ]} + + ); +} diff --git a/app/src/sections/plots-gallery/FilterBar/FilterSearch.test.tsx b/app/src/sections/plots-gallery/FilterBar/FilterSearch.test.tsx new file mode 100644 index 0000000000..d9c0667793 --- /dev/null +++ b/app/src/sections/plots-gallery/FilterBar/FilterSearch.test.tsx @@ -0,0 +1,147 @@ +import { fireEvent } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { FilterSearch } from 'src/sections/plots-gallery/FilterBar/FilterSearch'; +import { render, screen, waitFor } from 'src/test-utils'; +import type { FilterCounts } from 'src/types'; + +// Uses the real src/utils (fuse.js included) so keyboard navigation walks real results. +const filterCounts: FilterCounts = { + lang: { python: 180 }, + lib: { matplotlib: 100, seaborn: 80 }, + spec: {}, + plot: { scatter: 10, bar: 5 }, + data: {}, + dom: {}, + feat: {}, + dep: {}, + tech: {}, + pat: {}, + prep: {}, + style: {}, +}; + +const defaultProps = { + activeFilters: [], + filterCounts, + specTitles: {}, + onAddFilter: vi.fn(), + onTrackEvent: vi.fn(), +}; + +function getSearchInput() { + return screen.getByLabelText('Search filters'); +} + +describe('FilterSearch', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('opens the category dropdown on focus', async () => { + render(); + fireEvent.focus(getSearchInput()); + // Categories with available values: lang, lib, plot + expect(await screen.findByText('language')).toBeInTheDocument(); + expect(screen.getByText('library')).toBeInTheDocument(); + expect(screen.getByText('type')).toBeInTheDocument(); + }); + + it('drills into a category with ArrowDown + Enter', async () => { + render(); + const input = getSearchInput(); + fireEvent.focus(input); + await screen.findByText('language'); + + fireEvent.keyDown(input, { key: 'ArrowDown' }); + fireEvent.keyDown(input, { key: 'Enter' }); + + // First category (lang) is selected: back item + its values appear + expect(await screen.findByText('← language')).toBeInTheDocument(); + expect(screen.getByText('python')).toBeInTheDocument(); + expect(defaultProps.onAddFilter).not.toHaveBeenCalled(); + }); + + it('selects the first search result on Enter without highlight', async () => { + render(); + const input = getSearchInput(); + fireEvent.focus(input); + fireEvent.change(input, { target: { value: 'scatter' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + + expect(defaultProps.onAddFilter).toHaveBeenCalledWith('plot', 'scatter'); + expect(defaultProps.onTrackEvent).toHaveBeenCalledWith('search', { + query: 'scatter', + category: 'plot', + }); + }); + + it('closes the dropdown on Escape', async () => { + render(); + const input = getSearchInput(); + fireEvent.focus(input); + expect(await screen.findByText('library')).toBeInTheDocument(); + + fireEvent.keyDown(input, { key: 'Escape' }); + await waitFor(() => { + expect(screen.queryByText('library')).not.toBeInTheDocument(); + }); + }); + + it('adds a filter when a value is clicked, without a search track event', async () => { + render(); + fireEvent.focus(getSearchInput()); + fireEvent.click(await screen.findByText('library')); + fireEvent.click(await screen.findByText('matplotlib')); + + expect(defaultProps.onAddFilter).toHaveBeenCalledWith('lib', 'matplotlib'); + expect(defaultProps.onTrackEvent).not.toHaveBeenCalledWith('search', expect.anything()); + }); + + it('collapses to an icon button when filters are active', () => { + render( + + ); + expect(screen.getByRole('button', { name: 'Open filter search' })).toBeInTheDocument(); + }); + + it('expands the collapsed search via keyboard', async () => { + render( + + ); + const button = screen.getByRole('button', { name: 'Open filter search' }); + fireEvent.keyDown(button, { key: 'Enter' }); + + expect(await screen.findByText('library')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Open filter search' })).not.toBeInTheDocument(); + }); + + it('hides the search input entirely when 5 filter groups are active', () => { + const five = ['matplotlib', 'seaborn', 'plotly', 'bokeh', 'altair'].map(value => ({ + category: 'lib' as const, + values: [value], + })); + render(); + expect(screen.queryByLabelText('Search filters')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Open filter search' })).not.toBeInTheDocument(); + }); + + it('tracks search_no_results for queries without matches (debounced)', async () => { + render(); + const input = getSearchInput(); + fireEvent.focus(input); + fireEvent.change(input, { target: { value: 'zzzzzz' } }); + + await waitFor(() => { + expect(defaultProps.onTrackEvent).toHaveBeenCalledWith('search_no_results', { + query: 'zzzzzz', + }); + }); + }); +}); diff --git a/app/src/sections/plots-gallery/FilterBar/FilterSearch.tsx b/app/src/sections/plots-gallery/FilterBar/FilterSearch.tsx new file mode 100644 index 0000000000..5d733c6f3b --- /dev/null +++ b/app/src/sections/plots-gallery/FilterBar/FilterSearch.tsx @@ -0,0 +1,357 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import CloseIcon from '@mui/icons-material/Close'; +import SearchIcon from '@mui/icons-material/Search'; +import Box from '@mui/material/Box'; +import InputBase from '@mui/material/InputBase'; +import Tooltip from '@mui/material/Tooltip'; + +import { type DropdownItem, FilterMenu } from 'src/sections/plots-gallery/FilterBar/FilterMenu'; +import { colors, fontSize, semanticColors, typography } from 'src/theme'; +import type { ActiveFilters, FilterCategory, FilterCounts } from 'src/types'; +import { FILTER_CATEGORIES, FILTER_LABELS } from 'src/types'; +import { getAvailableValues, getSearchResults } from 'src/utils'; + +export interface FilterSearchProps { + activeFilters: ActiveFilters; + filterCounts: FilterCounts | null; // Contextual counts (for AND additions) + specTitles: Record; // Mapping spec_id -> title for search/tooltips + searchInputRef?: React.RefObject; + onAddFilter: (category: FilterCategory, value: string) => void; + onTrackEvent: (event: string, props?: Record) => void; +} + +/** + * Filter search input: expand/collapse behavior, fuse.js-backed search via + * getSearchResults, keyboard navigation, and the category dropdown (FilterMenu). + */ +export function FilterSearch({ + activeFilters, + filterCounts, + specTitles, + searchInputRef, + onAddFilter, + onTrackEvent, +}: FilterSearchProps) { + // Search/dropdown state + const [searchQuery, setSearchQuery] = useState(''); + const [dropdownAnchor, setDropdownAnchor] = useState(null); + const [selectedCategory, setSelectedCategory] = useState(null); + const [isSearchManuallyExpanded, setIsSearchManuallyExpanded] = useState(false); + const searchContainerRef = useRef(null); + const localInputRef = useRef(null); + const inputRef = searchInputRef || localInputRef; + + // Search is expanded when: no filters OR manually expanded + const isSearchExpanded = activeFilters.length === 0 || isSearchManuallyExpanded; + + // Dropdown keyboard navigation + const [highlightedIndex, setHighlightedIndex] = useState(-1); + + // Expand and open dropdown + const handleSearchExpand = useCallback(() => { + setIsSearchManuallyExpanded(true); + setDropdownAnchor(searchContainerRef.current); + setTimeout(() => inputRef.current?.focus(), 0); + }, [inputRef]); + + // Collapse when empty and loses focus (only if there are filters) + const handleSearchBlur = useCallback(() => { + // Delay to allow click events on dropdown to fire first + setTimeout(() => { + if (!searchQuery && !selectedCategory && !dropdownAnchor && activeFilters.length > 0) { + setIsSearchManuallyExpanded(false); + } + }, 200); + }, [searchQuery, selectedCategory, dropdownAnchor, activeFilters.length]); + + // Close dropdown and collapse if empty + const handleDropdownClose = useCallback(() => { + setDropdownAnchor(null); + setSelectedCategory(null); + setSearchQuery(''); + setHighlightedIndex(-1); + setIsSearchManuallyExpanded(false); + }, []); + + // Select category from dropdown + const handleCategorySelect = useCallback( + (category: FilterCategory) => { + setSelectedCategory(category); + setSearchQuery(''); + setHighlightedIndex(-1); + setTimeout(() => inputRef.current?.focus(), 50); + }, + [inputRef] + ); + + // Select value (add new filter group) + const handleValueSelect = useCallback( + (category: FilterCategory, value: string) => { + onAddFilter(category, value); + // Track search if query was used (filter changes tracked via pageview) + if (searchQuery.trim()) { + onTrackEvent('search', { query: searchQuery.trim(), category }); + } + setSelectedCategory(null); + setSearchQuery(''); + setHighlightedIndex(-1); + // Keep expanded and focused for next filter + setIsSearchManuallyExpanded(true); + setTimeout(() => { + setDropdownAnchor(searchContainerRef.current); + inputRef.current?.focus(); + }, 50); + }, + [onAddFilter, onTrackEvent, searchQuery, inputRef] + ); + + // Back from a selected category to the category list + const handleBackToCategories = useCallback(() => { + setSelectedCategory(null); + setSearchQuery(''); + }, []); + + // Memoize search results to avoid recalculating on every render + const searchResults = useMemo( + () => getSearchResults(filterCounts, activeFilters, searchQuery, selectedCategory, specTitles), + [filterCounts, activeFilters, searchQuery, selectedCategory, specTitles] + ); + + // Track searches with no results (debounced, to discover missing specs) + const lastTrackedQueryRef = useRef(''); + useEffect(() => { + const query = searchQuery.trim(); + // Only track if: query >= 2 chars, no results, not already tracked this query + if (query.length >= 2 && searchResults.length === 0 && query !== lastTrackedQueryRef.current) { + const timer = setTimeout(() => { + onTrackEvent('search_no_results', { query }); + lastTrackedQueryRef.current = query; + }, 200); + return () => clearTimeout(timer); + } + }, [searchQuery, searchResults.length, onTrackEvent]); + + // Reset tracked query when dropdown closes + useEffect(() => { + if (!dropdownAnchor) { + lastTrackedQueryRef.current = ''; + } + }, [dropdownAnchor]); + + // Only open if anchor is valid and in document + const isDropdownOpen = Boolean(dropdownAnchor) && document.body.contains(dropdownAnchor); + const hasQuery = searchQuery.trim().length > 0; + const maxFiltersReached = activeFilters.length >= 5; + + // Get dropdown items for keyboard navigation + const getDropdownItems = useCallback((): DropdownItem[] => { + if (!selectedCategory && !hasQuery) { + // Categories list + return FILTER_CATEGORIES.filter(cat => { + const available = getAvailableValues(filterCounts, activeFilters, cat); + return available.length > 0; + }).map(cat => ({ type: 'category' as const, category: cat })); + } else if (selectedCategory && !hasQuery) { + // Category selected but no query - show all available values for this category + const available = getAvailableValues(filterCounts, activeFilters, selectedCategory); + return available.map(([value, count]) => ({ + type: 'value' as const, + category: selectedCategory, + value, + count, + matchType: 'exact' as const, + })); + } else { + // Search results (with query) + return searchResults.map(r => ({ type: 'value' as const, ...r })); + } + }, [selectedCategory, hasQuery, filterCounts, activeFilters, searchResults]); + + const dropdownItems = getDropdownItems(); + + // Handle keyboard navigation + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === 'ArrowDown') { + event.preventDefault(); + setHighlightedIndex(prev => Math.min(prev + 1, dropdownItems.length - 1)); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + setHighlightedIndex(prev => Math.max(prev - 1, -1)); + } else if (event.key === 'Enter') { + event.preventDefault(); + const item = dropdownItems[highlightedIndex] || dropdownItems[0]; + if (item) { + if (item.type === 'category') { + handleCategorySelect(item.category); + setHighlightedIndex(-1); + } else { + handleValueSelect(item.category, item.value); + } + } + } else if (event.key === 'Escape') { + handleDropdownClose(); + inputRef.current?.blur(); + } + }, + [ + dropdownItems, + highlightedIndex, + handleCategorySelect, + handleValueSelect, + handleDropdownClose, + inputRef, + ] + ); + + return ( + <> + {/* Search input - collapsed icon or expanded input */} + {!maxFiltersReached && ( + { + if (!isSearchExpanded && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault(); + handleSearchExpand(); + } + }} + sx={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: 0.5, + px: isSearchExpanded ? 1.5 : 0, + height: 32, + width: isSearchExpanded ? { xs: 220, sm: 220, md: 'auto' } : 32, + minWidth: isSearchExpanded ? { xs: 220, sm: 220, md: 200 } : 32, + border: isSearchExpanded ? '1px dashed var(--ink-muted)' : 'none', + borderRadius: '16px', + bgcolor: isDropdownOpen ? 'var(--bg-elevated)' : 'transparent', + cursor: 'pointer', + transition: 'all 0.2s ease', + '&:hover': { + borderColor: isSearchExpanded ? colors.primary : undefined, + bgcolor: isSearchExpanded ? 'var(--bg-elevated)' : undefined, + }, + '&:hover .search-icon': { + color: colors.primary, + }, + '&:focus': isSearchExpanded + ? {} + : { outline: `2px solid ${colors.primary}`, outlineOffset: 2 }, + }} + > + + + + + { + setSearchQuery(e.target.value); + setHighlightedIndex(-1); + if (!dropdownAnchor) { + setDropdownAnchor(searchContainerRef.current); + } + }} + onFocus={() => { + if (!isSearchManuallyExpanded && activeFilters.length > 0) { + setIsSearchManuallyExpanded(true); + } + setDropdownAnchor(searchContainerRef.current); + setHighlightedIndex(-1); + }} + onBlur={handleSearchBlur} + onKeyDown={handleKeyDown} + sx={{ + flex: isSearchExpanded ? 1 : 0, + width: isSearchExpanded ? 'auto' : 0, + opacity: isSearchExpanded ? 1 : 0, + transition: 'all 0.2s ease', + fontFamily: typography.fontFamily, + fontSize: fontSize.base, + color: 'var(--ink)', + '& input': { + padding: 0, + fontFamily: typography.fontFamily, + fontSize: fontSize.base, + color: 'var(--ink)', + '&::placeholder': { + color: semanticColors.mutedText, + opacity: 1, + }, + }, + }} + /> + {isSearchExpanded && (searchQuery || selectedCategory) && ( + { + e.stopPropagation(); + setSearchQuery(''); + setSelectedCategory(null); + }} + sx={{ + color: 'var(--ink-muted)', + fontSize: fontSize.lg, + cursor: 'pointer', + '&:hover': { color: 'var(--ink-soft)' }, + }} + /> + )} + + )} + + {/* Dropdown menu */} + + + ); +} diff --git a/app/src/sections/plots-gallery/FilterBar/FilterSizeToggle.test.tsx b/app/src/sections/plots-gallery/FilterBar/FilterSizeToggle.test.tsx new file mode 100644 index 0000000000..115c3db42e --- /dev/null +++ b/app/src/sections/plots-gallery/FilterBar/FilterSizeToggle.test.tsx @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { FilterSizeToggle } from 'src/sections/plots-gallery/FilterBar/FilterSizeToggle'; +import { render, screen, userEvent } from 'src/test-utils'; + +describe('FilterSizeToggle', () => { + it('renders the grid size toggle for the current size', () => { + render( + + ); + expect(screen.getByRole('button', { name: 'Switch to compact view' })).toBeInTheDocument(); + }); + + it('passes size changes and analytics through to the handlers', async () => { + const onImageSizeChange = vi.fn(); + const onTrackEvent = vi.fn(); + render( + + ); + + await userEvent.click(screen.getByRole('button', { name: 'Switch to compact view' })); + + expect(onImageSizeChange).toHaveBeenCalledWith('compact'); + expect(onTrackEvent).toHaveBeenCalledWith('grid_resize', { size: 'compact' }); + }); +}); diff --git a/app/src/sections/plots-gallery/FilterBar/FilterSizeToggle.tsx b/app/src/sections/plots-gallery/FilterBar/FilterSizeToggle.tsx new file mode 100644 index 0000000000..b6a7723c5e --- /dev/null +++ b/app/src/sections/plots-gallery/FilterBar/FilterSizeToggle.tsx @@ -0,0 +1,26 @@ +import type { ImageSize } from 'src/constants'; +import { ToolbarActions } from 'src/sections/plots-gallery/ToolbarActions'; + +export interface FilterSizeToggleProps { + imageSize: ImageSize; + onImageSizeChange: (size: ImageSize) => void; + onTrackEvent: (event: string, props?: Record) => void; +} + +/** + * Image-size toggle wrapper used on both the desktop (absolute right) and + * mobile (counter row) placements of the FilterBar. + */ +export function FilterSizeToggle({ + imageSize, + onImageSizeChange, + onTrackEvent, +}: FilterSizeToggleProps) { + return ( + + ); +} diff --git a/app/src/sections/plots-gallery/FilterBar.test.tsx b/app/src/sections/plots-gallery/FilterBar/index.test.tsx similarity index 100% rename from app/src/sections/plots-gallery/FilterBar.test.tsx rename to app/src/sections/plots-gallery/FilterBar/index.test.tsx diff --git a/app/src/sections/plots-gallery/FilterBar/index.tsx b/app/src/sections/plots-gallery/FilterBar/index.tsx new file mode 100644 index 0000000000..f8cda95f18 --- /dev/null +++ b/app/src/sections/plots-gallery/FilterBar/index.tsx @@ -0,0 +1,268 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import Box from '@mui/material/Box'; +import { useTheme } from '@mui/material/styles'; +import Typography from '@mui/material/Typography'; +import useMediaQuery from '@mui/material/useMediaQuery'; + +import type { ImageSize } from 'src/constants'; +import { + FilterChips, + type RandomAnimation, +} from 'src/sections/plots-gallery/FilterBar/FilterChips'; +import { FilterSearch } from 'src/sections/plots-gallery/FilterBar/FilterSearch'; +import { FilterSizeToggle } from 'src/sections/plots-gallery/FilterBar/FilterSizeToggle'; +import { fontSize, semanticColors, typography } from 'src/theme'; +import type { ActiveFilters, FilterCategory, FilterCounts } from 'src/types'; + +export interface FilterBarProps { + activeFilters: ActiveFilters; + filterCounts: FilterCounts | null; // Contextual counts (for AND additions) + orCounts: Record[]; // Per-group counts for OR additions + specTitles: Record; // Mapping spec_id -> title for search/tooltips + currentTotal: number; // Total number of filtered images + displayedCount: number; // Currently displayed images + randomAnimation: RandomAnimation | null; + searchInputRef?: React.RefObject; + imageSize: ImageSize; + onImageSizeChange: (size: ImageSize) => void; + onAddFilter: (category: FilterCategory, value: string) => void; + onAddValueToGroup: (groupIndex: number, value: string) => void; + onRemoveFilter: (groupIndex: number, value: string) => void; + onRemoveGroup: (groupIndex: number) => void; + onTrackEvent: (event: string, props?: Record) => void; +} + +/** + * Sticky filter toolbar of the plots gallery: scroll-progress counter, + * active-filter chips, filter search with category dropdown, and grid-size toggle. + */ +export function FilterBar({ + activeFilters, + filterCounts, + orCounts, + specTitles, + currentTotal, + displayedCount, + randomAnimation, + searchInputRef, + imageSize, + onImageSizeChange, + onAddFilter, + onAddValueToGroup, + onRemoveFilter, + onRemoveGroup, + onTrackEvent, +}: FilterBarProps) { + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('md')); + + // Scroll percentage and sticky detection + const [scrollPercent, setScrollPercent] = useState(0); + const [isSticky, setIsSticky] = useState(false); + const filterBarRef = useRef(null); + + useEffect(() => { + const calculatePercent = () => { + const scrollY = window.scrollY; + const docHeight = document.documentElement.scrollHeight; + const windowHeight = window.innerHeight; + + // Estimate total height based on ratio of loaded vs total plots + const loadRatio = displayedCount > 0 && currentTotal > 0 ? currentTotal / displayedCount : 1; + const estimatedTotalHeight = (docHeight - windowHeight) * loadRatio; + + const percent = Math.round((scrollY / estimatedTotalHeight) * 100); + setScrollPercent(Math.min(100, Math.max(0, percent || 0))); + + // Detect if bar is in sticky mode (scrolled past threshold). + // On /plots the masthead+navbar flow with content (~120px), so the FilterBar + // starts sticking shortly after that. 60px is a conservative trigger. + setIsSticky(scrollY > 60); + }; + calculatePercent(); + // passive: true — scroll handler doesn't preventDefault, so let the + // browser scroll without waiting for our handler to ack. Matters on + // /plots, the busiest scroll path in the app. + window.addEventListener('scroll', calculatePercent, { passive: true }); + const resizeObserver = new ResizeObserver(calculatePercent); + resizeObserver.observe(document.body); + return () => { + window.removeEventListener('scroll', calculatePercent); + resizeObserver.disconnect(); + }; + }, [displayedCount, currentTotal]); + + // Chip menu state + const [chipMenuAnchor, setChipMenuAnchor] = useState(null); + const [activeGroupIndex, setActiveGroupIndex] = useState(null); + + // Chip click - open chip menu + const handleChipClick = useCallback( + (event: React.MouseEvent, groupIndex: number) => { + setChipMenuAnchor(event.currentTarget); + setActiveGroupIndex(groupIndex); + }, + [] + ); + + // Close chip menu + const handleChipMenuClose = useCallback(() => { + setChipMenuAnchor(null); + setActiveGroupIndex(null); + }, []); + + // Remove single value from group + const handleRemoveValue = useCallback( + (value: string) => { + if (activeGroupIndex !== null) { + onRemoveFilter(activeGroupIndex, value); + } + setChipMenuAnchor(null); + setActiveGroupIndex(null); + }, + [activeGroupIndex, onRemoveFilter] + ); + + // Remove entire group + const handleRemoveActiveGroup = useCallback(() => { + if (activeGroupIndex !== null) { + onRemoveGroup(activeGroupIndex); + } + setChipMenuAnchor(null); + setActiveGroupIndex(null); + }, [activeGroupIndex, onRemoveGroup]); + + // Add value to existing group (OR) + const handleAddValueToActiveGroup = useCallback( + (value: string) => { + if (activeGroupIndex !== null) { + onAddValueToGroup(activeGroupIndex, value); + } + setChipMenuAnchor(null); + setActiveGroupIndex(null); + }, + [activeGroupIndex, onAddValueToGroup] + ); + + return ( + + {/* Filter chips row */} + + {/* Progress counter - absolute left (desktop only) */} + {!isMobile && currentTotal > 0 && ( + + {scrollPercent}% · {currentTotal} + + )} + {/* Toolbar actions - absolute right (desktop only) */} + {!isMobile && ( + + + + )} + {/* Active filter chips + chip action menu */} + + {/* Search input + category dropdown */} + + + + {/* Counter and toggle row (mobile only) */} + {isMobile && ( + + {currentTotal > 0 ? ( + + {scrollPercent}% · {currentTotal} + + ) : ( + + )} + + + )} + + ); +}