Redesign grid filter menu
This commit is contained in:
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user