diff --git a/docs/superpowers/plans/2026-06-19-filter-header-menu.md b/docs/superpowers/plans/2026-06-19-filter-header-menu.md new file mode 100644 index 00000000..31b51703 --- /dev/null +++ b/docs/superpowers/plans/2026-06-19-filter-header-menu.md @@ -0,0 +1,825 @@ +# Filter Header Menu — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A built-in per-column header filter menu in `@pretable/react` (funnel → popover with operator select + typed value control), styled in `@pretable/ui`, driving the merged engine filter API. On-by-default for `filterable` columns; live-apply; uncontrolled or controlled via `state.filters`; fires `onFiltersChange`. + +**Architecture:** A pure `filter-operators.ts` module (operators-per-type, value-shape, completeness gating, draft↔`ColumnFilter` conversion) underpins three React components (`FunnelButton`, `FilterMenu`, plus a `useFilterPopover` open/position hook). The surface renders one funnel **overlay** per header (absolutely-positioned sibling of the resize handle — the header is itself a ` + ); +} +``` + +(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 `` + value control by shape, + Clear button. Owns local draft state; pushes changes through a callback (debounced for + text). `role="dialog"`, labelled by the column header text. + +Wiring in `packages/react/src/pretable-surface.tsx`: + +- Render `` inside the header content (left of the resize handle). Its + pointer/click handlers `stopPropagation()` so sort-on-header-click and reorder-drag are + untouched. +- Render **one** `` at the surface root (a fixed-position layer, mirroring the + reorder ghost) for the currently-open column — avoids header `overflow` clipping. +- New prop `onFiltersChange?: (filters: Record) => void`. + +## Data flow + +- **Uncontrolled (default):** the menu calls `grid.setColumnFilter(columnId, filter)` (or + `null` to clear), then `onFiltersChange(grid.getSnapshot().filters)`. The engine is the + source of truth; the funnel's active state reads from `snapshot.filters[columnId]`. +- **Controlled:** when the consumer passes `state.filters`, that already flows through + `usePretable` → `grid.replaceFilters`. The menu still calls `setColumnFilter` for + responsiveness and fires `onFiltersChange`; the consumer updates its `state.filters` and + the controlled apply re-asserts (same pattern as sort/selection today). The popover + hydrates its draft from `snapshot.filters[columnId]` on open. +- **Enum options:** use `column.filterOptions` if present; else + `grid.distinctColumnValues(columnId)` (computed when the popover opens). + +## Styling (`@pretable/ui`) + +- Vanilla CSS in `grid.css`, `:where()` + `data-pretable-filter-*` attributes (zero + specificity, consumer-overridable), matching existing conventions. +- Reuse existing tokens: `--pretable-bg-tooltip` (popover bg), `--pretable-rule`, + `--pretable-radius`, `--pretable-text-cell`, `--pretable-bg-hover`, `--pretable-accent` + (active funnel + focus), `--pretable-focus-ring`. **Goal: no new tokens.** If an active- + funnel color genuinely needs its own token, add it to **both** themes (excel; material + light + dark) and the `contract.test.ts` `TOKENS` list (currently 42) — and only then. +- Funnel is hidden by default and revealed via + `:where([data-pretable-header-cell]:hover [data-pretable-filter-funnel])`, + `:focus-within`, and always shown when `[data-pretable-filter-active="true"]`. +- Popover reuses/extends the existing `[data-pretable-popover]` rule. + +## Accessibility & keyboard + +- Funnel: ` +
+ ); +} diff --git a/packages/react/src/filter-menu/FunnelButton.tsx b/packages/react/src/filter-menu/FunnelButton.tsx new file mode 100644 index 00000000..68cbbecd --- /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/filter-operators.ts b/packages/react/src/filter-menu/filter-operators.ts new file mode 100644 index 00000000..7b906aed --- /dev/null +++ b/packages/react/src/filter-menu/filter-operators.ts @@ -0,0 +1,144 @@ +// packages/react/src/filter-menu/filter-operators.ts +import type { ColumnFilter, FilterOperator, FilterType } from "@pretable/core"; + +/** Local editing shape for the popover. One field set per value-shape. */ +export interface FilterDraft { + operator: FilterOperator; + text?: string; // single (text/number/date) + min?: string; // range lower + max?: string; // range upper + selected?: string[]; // set (enum) +} + +export type ValueShape = "none" | "single" | "range" | "set"; + +const TEXT_OPS: FilterOperator[] = [ + "contains", + "notContains", + "equals", + "notEquals", + "startsWith", + "endsWith", +]; +const NUMBER_OPS: FilterOperator[] = [ + "equals", + "notEquals", + "gt", + "gte", + "lt", + "lte", + "between", +]; +const DATE_OPS: FilterOperator[] = ["on", "before", "after", "dateBetween"]; +const ENUM_OPS: FilterOperator[] = ["isAnyOf", "isNoneOf"]; +const SHARED_OPS: FilterOperator[] = ["isEmpty", "isNotEmpty"]; + +export function operatorsForType(type: FilterType): FilterOperator[] { + const base = + type === "number" + ? NUMBER_OPS + : type === "date" + ? DATE_OPS + : type === "enum" + ? ENUM_OPS + : TEXT_OPS; + return [...base, ...SHARED_OPS]; +} + +export const OPERATOR_LABELS: Record = { + contains: "contains", + notContains: "does not contain", + equals: "equals", + notEquals: "does not equal", + startsWith: "starts with", + endsWith: "ends with", + gt: "greater than", + gte: "greater than or equal", + lt: "less than", + lte: "less than or equal", + between: "is between", + isAnyOf: "is any of", + isNoneOf: "is none of", + on: "on", + before: "before", + after: "after", + dateBetween: "is between", + isEmpty: "is empty", + isNotEmpty: "is not empty", +}; + +const RANGE_OPS = new Set(["between", "dateBetween"]); +const SET_OPS = new Set(["isAnyOf", "isNoneOf"]); +const NONE_OPS = new Set(["isEmpty", "isNotEmpty"]); + +export function operatorValueShape(op: FilterOperator): ValueShape { + if (NONE_OPS.has(op)) return "none"; + if (RANGE_OPS.has(op)) return "range"; + if (SET_OPS.has(op)) return "set"; + return "single"; +} + +export function defaultDraft(type: FilterType): FilterDraft { + const operator = operatorsForType(type)[0]!; + if (operatorValueShape(operator) === "set") return { operator, selected: [] }; + if (operatorValueShape(operator) === "range") + return { operator, min: "", max: "" }; + return { operator, text: "" }; +} + +const isNum = (s: string | undefined): s is string => + s !== undefined && s.trim() !== "" && !Number.isNaN(Number(s)); + +export function isComplete(type: FilterType, d: FilterDraft): boolean { + const shape = operatorValueShape(d.operator); + if (shape === "none") return true; + if (shape === "set") return (d.selected?.length ?? 0) > 0; + if (shape === "range") { + if (type === "number") return isNum(d.min) && isNum(d.max); + return !!d.min && !!d.max; // date ISO strings + } + // single + if (type === "number") return isNum(d.text); + return !!d.text && d.text.trim() !== ""; +} + +export function toColumnFilter( + type: FilterType, + d: FilterDraft, +): ColumnFilter | null { + const shape = operatorValueShape(d.operator); + if (shape === "none") return { operator: d.operator }; + if (!isComplete(type, d)) return null; + if (shape === "set") return { operator: d.operator, value: [...d.selected!] }; + if (shape === "range") { + if (type === "number") + return { operator: d.operator, value: [Number(d.min), Number(d.max)] }; + return { operator: d.operator, value: [d.min!, d.max!] }; + } + // single + if (type === "number") return { operator: d.operator, value: Number(d.text) }; + return { operator: d.operator, value: d.text! }; +} + +export function fromColumnFilter( + type: FilterType, + filter: ColumnFilter | null, +): FilterDraft { + if (!filter) return defaultDraft(type); + const { operator, value } = filter; + const shape = operatorValueShape(operator); + if (shape === "none") return { operator }; + if (shape === "set") + return { + operator, + selected: Array.isArray(value) ? value.map(String) : [], + }; + if (shape === "range") { + const arr = Array.isArray(value) ? value : ["", ""]; + return { operator, min: String(arr[0] ?? ""), max: String(arr[1] ?? "") }; + } + return { + operator, + text: value === null || value === undefined ? "" : String(value), + }; +} diff --git a/packages/react/src/filter-menu/index.ts b/packages/react/src/filter-menu/index.ts new file mode 100644 index 00000000..09214950 --- /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 00000000..07e837da --- /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, + }; +} diff --git a/packages/react/src/pretable-surface.tsx b/packages/react/src/pretable-surface.tsx index 29a80c53..e8bad546 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