diff --git a/src/components/FilterButton.tsx b/src/components/FilterButton.tsx index c21e079f..a463701b 100644 --- a/src/components/FilterButton.tsx +++ b/src/components/FilterButton.tsx @@ -26,14 +26,21 @@ const FilterButton: React.FC = ({ onClick={onClick} fullWidth={fullWidth} sx={{ + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + gap: 0.75, + lineHeight: 1.2, color: isActive ? activeTextColor : (t) => t.palette.text.secondary, backgroundColor: isActive ? 'surface.light' : 'surface.transparent', borderRadius: '6px', - px: { xs: 1, sm: 1.5 }, - py: { xs: 0.5, sm: 0.75 }, + px: { xs: 1.25, sm: 1.5 }, + py: { xs: 0.5, sm: 0.65 }, + minHeight: 32, minWidth: fullWidth ? 0 : 'auto', textTransform: 'none', - fontSize: { xs: '0.65rem', sm: '0.75rem' }, + fontSize: { xs: '0.7rem', sm: '0.75rem' }, + fontWeight: isActive ? 600 : 500, border: isActive ? `1px solid ${color}` : '1px solid transparent', whiteSpace: 'nowrap', '&:hover': { @@ -41,14 +48,19 @@ const FilterButton: React.FC = ({ }, }} > - {label}{' '} + + {label} + {count !== undefined && ( {count} diff --git a/src/components/repositories/RepositoryIssuesTable.tsx b/src/components/repositories/RepositoryIssuesTable.tsx index 40641a0c..f2bd1c92 100644 --- a/src/components/repositories/RepositoryIssuesTable.tsx +++ b/src/components/repositories/RepositoryIssuesTable.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useSessionStoredState } from '../../hooks/useSessionStoredState'; import { Box, @@ -9,10 +9,15 @@ import { Typography, alpha, useTheme, + TextField, + InputAdornment, + IconButton, + useMediaQuery, } from '@mui/material'; import CheckCircleIcon from '@mui/icons-material/CheckCircle'; import RadioButtonUncheckedIcon from '@mui/icons-material/RadioButtonUnchecked'; import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; +import SearchIcon from '@mui/icons-material/Search'; import { useRepositoryIssues, useRepoIssues, @@ -31,6 +36,7 @@ import { } from '../../utils/issueStatus'; import { STATUS_COLORS, TEXT_OPACITY, scrollbarSx } from '../../theme'; import FilterButton from '../FilterButton'; +import { ClearSearchAdornment } from '../common/ClearSearchAdornment'; interface RepositoryIssuesTableProps { repositoryFullName: string; @@ -49,6 +55,25 @@ type RepoIssuesFilter = 'all' | 'open' | 'closed'; const isRepoIssuesFilter = (v: unknown): v is RepoIssuesFilter => v === 'all' || v === 'open' || v === 'closed'; +function issueMatchesSearch( + issue: RepositoryIssue, + searchQuery: string, +): boolean { + const q = searchQuery.trim().toLowerCase(); + if (!q) return true; + const title = getLowerText(issue.title); + if (title.includes(q)) return true; + const author = (issue.authorLogin || issue.author || '').toLowerCase(); + if (author.includes(q)) return true; + const numStr = String(issue.number); + if (numStr.includes(q)) return true; + if (q.startsWith('#')) { + const rest = q.slice(1).trim(); + if (rest && numStr.includes(rest)) return true; + } + return false; +} + const RepositoryIssuesTable: React.FC = ({ repositoryFullName, }) => { @@ -62,15 +87,25 @@ const RepositoryIssuesTable: React.FC = ({ ); const [sortKey, setSortKey] = useState('number'); const [sortDirection, setSortDirection] = useState('desc'); + const [searchQuery, setSearchQuery] = useState(''); + const [mobileSearchOpen, setMobileSearchOpen] = useState(false); + const isSmDown = useMediaQuery(theme.breakpoints.down('sm')); + + useEffect(() => { + setSearchQuery(''); + setMobileSearchOpen(false); + }, [repositoryFullName]); const counts = useMemo(() => { if (!issues) return { total: 0, open: 0, closed: 0 }; + const match = (issue: RepositoryIssue) => + issueMatchesSearch(issue, searchQuery); return { - total: issues.length, - open: issues.filter((issue) => !issue.closedAt).length, - closed: issues.filter((issue) => issue.closedAt).length, + total: issues.filter(match).length, + open: issues.filter((issue) => !issue.closedAt).filter(match).length, + closed: issues.filter((issue) => issue.closedAt).filter(match).length, }; - }, [issues]); + }, [issues, searchQuery]); const filteredIssues = useMemo(() => { if (!issues) return []; @@ -79,13 +114,19 @@ const RepositoryIssuesTable: React.FC = ({ return issues; }, [issues, filter]); + const searchFilteredIssues = useMemo(() => { + return filteredIssues.filter((issue) => + issueMatchesSearch(issue, searchQuery), + ); + }, [filteredIssues, searchQuery]); + const sortedIssues = useMemo(() => { const directionFactor = sortDirection === 'asc' ? 1 : -1; const collator = new Intl.Collator(undefined, { sensitivity: 'base', numeric: true, }); - const decorated = filteredIssues.map((issue) => { + const decorated = searchFilteredIssues.map((issue) => { let value: number | string; switch (sortKey) { case 'number': @@ -121,7 +162,7 @@ const RepositoryIssuesTable: React.FC = ({ ); }); return decorated.map((item) => item.issue); - }, [filteredIssues, sortKey, sortDirection]); + }, [searchFilteredIssues, sortKey, sortDirection]); const handleSort = useCallback( (key: SortKey) => { @@ -282,22 +323,125 @@ const RepositoryIssuesTable: React.FC = ({ const headerToolbar = ( - + + + Issues ({sortedIssues.length}) + + {isSmDown && !mobileSearchOpen ? ( + setMobileSearchOpen(true)} + sx={{ + flexShrink: 0, + border: '1px solid', + borderColor: 'border.light', + borderRadius: 2, + color: 'text.secondary', + }} + > + + + ) : null} + + {(!isSmDown || mobileSearchOpen) && ( + setSearchQuery(e.target.value)} + onKeyDown={(e) => { + if ( + e.key === 'Escape' && + !searchQuery.trim() && + isSmDown && + mobileSearchOpen + ) { + setMobileSearchOpen(false); + } + }} + onBlur={() => { + if (isSmDown && !searchQuery.trim()) { + setMobileSearchOpen(false); + } + }} + autoFocus={Boolean(isSmDown && mobileSearchOpen)} + InputProps={{ + startAdornment: ( + + + + ), + endAdornment: ( + setSearchQuery('')} + /> + ), + }} + sx={{ + width: { xs: '100%', sm: 280 }, + maxWidth: { xs: '100%', sm: 360 }, + flexShrink: 0, + alignSelf: { xs: 'stretch', sm: 'auto' }, + '& .MuiOutlinedInput-root': { + fontSize: '0.8rem', + backgroundColor: 'surface.subtle', + borderRadius: 2, + '& fieldset': { borderColor: 'border.light' }, + '&:hover fieldset': { borderColor: 'border.medium' }, + '&.Mui-focused fieldset': { borderColor: 'primary.main' }, + }, + }} + /> + )} + + - Issues ({sortedIssues.length}) - - = ({ fontSize: '0.9rem', }} > - No issues found + {searchQuery.trim() && + sortedIssues.length === 0 && + filteredIssues.length > 0 + ? 'No issues match your search.' + : 'No issues found'} }