From fafccca50f3c1d2ea943f3aca0859c77659aaacc Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Wed, 22 Jul 2026 23:04:03 +0200 Subject: [PATCH] Redesign grid filter menu --- docs/spec/07-implementation-phases-todo.md | 3 +- src/app/globals.css | 98 +++++++++++-- .../components/circuit-tree-editor.tsx | 131 ++++++++++++++---- 3 files changed, 187 insertions(+), 45 deletions(-) diff --git a/docs/spec/07-implementation-phases-todo.md b/docs/spec/07-implementation-phases-todo.md index 45700ad..275be93 100644 --- a/docs/spec/07-implementation-phases-todo.md +++ b/docs/spec/07-implementation-phases-todo.md @@ -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: diff --git a/src/app/globals.css b/src/app/globals.css index dae8ba5..80b8a73 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; } diff --git a/src/frontend/components/circuit-tree-editor.tsx b/src/frontend/components/circuit-tree-editor.tsx index 6fae96c..e0d19b0 100644 --- a/src/frontend/components/circuit-tree-editor.tsx +++ b/src/frontend/components/circuit-tree-editor.tsx @@ -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>>({}); const [openFilterColumn, setOpenFilterColumn] = useState(null); + const [filterDraftValues, setFilterDraftValues] = useState([]); + const [filterValueSearch, setFilterValueSearch] = useState(""); const [visibleColumnKeys, setVisibleColumnKeys] = useState(defaultVisibleColumnKeys); const [columnOrder, setColumnOrder] = useState(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 (
- Active view + Sort and filters {sortState ? (
{openFilterColumn === column.key ? (
-
- Select one or more values. A circuit is shown when its block contains any selected value. -
-
- - {(columnFilters[column.key]?.length ?? 0) > 0 - ? `${columnFilters[column.key]?.length} selected` - : "Showing all"} - -
+
+ Select the values that should remain visible. Changes take effect when you apply them. +
+ setFilterValueSearch(event.target.value)} + placeholder="Search values..." + aria-label={`Search values for ${column.label}`} + autoFocus + /> +
+ + + {filterDraftValues.length} / {(distinctValuesByColumn[column.key] ?? []).length} +
- {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 ( ); })} + {(distinctValuesByColumn[column.key] ?? []).filter((value) => + value.toLocaleLowerCase("de-DE").includes(filterValueSearch.trim().toLocaleLowerCase("de-DE")) + ).length === 0 ? ( +
No matching values.
+ ) : null} +
+
+ {filterDraftValues.length === 0 ? ( + Select at least one value. + ) : ( + + )} +
+ + +
) : null}