Files
backlog/web/filter-panel.jsx
2026-05-05 18:42:06 -04:00

197 lines
6.4 KiB
React
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Filter sidebar: status, priority, due, tags + scope.
function FilterPanel({ filters, setFilters, tagsList, counts }) {
const toggle = (key, val) => {
const cur = new Set(filters[key] || []);
cur.has(val) ? cur.delete(val) : cur.add(val);
setFilters({ ...filters, [key]: [...cur] });
};
const isActive = (key, val) => (filters[key] || []).includes(val);
return (
<aside className="filter-panel">
<div className="filter-section">
<div className="filter-label">Status</div>
<div className="filter-list">
{STATUSES.map(s => (
<button
key={s.key}
className={`filter-chip ${isActive("statuses", s.key) ? "active" : ""}`}
onClick={() => toggle("statuses", s.key)}
>
<StatusIcon status={s.key} size={13}/>
<span className="filter-name">{s.label}</span>
<span className="filter-count">{counts[s.key] || 0}</span>
</button>
))}
</div>
</div>
<div className="filter-section">
<div className="filter-label">Priority</div>
<div className="filter-list filter-list-row">
{PRIORITIES.map(p => (
<button
key={p}
className={`filter-chip mini ${isActive("priorities", p) ? "active" : ""} pri-${p}`}
onClick={() => toggle("priorities", p)}
>
{p === "P0" && <span className="p0-dot" />}
{p}
</button>
))}
</div>
</div>
<div className="filter-section">
<div className="filter-label">Due</div>
<div className="filter-list">
{[
{ k: "overdue", n: "Overdue" },
{ k: "today", n: "Today" },
{ k: "week", n: "This week" },
{ k: "month", n: "This month" }
].map(o => (
<button
key={o.k}
className={`filter-chip ${filters.dueRange === o.k ? "active" : ""}`}
onClick={() => setFilters({ ...filters, dueRange: filters.dueRange === o.k ? null : o.k })}
>
<span className="filter-name">{o.n}</span>
</button>
))}
</div>
</div>
<TagFilter
tagsList={tagsList}
selected={filters.tags || []}
onToggle={(t) => toggle("tags", t)}
/>
<div className="filter-section">
<div className="filter-label">Scope</div>
<div className="filter-list">
{[
{ k: "all", n: "All items" },
{ k: "top", n: "Top-level only" }
].map(o => (
<button
key={o.k}
className={`filter-chip ${(filters.scope || "all") === o.k ? "active" : ""}`}
onClick={() => setFilters({ ...filters, scope: o.k })}
>
<span className="filter-name">{o.n}</span>
</button>
))}
</div>
</div>
{(filters.statuses?.length || filters.priorities?.length || filters.tags?.length || filters.dueRange) && (
<button className="clear-btn"
onClick={() => setFilters({ statuses: [], priorities: [], tags: [], dueRange: null, scope: filters.scope, text: filters.text })}>
Clear filters
</button>
)}
</aside>
);
}
// ---- Tag filter with autocomplete (handles hundreds of tags) ----
function TagFilter({ tagsList, selected, onToggle }) {
const [q, setQ] = React.useState("");
const [focused, setFocused] = React.useState(false);
const inputRef = React.useRef(null);
if (!tagsList.length) return null;
const ql = q.trim().toLowerCase();
const filtered = ql
? tagsList.filter(t => t.toLowerCase().includes(ql)).slice(0, 50)
: tagsList.slice(0, 12); // top dozen by frequency when not searching
const exact = ql && tagsList.includes(ql);
const TOTAL = tagsList.length;
return (
<div className="filter-section">
<div className="filter-label">
Tags <span className="tag-total mono">{TOTAL}</span>
</div>
{/* Selected tags chips (always visible if any) */}
{selected.length > 0 && (
<div className="tag-selected">
{selected.map(t => (
<button key={t} className="tag-chip active" onClick={() => onToggle(t)} title="Remove">
#{t}
<span className="tag-x">×</span>
</button>
))}
</div>
)}
{/* Search field */}
<div className={`tag-search ${focused ? "focused" : ""}`}>
<Icon name="search" size={12}/>
<input
ref={inputRef}
className="tag-search-input"
placeholder={`Search ${TOTAL} tags…`}
value={q}
onChange={(e) => setQ(e.target.value)}
onFocus={() => setFocused(true)}
onBlur={() => setTimeout(() => setFocused(false), 120)}
onKeyDown={(e) => {
if (e.key === "Enter" && filtered.length > 0) {
onToggle(filtered[0]);
setQ("");
} else if (e.key === "Escape") {
setQ("");
e.currentTarget.blur();
}
}}
/>
{q && <button className="tag-search-clear" onClick={() => { setQ(""); inputRef.current?.focus(); }}>×</button>}
</div>
{/* Results / suggestions */}
<div className="tag-results">
{filtered.length === 0 ? (
<div className="tag-empty">No tags match "{q}"</div>
) : (
<>
{filtered.map(t => {
const isSelected = selected.includes(t);
return (
<button
key={t}
className={`tag-chip ${isSelected ? "active" : ""}`}
onClick={() => onToggle(t)}
title={isSelected ? "Remove from filter" : "Add to filter"}
>
{ql && !isSelected ? <TagHighlight text={t} match={ql}/> : `#${t}`}
</button>
);
})}
{!ql && tagsList.length > 12 && (
<span className="tag-more mono">+{tagsList.length - 12} more — type to find</span>
)}
</>
)}
</div>
</div>
);
}
function TagHighlight({ text, match }) {
const i = text.toLowerCase().indexOf(match);
if (i < 0) return `#${text}`;
return (
<>
#{text.slice(0, i)}<span className="hl">{text.slice(i, i + match.length)}</span>{text.slice(i + match.length)}
</>
);
}
window.FilterPanel = FilterPanel;