From 5a7657396204d272d03f37020a197e87e56d4441 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 22 Jun 2026 18:48:27 -0700 Subject: [PATCH 1/8] docs: spec for built-in filter header menu (sub-project 2/3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-column funnel → popover (operator select + typed value control by filterType), live-apply with multi-part gating, onFiltersChange, on-by-default for filterable columns. React component + @pretable/ui vanilla CSS; RTL tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-19-filter-header-menu-design.md | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-19-filter-header-menu-design.md diff --git a/docs/superpowers/specs/2026-06-19-filter-header-menu-design.md b/docs/superpowers/specs/2026-06-19-filter-header-menu-design.md new file mode 100644 index 0000000..d9493fa --- /dev/null +++ b/docs/superpowers/specs/2026-06-19-filter-header-menu-design.md @@ -0,0 +1,153 @@ +# Filter header menu — design (sub-project 2 of 3) + +**Date:** 2026-06-19 +**Branch:** `claude/filter-ui` (off `main` after #180) +**Status:** approved (pending written-spec review) + +## Context + +Sub-project 1 (PR #180) shipped the headless operator filter model: `setColumnFilter`, +`distinctColumnValues`, typed `ColumnFilter`/`FilterOperator`/`FilterType`/`FilterOption` +(exported from `@pretable/core` + `@pretable/react`), per-column `filterType`/`filterOptions`/ +`filterable`. There is **no built-in filter UI** — the surface renders no filter affordance. + +This is **sub-project 2 of 3**: a built-in, per-column **header filter menu** (funnel → +popover) in `@pretable/react`, styled in `@pretable/ui`. Sub-project 3 (later) does the +filtering docs page, hero adoption, and e2e. + +## Goal + +Give every `filterable` column a header funnel that opens a popover to edit that column's +filter — operator dropdown + a typed value control that matches the column's `filterType` +— applying live to the engine and firing `onFiltersChange`. Works uncontrolled (default) +or controlled via the existing `state.filters`. + +## Decisions (locked in brainstorm) + +- **Apply timing:** live. Text inputs debounced ~200ms; dropdown/number/checkbox/date + apply immediately. No Apply button — a per-column **Clear** action only. + Multi-part operators (`between`/`dateBetween`) **gate**: apply only when both bounds are + valid; otherwise the column's filter is cleared (a half-range never blanks the grid). +- **Funnel visibility:** appears on header hover/focus; **permanently shown + accented** + on any column with an active filter. +- **On by default:** the funnel renders for every column with `filterable !== false` + (mirrors the resize/reorder precedent). Opt out per-column with `filterable: false`. +- **Deferred to sub-project 3:** hero adoption, docs page, Playwright e2e. RTL is the + coverage here. + +## Non-goals + +- OR / boolean trees, a chip/filter bar, a global "clear all" toolbar (no toolbar exists). +- `packages/*` Tailwind (vanilla CSS only). No new runtime deps (no Floating UI etc.). +- Changes to the engine filter model (done in sub-project 1). +- A surface-level master on/off prop (per-column `filterable` is the control). + +## Architecture + +New, all under `packages/react/src/filter-menu/`: + +- `filter-operators.ts` — **pure, no React.** The brains: + - `operatorsForType(filterType): FilterOperator[]` — which operators a column offers + (text/number/date/enum + shared isEmpty/isNotEmpty), in display order. + - `OPERATOR_LABELS: Record` — human labels ("contains", + "is between", "is any of", "is empty", …). + - `operatorValueShape(op): "none" | "single" | "range" | "set"` — drives which value + control renders. + - `isComplete(filterType, op, draft): boolean` — is the draft value usable (gating for + live-apply; e.g. `between` needs two parseable numbers). + - `toColumnFilter(filterType, op, draft): ColumnFilter | null` — build the engine value + (`null` when incomplete → clears). Parses number/date strings to the engine shape. + - `fromColumnFilter(filter): draft` — hydrate the popover from an existing filter + (controlled or re-open). + - Fully unit-tested in isolation. +- `FunnelButton.tsx` — the header affordance. ` + ); +} +``` + +(The `stopPropagation` on pointer-down/click is belt-and-suspenders even though the funnel is a sibling of — not a child of — the header sort button.) + +- [ ] **Step 2: Write `useFilterPopover.ts`** — open-state + position-from-rect + dismiss. + +```tsx +// packages/react/src/filter-menu/useFilterPopover.ts +import { useCallback, useEffect, useState } from "react"; + +export interface PopoverState { + columnId: string; + rect: DOMRect; +} + +export function useFilterPopover() { + const [openState, setOpenState] = useState(null); + + const toggle = useCallback((columnId: string, anchorEl: HTMLElement | null) => { + setOpenState((prev) => { + if (prev?.columnId === columnId) return null; + const rect = anchorEl?.getBoundingClientRect(); + return rect ? { columnId, rect } : null; + }); + }, []); + + const close = useCallback(() => setOpenState(null), []); + + useEffect(() => { + if (!openState) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") close(); + }; + // Close on scroll/resize so the popover never floats away from its anchor. + const onViewportChange = () => close(); + document.addEventListener("keydown", onKey); + window.addEventListener("resize", onViewportChange); + window.addEventListener("scroll", onViewportChange, true); + return () => { + document.removeEventListener("keydown", onKey); + window.removeEventListener("resize", onViewportChange); + window.removeEventListener("scroll", onViewportChange, true); + }; + }, [openState, close]); + + return { openState, toggle, close }; +} + +/** Fixed-position style from the anchor rect, flipped near the right/bottom edges. */ +export function popoverStyle(rect: DOMRect): React.CSSProperties { + const WIDTH = 240; + const MARGIN = 8; + const vw = typeof window !== "undefined" ? window.innerWidth : 1024; + const left = Math.min(rect.left, vw - WIDTH - MARGIN); + return { + position: "fixed", + top: rect.bottom + 4, + left: Math.max(MARGIN, left), + width: WIDTH, + zIndex: 50, + }; +} +``` + +- [ ] **Step 3: Write `FilterMenu.tsx`** — the dialog. Renders operator `` (`type="date"` when `filterType==="date"`, else `type="text"`/`inputMode="numeric"` for number) bound to `draft.text`. + - `range` → two inputs bound to `draft.min`/`draft.max` (date or number). + - `set` → a checkbox list from `options`; toggling updates `draft.selected`. +- Container: `
`. On mount, focus the operator ` onOperatorChange(e.target.value as FilterOperator)} + > + {operators.map((op) => ( + + ))} + + + {shape === "single" ? ( + + inputType === "date" + ? pushNow({ ...draft, text: e.target.value }) + : pushDebounced({ ...draft, text: e.target.value }) + } + /> + ) : null} + + {shape === "range" ? ( + <> + + inputType === "date" + ? pushNow({ ...draft, min: e.target.value }) + : pushDebounced({ ...draft, min: e.target.value }) + } + /> + + inputType === "date" + ? pushNow({ ...draft, max: e.target.value }) + : pushDebounced({ ...draft, max: e.target.value }) + } + /> + + ) : null} + + {shape === "set" ? ( +
+ {options.map((opt) => { + const checked = (draft.selected ?? []).includes(opt.value); + return ( + + ); + })} +
+ ) : null} + + +
+ ); +} diff --git a/packages/react/src/filter-menu/FunnelButton.tsx b/packages/react/src/filter-menu/FunnelButton.tsx new file mode 100644 index 0000000..68cbbec --- /dev/null +++ b/packages/react/src/filter-menu/FunnelButton.tsx @@ -0,0 +1,46 @@ +// packages/react/src/filter-menu/FunnelButton.tsx +import type { CSSProperties } from "react"; + +export function FunnelButton({ + columnId, + label, + active, + open, + style, + onToggle, +}: { + columnId: string; + label: string; + active: boolean; + open: boolean; + style?: CSSProperties; + onToggle: (columnId: string, anchor: HTMLElement) => void; +}) { + return ( + + ); +} diff --git a/packages/react/src/filter-menu/index.ts b/packages/react/src/filter-menu/index.ts new file mode 100644 index 0000000..0921495 --- /dev/null +++ b/packages/react/src/filter-menu/index.ts @@ -0,0 +1,3 @@ +export { FunnelButton } from "./FunnelButton"; +export { FilterMenu } from "./FilterMenu"; +export { useFilterPopover, popoverStyle } from "./useFilterPopover"; diff --git a/packages/react/src/filter-menu/useFilterPopover.ts b/packages/react/src/filter-menu/useFilterPopover.ts new file mode 100644 index 0000000..07e837d --- /dev/null +++ b/packages/react/src/filter-menu/useFilterPopover.ts @@ -0,0 +1,58 @@ +// packages/react/src/filter-menu/useFilterPopover.ts +import { useCallback, useEffect, useState, type CSSProperties } from "react"; + +export interface PopoverState { + columnId: string; + rect: DOMRect; +} + +export function useFilterPopover() { + const [openState, setOpenState] = useState(null); + + const toggle = useCallback( + (columnId: string, anchorEl: HTMLElement | null) => { + setOpenState((prev) => { + if (prev?.columnId === columnId) return null; + const rect = anchorEl?.getBoundingClientRect(); + return rect ? { columnId, rect } : null; + }); + }, + [], + ); + + const close = useCallback(() => setOpenState(null), []); + + useEffect(() => { + if (!openState) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") close(); + }; + // Close on scroll/resize so the popover never floats away from its anchor. + const onViewportChange = () => close(); + document.addEventListener("keydown", onKey); + window.addEventListener("resize", onViewportChange); + window.addEventListener("scroll", onViewportChange, true); + return () => { + document.removeEventListener("keydown", onKey); + window.removeEventListener("resize", onViewportChange); + window.removeEventListener("scroll", onViewportChange, true); + }; + }, [openState, close]); + + return { openState, toggle, close }; +} + +/** Fixed-position style from the anchor rect, flipped near the right/bottom edges. */ +export function popoverStyle(rect: DOMRect): CSSProperties { + const WIDTH = 240; + const MARGIN = 8; + const vw = typeof window !== "undefined" ? window.innerWidth : 1024; + const left = Math.min(rect.left, vw - WIDTH - MARGIN); + return { + position: "fixed", + top: rect.bottom + 4, + left: Math.max(MARGIN, left), + width: WIDTH, + zIndex: 50, + }; +} From 6a78dec9b80386414e882b58f3d48dbb97a946f9 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 27 Jun 2026 15:27:31 -0700 Subject: [PATCH 5/8] feat(react,ui): built-in header filter menu wired into the surface Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/filter-menu-surface.test.tsx | 290 ++++++++++++++++++ packages/react/src/filter-menu/FilterMenu.tsx | 6 +- packages/react/src/pretable-surface.tsx | 82 +++++ packages/ui/src/grid.css | 71 +++++ 4 files changed, 448 insertions(+), 1 deletion(-) create mode 100644 packages/react/src/__tests__/filter-menu-surface.test.tsx diff --git a/packages/react/src/__tests__/filter-menu-surface.test.tsx b/packages/react/src/__tests__/filter-menu-surface.test.tsx new file mode 100644 index 0000000..3fae023 --- /dev/null +++ b/packages/react/src/__tests__/filter-menu-surface.test.tsx @@ -0,0 +1,290 @@ +import "@testing-library/jest-dom/vitest"; +import { + act, + cleanup, + fireEvent, + render, + within, +} from "@testing-library/react"; +import * as React from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { PretableSurface } from "../pretable-surface"; +import type { ColumnFilter } from "@pretable/core"; +import type { PretableColumn } from "../types"; + +afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +type Bug = { + id: string; + title: string; + severity: string; + count: number; +}; + +const columns: PretableColumn[] = [ + { id: "title", header: "Title", widthPx: 200, filterType: "text" }, + { + id: "severity", + header: "Severity", + widthPx: 140, + filterType: "enum", + }, + { id: "count", header: "Count", widthPx: 120, filterType: "number" }, + // Non-filterable column: no funnel should render. + { + id: "internal", + header: "Internal", + widthPx: 120, + filterable: false, + }, +]; + +const rows: Bug[] = [ + { id: "b1", title: "alpha crash", severity: "high", count: 3 }, + { id: "b2", title: "beta hang", severity: "low", count: 7 }, + { id: "b3", title: "alpha leak", severity: "high", count: 1 }, +]; + +const getRowId = (row: Bug) => row.id; + +function renderSurface( + extra: Partial>> = {}, +) { + return render( + + ariaLabel="Bug grid" + columns={columns} + getRowId={getRowId} + overscan={0} + rows={rows} + viewportHeight={300} + {...extra} + />, + ); +} + +describe("PretableSurface — built-in filter funnel", () => { + it("renders a funnel for filterable columns and omits it for filterable:false", () => { + const view = renderSurface(); + + expect(view.getByRole("button", { name: "Filter Title" })).toBeTruthy(); + expect(view.getByRole("button", { name: "Filter Severity" })).toBeTruthy(); + expect(view.getByRole("button", { name: "Filter Count" })).toBeTruthy(); + expect( + view.queryByRole("button", { name: "Filter Internal" }), + ).toBeNull(); + + // Sanity: every funnel carries the stable hooks. + const funnels = view.container.querySelectorAll( + "[data-pretable-filter-funnel]", + ); + expect(funnels.length).toBe(3); + }); + + it("nests funnels inside the header row so the CSS hover selector matches", () => { + // The grid.css reveal rule is + // [data-pretable-header-row]:hover [data-pretable-filter-funnel] + // (a DESCENDANT selector). Confirm the rendered DOM actually nests the + // funnel button under the header row inside a funnel slot, so the selector + // resolves against real markup rather than a guess. + const view = renderSurface(); + const headerRow = view.container.querySelector( + "[data-pretable-header-row]", + )!; + expect(headerRow).toBeTruthy(); + + const slot = headerRow.querySelector("[data-pretable-filter-funnel-slot]")!; + expect(slot).toBeTruthy(); + // Slot is a direct child of the header row (a flat sibling of the header + // cells / resize handles), and the funnel button lives inside the slot. + expect(slot.parentElement).toBe(headerRow); + const funnel = slot.querySelector("[data-pretable-filter-funnel]")!; + expect(funnel).toBeTruthy(); + expect(headerRow.contains(funnel)).toBe(true); + }); + + it("opens the dialog on funnel click, and closes on second click / Escape / outside-click", () => { + const view = renderSurface(); + const funnel = view.getByRole("button", { name: "Filter Title" }); + + expect(view.queryByRole("dialog")).toBeNull(); + + // Open. + fireEvent.click(funnel); + expect(view.getByRole("dialog", { name: "Filter Title" })).toBeTruthy(); + expect(funnel).toHaveAttribute("aria-expanded", "true"); + + // Second click toggles closed. + fireEvent.click(funnel); + expect(view.queryByRole("dialog")).toBeNull(); + + // Reopen, then Escape closes (handled by useFilterPopover). + fireEvent.click(funnel); + expect(view.getByRole("dialog")).toBeTruthy(); + fireEvent.keyDown(document, { key: "Escape" }); + expect(view.queryByRole("dialog")).toBeNull(); + + // Reopen, then outside-click (pointerdown) closes (handled by FilterMenu). + fireEvent.click(funnel); + expect(view.getByRole("dialog")).toBeTruthy(); + fireEvent.pointerDown(document.body); + expect(view.queryByRole("dialog")).toBeNull(); + }); + + it("clicking the funnel does not sort the column", () => { + const onSortChange = vi.fn(); + const view = renderSurface({ onSortChange }); + + const orderBefore = view + .getAllByTestId("pretable-row") + .map((r) => r.getAttribute("data-pretable-row-id")); + + fireEvent.click(view.getByRole("button", { name: "Filter Title" })); + + expect(onSortChange).not.toHaveBeenCalled(); + const orderAfter = view + .getAllByTestId("pretable-row") + .map((r) => r.getAttribute("data-pretable-row-id")); + expect(orderAfter).toEqual(orderBefore); + // The sort header still reads "Sort" (no direction applied). + expect( + view.getByRole("columnheader", { name: "Sort Title" }), + ).toHaveTextContent("Sort"); + }); + + it("typing into a text filter narrows the rows and fires onFiltersChange", async () => { + vi.useFakeTimers(); + const onFiltersChange = vi.fn(); + const view = renderSurface({ onFiltersChange }); + + expect(view.getAllByTestId("pretable-row")).toHaveLength(3); + + fireEvent.click(view.getByRole("button", { name: "Filter Title" })); + const dialog = view.getByRole("dialog", { name: "Filter Title" }); + const valueInput = within(dialog).getByLabelText("Filter value"); + + // Default text operator is "contains". + act(() => { + fireEvent.change(valueInput, { target: { value: "alpha" } }); + }); + + // Text input is debounced (~200ms). + expect(onFiltersChange).not.toHaveBeenCalled(); + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + }); + + expect(onFiltersChange).toHaveBeenCalled(); + const lastFilters = onFiltersChange.mock.lastCall?.[0] as Record< + string, + ColumnFilter + >; + expect(lastFilters.title).toEqual({ operator: "contains", value: "alpha" }); + + // Rows narrowed to the two "alpha" titles. + const ids = view + .getAllByTestId("pretable-row") + .map((r) => r.getAttribute("data-pretable-row-id")); + expect(ids).toEqual(["b1", "b3"]); + }); + + it("enum options come from distinctColumnValues when filterOptions is absent", () => { + const view = renderSurface(); + + fireEvent.click(view.getByRole("button", { name: "Filter Severity" })); + const dialog = view.getByRole("dialog", { name: "Filter Severity" }); + const group = within(dialog).getByRole("group"); + const labels = within(group) + .getAllByRole("checkbox") + .map((cb) => cb.closest("label")?.textContent?.trim()); + + // Distinct values across the rows: "high" and "low". + expect(new Set(labels)).toEqual(new Set(["high", "low"])); + }); + + it("checking enum values fires onFiltersChange and narrows rows", () => { + const onFiltersChange = vi.fn(); + const view = renderSurface({ onFiltersChange }); + + fireEvent.click(view.getByRole("button", { name: "Filter Severity" })); + const dialog = view.getByRole("dialog", { name: "Filter Severity" }); + const highCheckbox = within(dialog) + .getAllByRole("checkbox") + .find((cb) => cb.closest("label")?.textContent?.includes("high"))!; + + fireEvent.click(highCheckbox); + + const lastFilters = onFiltersChange.mock.lastCall?.[0] as Record< + string, + ColumnFilter + >; + expect(lastFilters.severity).toEqual({ + operator: "isAnyOf", + value: ["high"], + }); + const ids = view + .getAllByTestId("pretable-row") + .map((r) => r.getAttribute("data-pretable-row-id")); + expect(ids).toEqual(["b1", "b3"]); + }); + + it("controlled state.filters lights the funnel active and hydrates the dialog", () => { + const view = renderSurface({ + state: { + filters: { + title: { operator: "contains", value: "beta" }, + }, + }, + }); + + // Only the matching row survives. + const ids = view + .getAllByTestId("pretable-row") + .map((r) => r.getAttribute("data-pretable-row-id")); + expect(ids).toEqual(["b2"]); + + // Funnel is marked active. + const funnel = view.getByRole("button", { name: "Filter Title" }); + expect(funnel).toHaveAttribute("data-pretable-filter-active", "true"); + + // Opening hydrates the dialog to the active operator/value. + fireEvent.click(funnel); + const dialog = view.getByRole("dialog", { name: "Filter Title" }); + expect(within(dialog).getByLabelText("Filter operator")).toHaveValue( + "contains", + ); + expect(within(dialog).getByLabelText("Filter value")).toHaveValue("beta"); + }); + + it("Clear resets the filter and fires onFiltersChange with the column removed", () => { + const onFiltersChange = vi.fn(); + const view = renderSurface({ + onFiltersChange, + state: undefined, + }); + + // Open + apply a text filter first (immediate via enum-free path uses + // debounce; instead clear from a hydrated controlled-less state by typing). + fireEvent.click(view.getByRole("button", { name: "Filter Severity" })); + const dialog = view.getByRole("dialog", { name: "Filter Severity" }); + const highCheckbox = within(dialog) + .getAllByRole("checkbox") + .find((cb) => cb.closest("label")?.textContent?.includes("high"))!; + fireEvent.click(highCheckbox); + expect(view.getAllByTestId("pretable-row")).toHaveLength(2); + + // Clear. + fireEvent.click(within(dialog).getByText("Clear")); + const lastFilters = onFiltersChange.mock.lastCall?.[0] as Record< + string, + ColumnFilter + >; + expect(lastFilters.severity).toBeUndefined(); + expect(view.getAllByTestId("pretable-row")).toHaveLength(3); + }); +}); diff --git a/packages/react/src/filter-menu/FilterMenu.tsx b/packages/react/src/filter-menu/FilterMenu.tsx index 451ab23..b1d4908 100644 --- a/packages/react/src/filter-menu/FilterMenu.tsx +++ b/packages/react/src/filter-menu/FilterMenu.tsx @@ -48,8 +48,12 @@ export function FilterMenu({ const timerRef = useRef | null>(null); // Keep the latest draft in a ref so the unmount flush sees current state. + // Synced in an effect (not during render) so the ref write is not a render + // side effect; the flush only fires on unmount, after this has run. const latestDraftRef = useRef(draft); - latestDraftRef.current = draft; + useEffect(() => { + latestDraftRef.current = draft; + }, [draft]); const apply = useCallback( (next: FilterDraft) => { diff --git a/packages/react/src/pretable-surface.tsx b/packages/react/src/pretable-surface.tsx index 29a80c5..9161073 100644 --- a/packages/react/src/pretable-surface.tsx +++ b/packages/react/src/pretable-surface.tsx @@ -14,6 +14,7 @@ import { } from "react"; import type { AutosizeOptions, + ColumnFilter, PretableCellAddress, PretableCellRange, PretableFocusState, @@ -60,6 +61,12 @@ export { ROW_SELECT_COLUMN_ID } from "./constants"; import { ROW_SELECT_COLUMN_ID } from "./constants"; import { useCellEditController } from "./use-cell-edit-controller"; import { CellEditor } from "./cell-editor"; +import { + FilterMenu, + FunnelButton, + popoverStyle, + useFilterPopover, +} from "./filter-menu"; import { type CopyPayload, type SerializeRangesArgs, @@ -233,6 +240,12 @@ export interface PretableSurfaceProps { onColumnOrderChange?: (next: readonly string[]) => void; onColumnPinnedChange?: (next: Record) => void; onTelemetryChange?: (telemetry: PretableTelemetry) => void; + /** + * Called when the built-in column filter menu mutates the active filter set. + * Receives the engine's full `filters` map after the change. Use to mirror + * filter state externally (e.g. controlled `state.filters`). + */ + onFiltersChange?: (filters: Record) => void; onGridReady?: (grid: PretableGrid) => void; renderBodyCell?: ( input: PretableSurfaceBodyCellRenderInput, @@ -435,6 +448,7 @@ export function PretableSurface({ onColumnOrderChange, onColumnPinnedChange, onTelemetryChange, + onFiltersChange, renderBodyCell, renderHeaderCell, rows, @@ -598,6 +612,13 @@ export function PretableSurface({ ), }); + // Built-in column filter menu: one open-state for the whole surface. + const { + openState: filterOpenState, + toggle: toggleFilter, + close: closeFilter, + } = useFilterPopover(); + const pinnedOffsets = useMemo( () => getPinnedLeftOffsets(effectiveColumns), [effectiveColumns], @@ -1561,6 +1582,39 @@ export function PretableSurface({ }} /> ) : null, + // Built-in filter funnel overlay — an absolutely-positioned sibling + // of the resize handle (NOT nested in the header @@ -357,13 +459,16 @@ export interface PopoverState { export function useFilterPopover() { const [openState, setOpenState] = useState(null); - const toggle = useCallback((columnId: string, anchorEl: HTMLElement | null) => { - setOpenState((prev) => { - if (prev?.columnId === columnId) return null; - const rect = anchorEl?.getBoundingClientRect(); - return rect ? { columnId, rect } : null; - }); - }, []); + const toggle = useCallback( + (columnId: string, anchorEl: HTMLElement | null) => { + setOpenState((prev) => { + if (prev?.columnId === columnId) return null; + const rect = anchorEl?.getBoundingClientRect(); + return rect ? { columnId, rect } : null; + }); + }, + [], + ); const close = useCallback(() => setOpenState(null), []); @@ -406,9 +511,17 @@ export function popoverStyle(rect: DOMRect): React.CSSProperties { - [ ] **Step 3: Write `FilterMenu.tsx`** — the dialog. Renders operator `