feat(design-files): add filter-by-kind dropdown to the design files viewer (#1990)

* feat(design-files): add filter-by-kind dropdown

The design files viewer already supports grouping and sorting by kind,
but on projects with many files of mixed kinds you still see everything
in one list. Adds a small dropdown next to the group-by toggle that
filters the visible files to one or more selected kinds.

Behavior:
- Dropdown only appears when the project has at least two distinct
  kinds; on a single-kind project the control would be noise.
- Multi-select via checkboxes. Empty set means no filter (show all).
- Each row shows the kind glyph, label, and the per-kind file count.
- Trigger label collapses sensibly: "Filter by kind" when none selected,
  the single kind's label when one selected, "{n} kinds" when many.
- Filter applies upstream of sort/group/page, so pagination ranges and
  the "1-N of M" counter reflect the filtered total.
- Page resets to 0 on filter change so the user doesn't land on an
  empty page.
- Selected kinds that disappear from the file list (after a delete or
  refresh) are pruned automatically — a stale filter cannot silently
  hide everything.
- Popover closes on outside click and Escape.

i18n keys: designFiles.filterBy, designFiles.filterClear,
designFiles.filterCount. All non-English locales inherit via the
existing `...en` spread pattern; zh-CN is the one fully-explicit
dictionary and gets translated values inline.

No behavior change to the group-by toggle, sort, pagination, selection,
or any other existing control. Filtering is layered alongside.

* fix(design-files): prune selection when filter hides selected rows

Codex review flagged that the filter-by-kind change only reset
pagination, not selection. If a user selected files, then narrowed the
filter to a kind that excludes some of them, the now-hidden rows
remained in `selected`. Bulk delete / download still operated on the
full set, so the user could destructively act on rows they could no
longer see — especially risky for the delete path.

Add a useEffect that prunes `selected` to the filtered set whenever
`filteredFiles` or `kindFilter` changes. The empty-filter branch is a
no-op so clearing the filter doesn't disturb any pre-existing
selection — only narrowing the filter prunes.
This commit is contained in:
Abe Pena 2026-05-17 23:34:13 -04:00 committed by GitHub
parent 4b6ddda052
commit fa9f1d37be
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 349 additions and 24 deletions

View file

@ -100,16 +100,55 @@ export function DesignFilesPanel({
>(new Set());
const [renaming, setRenaming] = useState<{ name: string; draft: string; saving: boolean } | null>(null);
const [dayBoundary, setDayBoundary] = useState(() => Date.now());
const [kindFilter, setKindFilter] = useState<Set<ProjectFileKind>>(() => new Set());
const [filterMenuOpen, setFilterMenuOpen] = useState(false);
const filterMenuRef = useRef<HTMLDivElement | null>(null);
const kindCounts = useMemo(() => {
const counts = new Map<ProjectFileKind, number>();
for (const f of files) counts.set(f.kind, (counts.get(f.kind) ?? 0) + 1);
return counts;
}, [files]);
const availableKinds = useMemo(
() =>
Array.from(kindCounts.keys()).sort(
(a, b) => kindSortPriority(a) - kindSortPriority(b),
),
[kindCounts],
);
// Drop any selected-filter kinds that no longer appear in the file list
// (e.g. after a delete leaves the kind empty). Keeps the filter UI honest
// and prevents a stale filter from silently hiding everything.
useEffect(() => {
setKindFilter((prev) => {
if (prev.size === 0) return prev;
const present = new Set(availableKinds);
const next = new Set<ProjectFileKind>();
let changed = false;
for (const k of prev) {
if (present.has(k)) next.add(k);
else changed = true;
}
return changed ? next : prev;
});
}, [availableKinds]);
const filteredFiles = useMemo(() => {
if (kindFilter.size === 0) return files;
return files.filter((f) => kindFilter.has(f.kind));
}, [files, kindFilter]);
const sortedFiles = useMemo(() => {
return [...files].sort((a, b) => {
return [...filteredFiles].sort((a, b) => {
let cmp: number;
if (sortKey === 'name') cmp = a.name.localeCompare(b.name);
else if (sortKey === 'kind') cmp = kindSortPriority(a.kind) - kindSortPriority(b.kind);
else cmp = a.mtime - b.mtime;
return sortDir === 'asc' ? cmp : -cmp;
});
}, [files, sortKey, sortDir]);
}, [filteredFiles, sortKey, sortDir]);
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState<number | 'all'>(30);
@ -151,6 +190,63 @@ export function DesignFilesPanel({
setPage(0);
}, [pageSize]);
// Reset to the first page when the filter changes — the previous page
// index may no longer exist (or may now sit past the new totalPages).
useEffect(() => {
setPage(0);
}, [kindFilter]);
// Drop any selected files that fall outside the active filter. Without
// this, bulk delete / download would silently operate on rows the user
// can no longer see — particularly dangerous for destructive deletes.
// We keep the empty-filter branch a no-op so clearing the filter
// doesn't disturb existing selections.
useEffect(() => {
if (kindFilter.size === 0) return;
setSelected((prev) => {
if (prev.size === 0) return prev;
const visible = new Set(filteredFiles.map((f) => f.name));
const next = new Set<string>();
let changed = false;
for (const name of prev) {
if (visible.has(name)) next.add(name);
else changed = true;
}
return changed ? next : prev;
});
}, [filteredFiles, kindFilter]);
// Outside-click + escape to close the filter popover. Stops short of a
// full focus trap because the popover hosts only checkboxes plus a
// small clear button; the existing tab order through them is fine.
useEffect(() => {
if (!filterMenuOpen) return;
const onMouseDown = (event: MouseEvent) => {
const root = filterMenuRef.current;
if (root && event.target instanceof Node && !root.contains(event.target)) {
setFilterMenuOpen(false);
}
};
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') setFilterMenuOpen(false);
};
window.addEventListener('mousedown', onMouseDown);
window.addEventListener('keydown', onKey);
return () => {
window.removeEventListener('mousedown', onMouseDown);
window.removeEventListener('keydown', onKey);
};
}, [filterMenuOpen]);
function toggleKindFilter(kind: ProjectFileKind): void {
setKindFilter((prev) => {
const next = new Set(prev);
if (next.has(kind)) next.delete(kind);
else next.add(kind);
return next;
});
}
useEffect(() => {
if (Number.isFinite(totalPages)) setPage((p) => Math.min(p, totalPages - 1));
}, [totalPages]);
@ -695,28 +791,105 @@ export function DesignFilesPanel({
) : (
<>
{files.length > 0 ? (
<div
className="df-group-toggle"
role="group"
aria-label={t('designFiles.groupBy')}
>
<span>{t('designFiles.groupBy')}</span>
<button
type="button"
className={groupMode === 'kind' ? 'active' : ''}
aria-pressed={groupMode === 'kind'}
onClick={() => setGroupMode('kind')}
<div className="df-toolbar-row">
<div
className="df-group-toggle"
role="group"
aria-label={t('designFiles.groupBy')}
>
{t('designFiles.groupByKind')}
</button>
<button
type="button"
className={groupMode === 'modified' ? 'active' : ''}
aria-pressed={groupMode === 'modified'}
onClick={() => setGroupMode('modified')}
>
{t('designFiles.groupByModified')}
</button>
<span>{t('designFiles.groupBy')}</span>
<button
type="button"
className={groupMode === 'kind' ? 'active' : ''}
aria-pressed={groupMode === 'kind'}
onClick={() => setGroupMode('kind')}
>
{t('designFiles.groupByKind')}
</button>
<button
type="button"
className={groupMode === 'modified' ? 'active' : ''}
aria-pressed={groupMode === 'modified'}
onClick={() => setGroupMode('modified')}
>
{t('designFiles.groupByModified')}
</button>
</div>
{availableKinds.length > 1 ? (
<div className="df-kind-filter" ref={filterMenuRef}>
<button
type="button"
className={`df-kind-filter-trigger${kindFilter.size > 0 ? ' active' : ''}`}
aria-haspopup="dialog"
aria-expanded={filterMenuOpen}
aria-label={t('designFiles.filterBy')}
onClick={() => setFilterMenuOpen((open) => !open)}
>
<Icon name="sliders" size={13} />
<span className="df-kind-filter-trigger-label">
{kindFilter.size === 0
? t('designFiles.filterBy')
: kindFilter.size === 1
? kindLabel(Array.from(kindFilter)[0]!, t)
: t('designFiles.filterCount', { n: kindFilter.size })}
</span>
{kindFilter.size > 0 ? (
<span
className="df-kind-filter-count"
aria-hidden
>
{kindFilter.size}
</span>
) : null}
</button>
{filterMenuOpen ? (
<div
className="df-kind-filter-popover"
role="dialog"
aria-label={t('designFiles.filterBy')}
>
<div className="df-kind-filter-header">
<span>{t('designFiles.filterBy')}</span>
{kindFilter.size > 0 ? (
<button
type="button"
className="df-kind-filter-clear"
onClick={() => setKindFilter(new Set())}
>
{t('designFiles.filterClear')}
</button>
) : null}
</div>
<ul className="df-kind-filter-list">
{availableKinds.map((kind) => {
const checked = kindFilter.has(kind);
const count = kindCounts.get(kind) ?? 0;
return (
<li key={kind}>
<label className="df-kind-filter-item">
<input
type="checkbox"
checked={checked}
onChange={() => toggleKindFilter(kind)}
/>
<span className="df-kind-filter-glyph" aria-hidden>
{kindGlyph(kind)}
</span>
<span className="df-kind-filter-label">
{kindLabel(kind, t)}
</span>
<span className="df-kind-filter-itemcount">
{count}
</span>
</label>
</li>
);
})}
</ul>
</div>
) : null}
</div>
) : null}
</div>
) : null}
{liveArtifacts.length > 0 ? (

View file

@ -833,6 +833,9 @@ export const en: Dict = {
'designFiles.groupBy': 'Group by',
'designFiles.groupByKind': 'Kind',
'designFiles.groupByModified': 'Modified',
'designFiles.filterBy': 'Filter by kind',
'designFiles.filterClear': 'Clear',
'designFiles.filterCount': '{n} kinds',
'designFiles.expandGroup': 'Expand',
'designFiles.collapseGroup': 'Collapse',
'designFiles.sectionPages': 'Pages',

View file

@ -824,6 +824,9 @@ export const zhCN: Dict = {
'designFiles.groupBy': '分组方式',
'designFiles.groupByKind': '类型',
'designFiles.groupByModified': '修改时间',
'designFiles.filterBy': '按类型筛选',
'designFiles.filterClear': '清除',
'designFiles.filterCount': '{n} 种类型',
'designFiles.expandGroup': '展开',
'designFiles.collapseGroup': '折叠',
'designFiles.sectionPages': '页面',

View file

@ -1102,6 +1102,9 @@ export interface Dict {
'designFiles.groupBy': string;
'designFiles.groupByKind': string;
'designFiles.groupByModified': string;
'designFiles.filterBy': string;
'designFiles.filterClear': string;
'designFiles.filterCount': string;
'designFiles.expandGroup': string;
'designFiles.collapseGroup': string;
'designFiles.sectionPages': string;

View file

@ -9369,8 +9369,14 @@ button.connector-action.is-loading {
display: flex;
flex-direction: column;
}
.df-group-toggle {
.df-toolbar-row {
margin: 0 20px 8px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.df-group-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
@ -9402,6 +9408,143 @@ button.connector-action.is-loading {
border-color: var(--border);
color: var(--text);
}
/* Filter-by-kind dropdown. Lives in the .df-toolbar-row alongside the
group-by toggle. Trigger is a button with a count badge when active;
popover is a single-column checkbox list of available kinds. */
.df-kind-filter {
position: relative;
display: inline-flex;
}
.df-kind-filter-trigger {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 30px;
padding: 3px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-panel);
color: var(--text-muted);
font-size: 11.5px;
cursor: pointer;
}
.df-kind-filter-trigger:hover {
background: var(--bg-subtle);
color: var(--text);
}
.df-kind-filter-trigger.active {
background: var(--bg-subtle);
border-color: var(--selected, var(--border));
color: var(--text);
}
.df-kind-filter-trigger-label {
white-space: nowrap;
}
.df-kind-filter-count {
min-width: 18px;
height: 18px;
padding: 0 5px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 9px;
background: var(--selected, var(--accent, var(--text)));
color: var(--bg-panel);
font-size: 10.5px;
font-weight: 600;
line-height: 1;
}
.df-kind-filter-popover {
position: absolute;
top: calc(100% + 6px);
left: 0;
width: max(220px, 100%);
max-height: min(360px, calc(100vh - 96px));
padding: 6px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-panel);
box-shadow: var(--shadow-lg);
z-index: 130;
display: flex;
flex-direction: column;
gap: 4px;
overflow: hidden;
}
.df-kind-filter-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 4px 8px 6px;
border-bottom: 1px solid var(--border);
color: var(--text-faint);
font-size: 10.5px;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.df-kind-filter-clear {
border: 0;
background: transparent;
color: var(--text-muted);
font-size: 11px;
text-transform: none;
letter-spacing: 0;
cursor: pointer;
padding: 2px 4px;
border-radius: 4px;
}
.df-kind-filter-clear:hover {
background: var(--bg-subtle);
color: var(--text);
}
.df-kind-filter-list {
list-style: none;
margin: 0;
padding: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.df-kind-filter-list li + li {
border-top: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
}
.df-kind-filter-item {
display: grid;
grid-template-columns: 18px 22px 1fr auto;
align-items: center;
gap: 8px;
padding: 6px 8px;
cursor: pointer;
color: var(--text);
font-size: 12px;
border-radius: 4px;
}
.df-kind-filter-item:hover {
background: var(--bg-subtle);
}
.df-kind-filter-item input[type='checkbox'] {
margin: 0;
cursor: pointer;
}
.df-kind-filter-glyph {
color: var(--text-faint);
font-size: 11px;
text-align: center;
font-family: var(--mono, ui-monospace, monospace);
}
.df-kind-filter-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.df-kind-filter-itemcount {
color: var(--text-faint);
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.df-section { display: flex; flex-direction: column; gap: 0; }
.df-section + .df-section { margin-top: 6px; }
.df-section-label {