Redesign grid filter menu

This commit is contained in:
2026-07-22 23:04:03 +02:00
parent d1615bcfee
commit fafccca50f
3 changed files with 187 additions and 45 deletions
+2 -1
View File
@@ -288,7 +288,8 @@ Implemented layout foundation:
- programmatic grid focus preserves the viewport during undo/redo reloads
- wide tables no longer expand the surrounding browser layout
- active sorts and filters are summarized above the grid and can be removed individually or reset together
- column filter buttons show their selected-value count and filter menus explain their matching behavior
- column filter buttons show their selected-value count
- filter menus provide value search, select-all/select-none controls and explicit cancel/apply actions
Acceptance criteria:
+83 -15
View File
@@ -326,21 +326,57 @@ body {
border: 1px solid #cfd7e5;
background: #fff;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
width: 220px;
max-height: 260px;
overflow: auto;
padding: 0.35rem;
width: 300px;
padding: 0.55rem;
text-align: left;
}
.tree-grid .header-filter-actions {
.tree-grid .header-filter-title-row,
.tree-grid .header-filter-selection-actions,
.tree-grid .header-filter-footer,
.tree-grid .header-filter-footer-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.4rem;
margin-bottom: 0.35rem;
}
.tree-grid .header-filter-title-row {
margin-bottom: 0.25rem;
font-size: 0.82rem;
}
.tree-grid .header-filter-close {
border: 0;
background: transparent;
color: #4b5563;
padding: 0.05rem 0.2rem;
font-size: 1rem;
line-height: 1;
cursor: pointer;
}
.tree-grid .header-filter-selection-actions {
justify-content: flex-start;
margin: 0.4rem 0 0.3rem;
font-size: 0.72rem;
}
.tree-grid .header-filter-selection-actions span {
margin-left: auto;
color: #4b5563;
}
.tree-grid .header-filter-selection-actions button,
.tree-grid .header-filter-footer button {
border: 1px solid #c4cddc;
background: #fff;
border-radius: 3px;
padding: 0.18rem 0.35rem;
font-size: 0.72rem;
cursor: pointer;
}
.tree-grid .header-filter-explanation {
margin-bottom: 0.4rem;
color: #4b5563;
@@ -349,22 +385,23 @@ body {
white-space: normal;
}
.tree-grid .header-filter-clear {
.tree-grid .header-filter-search {
width: 100%;
border: 1px solid #c4cddc;
background: #fff;
border-radius: 3px;
font-size: 0.72rem;
padding: 0.12rem 0.3rem;
}
.tree-grid .header-filter-clear:disabled {
opacity: 0.45;
border-radius: 4px;
padding: 0.3rem 0.4rem;
font-size: 0.76rem;
}
.tree-grid .header-filter-values {
display: flex;
flex-direction: column;
gap: 0.15rem;
max-height: 210px;
overflow: auto;
border: 1px solid #e1e6ef;
border-radius: 4px;
padding: 0.25rem;
}
.tree-grid .header-filter-item {
@@ -375,6 +412,37 @@ body {
font-size: 0.75rem;
}
.tree-grid .header-filter-empty {
padding: 0.45rem 0.25rem;
color: #6b7280;
font-size: 0.74rem;
}
.tree-grid .header-filter-footer {
margin-top: 0.45rem;
}
.tree-grid .header-filter-footer-actions {
margin-left: auto;
}
.tree-grid .header-filter-footer button.primary {
border-color: #2563eb;
background: #2563eb;
color: #fff;
}
.tree-grid .header-filter-footer button:disabled {
opacity: 0.45;
cursor: default;
}
.tree-grid .header-filter-warning {
color: #9a3412;
font-size: 0.7rem;
white-space: normal;
}
.tree-grid .num {
text-align: right;
}
+102 -29
View File
@@ -197,6 +197,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const [sortState, setSortState] = useState<{ key: CellKey; direction: SortDirection } | null>(null);
const [columnFilters, setColumnFilters] = useState<Partial<Record<CellKey, string[]>>>({});
const [openFilterColumn, setOpenFilterColumn] = useState<CellKey | null>(null);
const [filterDraftValues, setFilterDraftValues] = useState<string[]>([]);
const [filterValueSearch, setFilterValueSearch] = useState("");
const [visibleColumnKeys, setVisibleColumnKeys] = useState<CellKey[]>(defaultVisibleColumnKeys);
const [columnOrder, setColumnOrder] = useState<CellKey[]>(allColumns.map((column) => column.key));
const [isColumnMenuOpen, setIsColumnMenuOpen] = useState(false);
@@ -351,10 +353,45 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
setOpenFilterColumn((current) => (current === key ? null : current));
}
function closeColumnFilterMenu() {
setOpenFilterColumn(null);
setFilterDraftValues([]);
setFilterValueSearch("");
}
function toggleColumnFilterMenu(key: CellKey) {
if (openFilterColumn === key) {
closeColumnFilterMenu();
return;
}
const availableValues = distinctValuesByColumn[key] ?? [];
const activeValues = columnFilters[key] ?? [];
setFilterDraftValues(activeValues.length > 0 ? activeValues : availableValues);
setFilterValueSearch("");
setOpenFilterColumn(key);
}
function applyColumnFilter(key: CellKey) {
const availableValues = distinctValuesByColumn[key] ?? [];
const availableSet = new Set(availableValues);
const selectedValues = [...new Set(filterDraftValues)].filter((value) => availableSet.has(value));
if (selectedValues.length === 0) {
return;
}
if (selectedValues.length === availableValues.length) {
clearColumnFilter(key);
} else {
setColumnFilters((current) => ({ ...current, [key]: selectedValues }));
}
closeColumnFilterMenu();
}
function clearSortAndFilters() {
setSortState(null);
setColumnFilters({});
setOpenFilterColumn(null);
closeColumnFilterMenu();
}
function renderActiveViewSummary() {
@@ -368,7 +405,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
return (
<div className="active-view-summary" aria-label="Active sort and filters">
<span className="active-view-label">Active view</span>
<span className="active-view-label">Sort and filters</span>
{sortState ? (
<button
type="button"
@@ -2650,7 +2687,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
<button
type="button"
className={`header-filter-btn ${(columnFilters[column.key]?.length ?? 0) > 0 ? "active" : ""}`}
onClick={() => setOpenFilterColumn((current) => (current === column.key ? null : column.key))}
onClick={() => toggleColumnFilterMenu(column.key)}
aria-pressed={(columnFilters[column.key]?.length ?? 0) > 0}
title={
(columnFilters[column.key]?.length ?? 0) > 0
@@ -2663,47 +2700,83 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
</div>
{openFilterColumn === column.key ? (
<div className="header-filter-menu">
<div className="header-filter-explanation">
Select one or more values. A circuit is shown when its block contains any selected value.
</div>
<div className="header-filter-actions">
<span>
{(columnFilters[column.key]?.length ?? 0) > 0
? `${columnFilters[column.key]?.length} selected`
: "Showing all"}
</span>
<button
type="button"
className="header-filter-clear"
onClick={() => clearColumnFilter(column.key)}
disabled={(columnFilters[column.key]?.length ?? 0) === 0}
>
Show all
<div className="header-filter-title-row">
<strong>Filter {column.label}</strong>
<button type="button" className="header-filter-close" onClick={closeColumnFilterMenu} aria-label="Close filter">
×
</button>
</div>
<div className="header-filter-explanation">
Select the values that should remain visible. Changes take effect when you apply them.
</div>
<input
type="search"
className="header-filter-search"
value={filterValueSearch}
onChange={(event) => setFilterValueSearch(event.target.value)}
placeholder="Search values..."
aria-label={`Search values for ${column.label}`}
autoFocus
/>
<div className="header-filter-selection-actions">
<button
type="button"
onClick={() => setFilterDraftValues(distinctValuesByColumn[column.key] ?? [])}
>
Select all
</button>
<button type="button" onClick={() => setFilterDraftValues([])}>
Select none
</button>
<span>{filterDraftValues.length} / {(distinctValuesByColumn[column.key] ?? []).length}</span>
</div>
<div className="header-filter-values">
{distinctValuesByColumn[column.key].map((value) => {
const selected = columnFilters[column.key] ?? [];
const checked = selected.includes(value);
{(distinctValuesByColumn[column.key] ?? [])
.filter((value) => value.toLocaleLowerCase("de-DE").includes(filterValueSearch.trim().toLocaleLowerCase("de-DE")))
.map((value) => {
const checked = filterDraftValues.includes(value);
return (
<label key={`${column.key}-${value}`} className="header-filter-item">
<input
type="checkbox"
checked={checked}
onChange={(event) => {
setColumnFilters((current) => {
const previous = current[column.key] ?? [];
const next = event.target.checked
? [...previous, value]
: previous.filter((entry) => entry !== value);
return { ...current, [column.key]: next };
});
setFilterDraftValues((current) =>
event.target.checked
? [...current, value]
: current.filter((entry) => entry !== value)
);
}}
/>
<span>{value}</span>
</label>
);
})}
{(distinctValuesByColumn[column.key] ?? []).filter((value) =>
value.toLocaleLowerCase("de-DE").includes(filterValueSearch.trim().toLocaleLowerCase("de-DE"))
).length === 0 ? (
<div className="header-filter-empty">No matching values.</div>
) : null}
</div>
<div className="header-filter-footer">
{filterDraftValues.length === 0 ? (
<span className="header-filter-warning">Select at least one value.</span>
) : (
<span />
)}
<div className="header-filter-footer-actions">
<button type="button" onClick={closeColumnFilterMenu}>Cancel</button>
<button
type="button"
className="primary"
onClick={() => applyColumnFilter(column.key)}
disabled={filterDraftValues.length === 0}
>
{filterDraftValues.length === (distinctValuesByColumn[column.key] ?? []).length
? "Show all"
: "Apply filter"}
</button>
</div>
</div>
</div>
) : null}