From 421a1d9cc6dc6ae2fd3abb1cb544cf6bcffa6b06 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 1 Jun 2026 17:24:46 +0000 Subject: [PATCH 1/6] Initial plan From f4e758b10835a0789305683785f445266704a8b8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 1 Jun 2026 17:37:40 +0000 Subject: [PATCH 2/6] feat: Extract FilterPanel into standalone reusable Filter component --- Source/Filter/FilterPanel.css | 412 +++++++++++++ Source/Filter/FilterPanel.stories.tsx | 568 ++++++++++++++++++ Source/Filter/FilterPanel.tsx | 231 +++++++ Source/Filter/RangeHistogramFilter.tsx | 220 +++++++ Source/Filter/index.ts | 18 + Source/Filter/types.ts | 44 ++ Source/Filter/useFilterState.ts | 138 +++++ Source/PivotViewer/components/FilterPanel.tsx | 181 ++---- Source/PivotViewer/types.ts | 5 +- Source/index.ts | 2 + Source/package.json | 11 +- 11 files changed, 1681 insertions(+), 149 deletions(-) create mode 100644 Source/Filter/FilterPanel.css create mode 100644 Source/Filter/FilterPanel.stories.tsx create mode 100644 Source/Filter/FilterPanel.tsx create mode 100644 Source/Filter/RangeHistogramFilter.tsx create mode 100644 Source/Filter/index.ts create mode 100644 Source/Filter/types.ts create mode 100644 Source/Filter/useFilterState.ts diff --git a/Source/Filter/FilterPanel.css b/Source/Filter/FilterPanel.css new file mode 100644 index 0000000..90b18ff --- /dev/null +++ b/Source/Filter/FilterPanel.css @@ -0,0 +1,412 @@ +/* ----------------------------------------------------------------------- + * Standalone FilterPanel styles. + * + * These styles are shared by the standalone FilterPanel component and the + * PivotViewer which embeds it. All class names use the .pv-filter-* prefix + * so they can coexist with PivotViewer's other .pv-* rules. + * ----------------------------------------------------------------------- */ + +.pv-filter-panel { + position: fixed; + width: min(280px, calc(100% - 2rem)); + max-height: calc(100vh - 6.5rem); + background: linear-gradient(180deg, var(--cratis-surface-card), var(--cratis-surface-ground)); + border: 1px solid var(--cratis-surface-border); + border-radius: 1rem; + box-shadow: 0 24px 60px rgba(0, 0, 0, 0.3); + backdrop-filter: blur(14px); + overflow: hidden; + z-index: 12; + display: flex; + flex-direction: column; +} + +.pv-filter-panel.dragging { + cursor: grabbing; + user-select: none; +} + +.pv-filter-panel.minimized { + max-height: none; +} + +.pv-filter-panel-header { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1rem; + cursor: grab; + background: var(--cratis-surface-section); + border-bottom: 1px solid var(--cratis-surface-border); + flex-shrink: 0; +} + +.pv-filter-panel.dragging .pv-filter-panel-header { + cursor: grabbing; +} + +.pv-filter-panel-header h2 { + margin: 0; + font-size: 0.85rem; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--cratis-text-color); + flex: 1; + pointer-events: none; +} + +.pv-minimize-btn, +.pv-close-btn { + appearance: none; + border: none; + border-radius: 0.4rem; + width: 1.5rem; + height: 1.5rem; + display: flex; + align-items: center; + justify-content: center; + background: var(--cratis-highlight-bg); + color: var(--cratis-text-color); + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: background 120ms ease; +} + +.pv-minimize-btn:hover, +.pv-close-btn:hover { + background: var(--cratis-surface-hover); +} + +.pv-close-btn { + font-size: 1.2rem; +} + +.pv-filter-panel-content { + overflow: hidden; + max-height: 0; + opacity: 0; + padding: 0 1.25rem; + transition: max-height 150ms ease-out, opacity 100ms ease-out, padding 150ms ease-out; +} + +.pv-filter-panel-content.expanded { + overflow-y: auto; + flex: 1; + max-height: 600px; + opacity: 1; + padding: 1rem 1.25rem 1.25rem; +} + +.pv-search { + padding-bottom: 1rem; +} + +.pv-search input { + width: 100%; + padding: 0.65rem 0.85rem; + border-radius: 0.75rem; + border: 1px solid var(--cratis-surface-border); + background: var(--cratis-surface-ground); + color: inherit; + font-size: 0.95rem; + box-shadow: inset 0 1px 0 var(--cratis-surface-border); +} + +.pv-search input:focus { + outline: none; + border-color: var(--cratis-primary-color); + box-shadow: var(--cratis-focus-ring); +} + +.pv-filter-groups { + display: flex; + flex-direction: column; + gap: 0.85rem; +} + +.pv-filter { + background: var(--cratis-surface-overlay); + border: 1px solid var(--cratis-surface-border); + border-radius: 0.85rem; + padding: 0.75rem 0.85rem; + transition: border-color 180ms ease, box-shadow 180ms ease; +} + +.pv-filter.expanded { + border-color: var(--cratis-primary-color); + box-shadow: 0 15px 38px rgba(0, 0, 0, 0.3); +} + +.pv-filter-trigger { + display: flex; + width: 100%; + align-items: center; + justify-content: space-between; + background: none; + border: none; + color: inherit; + padding: 0; + cursor: pointer; + font-size: 0.82rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.pv-filter-label { + display: flex; + align-items: center; + gap: 0.4rem; +} + +.pv-filter-trigger-meta { + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +.pv-filter-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.5rem; + padding: 0.15rem 0.45rem; + border-radius: 999px; + background: var(--cratis-highlight-bg); + color: var(--cratis-text-color); + font-size: 0.7rem; +} + +.pv-filter-chevron { + width: 0.65rem; + height: 0.65rem; + border-right: 2px solid var(--cratis-text-color); + border-bottom: 2px solid var(--cratis-text-color); + transform: rotate(45deg); + transition: transform 180ms ease; +} + +.pv-filter.expanded .pv-filter-chevron { + transform: rotate(225deg); +} + +.pv-filter-content { + overflow: hidden; + max-height: 0; + opacity: 0; + margin-top: 0; + padding-top: 0; + border-top: 1px solid transparent; + transition: max-height 120ms ease-out, opacity 80ms ease-out, margin-top 120ms ease-out, padding-top 120ms ease-out, border-color 120ms ease-out; +} + +.pv-filter-content.expanded { + max-height: 350px; + opacity: 1; + margin-top: 0.75rem; + padding-top: 0.75rem; + border-top-color: var(--cratis-surface-border); + overflow-y: auto; +} + +.pv-filter-content ul { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 0.45rem; +} + +.pv-filter-content li label { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 0.6rem; + align-items: center; + padding: 0.45rem 0.5rem; + border-radius: 0.55rem; + border: 1px solid transparent; + cursor: pointer; + transition: background 160ms ease, border-color 160ms ease; +} + +.pv-filter-content li label:hover { + background: var(--cratis-surface-hover); +} + +.pv-filter-content input[type='checkbox'], +.pv-filter-content input[type='radio'] { + accent-color: var(--cratis-primary-color); +} + +.pv-filter-content li label span { + font-size: 0.85rem; +} + +.pv-filter-clear { + margin-top: 0.75rem; + appearance: none; + border: none; + border-radius: 0.55rem; + padding: 0.4rem 0.75rem; + background: var(--cratis-highlight-bg); + color: var(--cratis-text-color); + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.08em; + cursor: pointer; +} + +.pv-option-count { + font-feature-settings: "tnum"; + font-variant-numeric: tabular-nums; + color: var(--cratis-text-color-secondary); + font-size: 0.75rem; +} + +/* Filter Dropdown (anchor-positioned panel) */ +.pv-filter-dropdown { + position: fixed; + width: min(320px, calc(100vw - 2rem)); + max-height: calc(100vh - 8rem); + background: linear-gradient(180deg, var(--cratis-surface-card), var(--cratis-surface-ground)); + border: 1px solid var(--cratis-surface-border); + border-radius: 0.85rem; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.4); + backdrop-filter: blur(16px); + overflow: hidden; + z-index: 1000; + display: flex; + flex-direction: column; + will-change: opacity, transform; + contain: layout; +} + +.pv-filter-dropdown-content { + padding: 1rem; + overflow-y: auto; + flex: 1; +} + +/* Range Histogram Filter */ +.pv-range-histogram { + padding: 0.5rem 0; +} + +.pv-histogram-bars { + display: flex; + align-items: flex-end; + gap: 2px; + height: 80px; + padding: 0 2px; + margin-bottom: 0.5rem; +} + +.pv-histogram-bar { + flex: 1; + min-width: 8px; + background: var(--cratis-highlight-bg); + border: none; + border-radius: 2px 2px 0 0; + cursor: pointer; + transition: background 160ms ease, transform 120ms ease; + padding: 0; +} + +.pv-histogram-bar:hover { + background: var(--cratis-surface-hover); + transform: scaleY(1.02); +} + +.pv-histogram-bar.in-range { + background: var(--cratis-primary-color); +} + +.pv-histogram-bar.partial { + background: var(--cratis-highlight-bg); +} + +.pv-range-slider { + position: relative; + height: 24px; + margin: 0.5rem 0; +} + +.pv-range-track { + position: absolute; + top: 50%; + left: 0; + right: 0; + height: 4px; + background: var(--cratis-surface-border); + border-radius: 2px; + transform: translateY(-50%); +} + +.pv-range-selection { + position: absolute; + top: 50%; + height: 4px; + background: var(--cratis-primary-color); + border-radius: 2px; + transform: translateY(-50%); + cursor: grab; +} + +.pv-range-selection:active { + cursor: grabbing; +} + +.pv-range-handle { + position: absolute; + top: 50%; + width: 16px; + height: 16px; + background: var(--cratis-primary-color); + border: 2px solid var(--cratis-text-color); + border-radius: 50%; + transform: translate(-50%, -50%); + cursor: ew-resize; + transition: transform 120ms ease, box-shadow 120ms ease; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); +} + +.pv-range-handle:hover { + transform: translate(-50%, -50%) scale(1.15); + box-shadow: 0 4px 12px var(--cratis-primary-color); +} + +.pv-range-labels { + display: flex; + justify-content: space-between; + font-size: 0.75rem; + color: var(--cratis-text-color-secondary); + font-variant-numeric: tabular-nums; +} + +.pv-range-clear { + margin-top: 0.75rem; + appearance: none; + border: none; + border-radius: 0.55rem; + padding: 0.4rem 0.75rem; + background: var(--cratis-highlight-bg); + color: var(--cratis-text-color); + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.08em; + cursor: pointer; + width: 100%; +} + +.pv-range-clear:hover { + background: var(--cratis-surface-hover); +} + +@media (max-width: 900px) { + .pv-filter-panel { + width: calc(100% - 2.5rem); + left: 1.25rem; + right: 1.25rem; + } +} diff --git a/Source/Filter/FilterPanel.stories.tsx b/Source/Filter/FilterPanel.stories.tsx new file mode 100644 index 0000000..46a8aa8 --- /dev/null +++ b/Source/Filter/FilterPanel.stories.tsx @@ -0,0 +1,568 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import React, { useRef, useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { FilterPanel } from './FilterPanel'; +import { useFilterState } from './useFilterState'; +import type { FilterDefinition } from './types'; + +const meta: Meta = { + title: 'Filter/FilterPanel', + component: FilterPanel, + parameters: { + layout: 'fullscreen', + }, +}; + +export default meta; +type Story = StoryObj; + +// --------------------------------------------------------------------------- +// Shared wrapper styles +// --------------------------------------------------------------------------- + +const pageStyle: React.CSSProperties = { + minHeight: '100vh', + background: 'var(--cratis-surface-ground, #0d0d1a)', + color: 'var(--cratis-text-color, #e2e8f0)', + fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif", + colorScheme: 'dark', + padding: '2rem', + display: 'flex', + flexDirection: 'column', + gap: '1.5rem', +}; + +const buttonStyle: React.CSSProperties = { + appearance: 'none', + border: '1px solid var(--cratis-surface-border, #334155)', + borderRadius: '999px', + padding: '0.45rem 1.25rem', + background: 'var(--cratis-highlight-bg, rgba(255,255,255,0.06))', + color: 'var(--cratis-text-color, #e2e8f0)', + fontSize: '0.8rem', + letterSpacing: '0.08em', + textTransform: 'uppercase', + cursor: 'pointer', + transition: 'background 180ms ease', +}; + +const activeButtonStyle: React.CSSProperties = { + ...buttonStyle, + background: 'var(--cratis-primary-color, #3b82f6)', + color: 'var(--cratis-primary-color-text, #fff)', +}; + +// --------------------------------------------------------------------------- +// Story: Single-select string filter +// --------------------------------------------------------------------------- + +export const SingleSelectFilter: Story = { + name: 'Single-select string filter', + render: () => { + const buttonRef = useRef(null!); + const [isOpen, setIsOpen] = useState(false); + + const filters: FilterDefinition[] = [ + { + key: 'status', + label: 'Status', + type: 'string', + options: [ + { key: 'active', label: 'Active', value: 'active', count: 42 }, + { key: 'inactive', label: 'Inactive', value: 'inactive', count: 18 }, + { key: 'pending', label: 'Pending', value: 'pending', count: 7 }, + { key: 'archived', label: 'Archived', value: 'archived', count: 3 }, + ], + }, + ]; + + const { + filterValues, + rangeValues, + expandedFilterKey, + setExpandedFilterKey, + handleToggleFilter, + handleClearFilter, + handleRangeChange, + } = useFilterState(filters); + + const activeCount = (filterValues['status']?.size ?? 0); + + return ( +
+
+

Single-select Filter

+

+ Radio-button style — only one option can be selected at a time. +

+
+
+ + {activeCount > 0 && ( + + Selected: {Array.from(filterValues['status'] ?? []).join(', ')} + + )} +
+ setIsOpen(false)} + onFilterToggle={handleToggleFilter} + onFilterClear={handleClearFilter} + onRangeChange={handleRangeChange} + onExpandedFilterChange={setExpandedFilterKey} + /> +
+ ); + }, +}; + +// --------------------------------------------------------------------------- +// Story: Multi-select string filter +// --------------------------------------------------------------------------- + +export const MultiSelectFilter: Story = { + name: 'Multi-select string filter', + render: () => { + const buttonRef = useRef(null!); + const [isOpen, setIsOpen] = useState(false); + + const filters: FilterDefinition[] = [ + { + key: 'department', + label: 'Department', + type: 'string', + multi: true, + options: [ + { key: 'engineering', label: 'Engineering', value: 'engineering', count: 120 }, + { key: 'product', label: 'Product', value: 'product', count: 45 }, + { key: 'design', label: 'Design', value: 'design', count: 32 }, + { key: 'marketing', label: 'Marketing', value: 'marketing', count: 28 }, + { key: 'sales', label: 'Sales', value: 'sales', count: 67 }, + { key: 'finance', label: 'Finance', value: 'finance', count: 19 }, + { key: 'hr', label: 'Human Resources', value: 'hr', count: 14 }, + { key: 'legal', label: 'Legal', value: 'legal', count: 8 }, + ], + }, + ]; + + const { filterValues, rangeValues, expandedFilterKey, setExpandedFilterKey, handleToggleFilter, handleClearFilter, handleRangeChange } = + useFilterState(filters); + + const activeCount = filterValues['department']?.size ?? 0; + + return ( +
+
+

Multi-select Filter

+

+ Checkbox style — multiple options can be selected simultaneously. +

+
+
+ + {activeCount > 0 && ( + + Selected: {Array.from(filterValues['department'] ?? []).join(', ')} + + )} +
+ setIsOpen(false)} + onFilterToggle={handleToggleFilter} + onFilterClear={handleClearFilter} + onRangeChange={handleRangeChange} + onExpandedFilterChange={setExpandedFilterKey} + /> +
+ ); + }, +}; + +// --------------------------------------------------------------------------- +// Story: Numeric range filter with histogram +// --------------------------------------------------------------------------- + +function generateAgeValues(count: number): number[] { + const values: number[] = []; + // Bell-curve-ish distribution centred around 35 + for (let i = 0; i < count; i++) { + const u = Math.random() + Math.random() + Math.random() + Math.random(); + values.push(Math.round(20 + (u / 4) * 40)); + } + return values; +} + +const ageValues = generateAgeValues(500); + +export const NumericRangeFilter: Story = { + name: 'Numeric range filter (histogram)', + render: () => { + const buttonRef = useRef(null!); + const [isOpen, setIsOpen] = useState(false); + + const filters: FilterDefinition[] = [ + { + key: 'age', + label: 'Age', + type: 'number', + buckets: 20, + numericRange: { + min: 20, + max: 60, + values: ageValues, + }, + }, + ]; + + const { filterValues, rangeValues, expandedFilterKey, setExpandedFilterKey, handleToggleFilter, handleClearFilter, handleRangeChange } = + useFilterState(filters); + + const range = rangeValues['age']; + + return ( +
+
+

Numeric Range Filter

+

+ Histogram with draggable range handles. Click a bar to jump-select that bucket. +

+
+
+ +
+ setIsOpen(false)} + onFilterToggle={handleToggleFilter} + onFilterClear={handleClearFilter} + onRangeChange={handleRangeChange} + onExpandedFilterChange={setExpandedFilterKey} + /> +
+ ); + }, +}; + +// --------------------------------------------------------------------------- +// Story: Custom filter editor +// --------------------------------------------------------------------------- + +function RatingEditor({ value, onChange }: { value: unknown; onChange: (v: unknown) => void }) { + const rating = typeof value === 'number' ? value : 0; + return ( +
+
+ {[1, 2, 3, 4, 5].map((star) => ( + + ))} +
+ {rating > 0 && ( + + )} +
+ ); +} + +function DateRangeEditor({ value, onChange }: { value: unknown; onChange: (v: unknown) => void }) { + const range = value as { from?: string; to?: string } | undefined; + return ( +
+
+ + +
+ {(range?.from || range?.to) && ( + + )} +
+ ); +} + +export const CustomEditor: Story = { + name: 'Custom filter editor', + render: () => { + const buttonRef = useRef(null!); + const [isOpen, setIsOpen] = useState(false); + + const filters: FilterDefinition[] = [ + { + key: 'rating', + label: 'Rating', + type: 'custom', + renderEditor: ({ value, onChange }) => ( + + ), + }, + { + key: 'createdAt', + label: 'Created Date', + type: 'custom', + renderEditor: ({ value, onChange }) => ( + + ), + }, + ]; + + const { filterValues, rangeValues, customValues, expandedFilterKey, setExpandedFilterKey, handleToggleFilter, handleClearFilter, handleRangeChange, handleCustomValueChange } = + useFilterState(filters); + + const rating = customValues['rating']; + const dateRange = customValues['createdAt'] as { from?: string; to?: string } | undefined; + const hasFilters = (typeof rating === 'number' && rating > 0) || dateRange?.from || dateRange?.to; + + return ( +
+
+

Custom Filter Editors

+

+ Provide a renderEditor on any filter to replace the default UI with your own component. +

+
+
+ + {hasFilters && ( + + {typeof rating === 'number' && rating > 0 && `Rating ≥ ${rating} ★`} + {dateRange?.from && ` · From ${dateRange.from}`} + {dateRange?.to && ` · To ${dateRange.to}`} + + )} +
+ setIsOpen(false)} + onFilterToggle={handleToggleFilter} + onFilterClear={handleClearFilter} + onRangeChange={handleRangeChange} + onExpandedFilterChange={setExpandedFilterKey} + onCustomValueChange={handleCustomValueChange} + /> +
+ ); + }, +}; + +// --------------------------------------------------------------------------- +// Story: Mixed filter types (all types together) +// --------------------------------------------------------------------------- + +const salaryValues = Array.from({ length: 300 }, () => { + const u = Math.random() + Math.random() + Math.random(); + return Math.round(40_000 + (u / 3) * 160_000); +}); + +export const MixedFilters: Story = { + name: 'Mixed filter types', + render: () => { + const buttonRef = useRef(null!); + const [isOpen, setIsOpen] = useState(false); + const [search, setSearch] = useState(''); + + const filters: FilterDefinition[] = [ + { + key: 'department', + label: 'Department', + type: 'string', + multi: true, + options: [ + { key: 'engineering', label: 'Engineering', value: 'engineering', count: 120 }, + { key: 'product', label: 'Product', value: 'product', count: 45 }, + { key: 'design', label: 'Design', value: 'design', count: 32 }, + { key: 'marketing', label: 'Marketing', value: 'marketing', count: 28 }, + { key: 'sales', label: 'Sales', value: 'sales', count: 67 }, + ], + }, + { + key: 'status', + label: 'Status', + type: 'string', + options: [ + { key: 'active', label: 'Active', value: 'active', count: 235 }, + { key: 'on_leave', label: 'On Leave', value: 'on_leave', count: 28 }, + { key: 'contractor', label: 'Contractor', value: 'contractor', count: 57 }, + ], + }, + { + key: 'salary', + label: 'Salary', + type: 'number', + buckets: 15, + numericRange: { + min: 40_000, + max: 200_000, + values: salaryValues, + }, + }, + { + key: 'hired', + label: 'Hire Date', + type: 'custom', + renderEditor: ({ value, onChange }) => ( + + ), + }, + ]; + + const { filterValues, rangeValues, customValues, expandedFilterKey, setExpandedFilterKey, handleToggleFilter, handleClearFilter, handleRangeChange, handleCustomValueChange } = + useFilterState(filters); + + const activeFilterCount = + Object.values(filterValues).reduce((sum, s) => sum + s.size, 0) + + Object.values(rangeValues).filter(Boolean).length + + Object.values(customValues).filter((v) => v !== undefined && v !== null).length; + + return ( +
+
+

Mixed Filter Types

+

+ Single-select, multi-select, numeric range and custom editor in one panel, plus a search box. +

+
+
+ + {activeFilterCount > 0 && ( + + {activeFilterCount} active filter{activeFilterCount !== 1 ? 's' : ''} + + )} +
+ setIsOpen(false)} + onSearchChange={setSearch} + onFilterToggle={handleToggleFilter} + onFilterClear={handleClearFilter} + onRangeChange={handleRangeChange} + onExpandedFilterChange={setExpandedFilterKey} + onCustomValueChange={handleCustomValueChange} + /> +
+ ); + }, +}; diff --git a/Source/Filter/FilterPanel.tsx b/Source/Filter/FilterPanel.tsx new file mode 100644 index 0000000..26b9cc3 --- /dev/null +++ b/Source/Filter/FilterPanel.tsx @@ -0,0 +1,231 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { AnimatePresence, motion } from 'framer-motion'; +import type { + FilterDefinition, + FilterValues, + RangeValues, + CustomFilterValues, +} from './types'; +import { RangeHistogramFilter } from './RangeHistogramFilter'; +import './FilterPanel.css'; + +export interface FilterPanelProps { + /** Whether the panel is visible. */ + isOpen: boolean; + /** Filter definitions, each describing one filter group. */ + filters: FilterDefinition[]; + /** Current string/option selections, keyed by FilterDefinition.key. */ + filterValues: FilterValues; + /** Current numeric range selections, keyed by FilterDefinition.key. */ + rangeValues: RangeValues; + /** Current values for filters using renderEditor, keyed by FilterDefinition.key. */ + customValues?: CustomFilterValues; + /** Current search text shown in the search box. */ + search?: string; + /** Placeholder text for the search input. Defaults to 'Search…'. */ + searchPlaceholder?: string; + /** Which filter group is currently expanded. */ + expandedFilterKey?: string | null; + /** The button element the panel anchors below. */ + anchorRef: React.RefObject; + /** Called when the panel should close (e.g. click outside). */ + onClose: () => void; + /** Called when the search text changes. If omitted, the search box is hidden. */ + onSearchChange?: (value: string) => void; + /** Called when a string option is toggled. */ + onFilterToggle: (filterKey: string, optionKey: string, multi: boolean) => void; + /** Called when all selections for a filter are cleared. */ + onFilterClear: (filterKey: string) => void; + /** Called when a numeric range changes. */ + onRangeChange: (filterKey: string, range: [number, number] | null) => void; + /** Called when the expanded filter group changes. */ + onExpandedFilterChange: (key: string | null) => void; + /** Called when a custom-editor value changes. */ + onCustomValueChange?: (filterKey: string, value: unknown) => void; +} + +function renderOptionCount(count: number | undefined): string | number { + return typeof count === 'number' ? count : ''; +} + +export function FilterPanel({ + isOpen, + filters, + filterValues, + rangeValues, + customValues, + search, + searchPlaceholder = 'Search…', + expandedFilterKey, + anchorRef, + onClose, + onSearchChange, + onFilterToggle, + onFilterClear, + onRangeChange, + onExpandedFilterChange, + onCustomValueChange, +}: FilterPanelProps) { + const panelRef = useRef(null); + const [position, setPosition] = useState({ top: 0, left: 0 }); + + // Calculate position when opening + useEffect(() => { + if (isOpen && anchorRef.current) { + const rect = anchorRef.current.getBoundingClientRect(); + setPosition({ + top: rect.bottom + 8, + left: rect.left, + }); + } + }, [isOpen, anchorRef]); + + // Handle click outside to close + useEffect(() => { + if (!isOpen) return; + + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Node; + const panel = panelRef.current; + const anchor = anchorRef.current; + + if (panel && !panel.contains(target) && anchor && !anchor.contains(target)) { + onClose(); + } + }; + + // Use capture phase to ensure we catch the event before any other handlers. + // Use timeout to avoid closing immediately when clicking the button to open. + const timeoutId = setTimeout(() => { + document.addEventListener('mousedown', handleClickOutside, true); + }, 0); + + return () => { + clearTimeout(timeoutId); + document.removeEventListener('mousedown', handleClickOutside, true); + }; + }, [isOpen, anchorRef, onClose]); + + return createPortal( + + {isOpen && ( + +
+ {onSearchChange && ( +
+ onSearchChange(event.target.value)} + /> +
+ )} +
+ {filters.map((filter) => { + const selections = filterValues[filter.key] ?? new Set(); + const rangeSelection = rangeValues[filter.key]; + const customValue = customValues?.[filter.key]; + const isExpanded = expandedFilterKey === filter.key; + const isNumeric = filter.type === 'number'; + const isCustom = filter.type === 'custom' || filter.renderEditor !== undefined; + + return ( +
+ +
+ {isCustom && filter.renderEditor ? ( + filter.renderEditor({ + value: customValue, + onChange: (value) => onCustomValueChange?.(filter.key, value), + }) + ) : isNumeric && filter.numericRange ? ( + onRangeChange(filter.key, range)} + /> + ) : ( + <> +
    + {(filter.options ?? []).map((option) => { + const optionKey = option.key; + const checked = selections.has(optionKey); + return ( +
  • + +
  • + ); + })} +
+ {selections.size > 0 && ( + + )} + + )} +
+
+ ); + })} +
+
+
+ )} +
, + document.body + ); +} diff --git a/Source/Filter/RangeHistogramFilter.tsx b/Source/Filter/RangeHistogramFilter.tsx new file mode 100644 index 0000000..67b7e55 --- /dev/null +++ b/Source/Filter/RangeHistogramFilter.tsx @@ -0,0 +1,220 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { FilterValue } from './types'; + +export interface RangeHistogramFilterProps { + values: FilterValue[]; + min: number; + max: number; + buckets?: number; + selectedRange: [number, number] | null; + onChange: (range: [number, number] | null) => void; +} + +interface HistogramBucket { + start: number; + end: number; + count: number; + maxCount: number; +} + +export function RangeHistogramFilter({ + values, + min, + max, + buckets = 20, + selectedRange, + onChange, +}: RangeHistogramFilterProps) { + const containerRef = useRef(null); + const [isDragging, setIsDragging] = useState<'left' | 'right' | 'range' | null>(null); + const [dragStart, setDragStart] = useState<{ x: number; range: [number, number] } | null>(null); + + const numericValues = useMemo(() => { + return values + .map((v) => { + if (typeof v === 'number') return v; + if (v instanceof Date) return v.getTime(); + const parsed = Number(v); + return Number.isNaN(parsed) ? null : parsed; + }) + .filter((v): v is number => v !== null); + }, [values]); + + const histogram = useMemo((): HistogramBucket[] => { + const range = max - min; + if (range <= 0 || numericValues.length === 0) { + return []; + } + + const bucketSize = range / buckets; + const bucketCounts: number[] = Array(buckets).fill(0); + + numericValues.forEach((value) => { + const bucketIndex = Math.min( + Math.floor((value - min) / bucketSize), + buckets - 1 + ); + if (bucketIndex >= 0 && bucketIndex < buckets) { + bucketCounts[bucketIndex]++; + } + }); + + const maxCount = Math.max(...bucketCounts, 1); + + return bucketCounts.map((count, i) => ({ + start: min + i * bucketSize, + end: min + (i + 1) * bucketSize, + count, + maxCount, + })); + }, [numericValues, min, max, buckets]); + + const currentRange = selectedRange ?? [min, max]; + + const getPositionFromValue = useCallback( + (value: number) => { + const range = max - min; + if (range <= 0) return 0; + return ((value - min) / range) * 100; + }, + [min, max] + ); + + const handleMouseDown = ( + e: React.MouseEvent, + handle: 'left' | 'right' | 'range' + ) => { + e.preventDefault?.(); + setIsDragging(handle); + setDragStart({ x: e.clientX, range: [...currentRange] as [number, number] }); + }; + + useEffect(() => { + if (!isDragging || !dragStart || !containerRef.current) return; + + const container = containerRef.current; + const rect = container.getBoundingClientRect(); + const range = max - min; + + const handleMouseMove = (e: MouseEvent) => { + const deltaX = e.clientX - dragStart.x; + const deltaPercent = (deltaX / rect.width) * 100; + const deltaValue = (deltaPercent / 100) * range; + + let newRange: [number, number] = [...dragStart.range]; + + if (isDragging === 'left') { + newRange[0] = Math.max(min, Math.min(dragStart.range[0] + deltaValue, newRange[1] - range * 0.01)); + } else if (isDragging === 'right') { + newRange[1] = Math.min(max, Math.max(dragStart.range[1] + deltaValue, newRange[0] + range * 0.01)); + } else if (isDragging === 'range') { + const rangeWidth = dragStart.range[1] - dragStart.range[0]; + let newStart = dragStart.range[0] + deltaValue; + let newEnd = dragStart.range[1] + deltaValue; + + if (newStart < min) { + newStart = min; + newEnd = min + rangeWidth; + } + if (newEnd > max) { + newEnd = max; + newStart = max - rangeWidth; + } + + newRange = [newStart, newEnd]; + } + + onChange(newRange); + }; + + const handleMouseUp = () => { + setIsDragging(null); + setDragStart(null); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, [isDragging, dragStart, min, max, onChange]); + + const handleBarClick = (bucket: HistogramBucket) => { + onChange([bucket.start, bucket.end]); + }; + + const handleClear = () => { + onChange(null); + }; + + const leftPos = getPositionFromValue(currentRange[0]); + const rightPos = getPositionFromValue(currentRange[1]); + + const formatValue = (value: number) => { + if (Number.isInteger(value)) return value.toString(); + return value.toFixed(1); + }; + + return ( +
+
+ {histogram.map((bucket, i) => { + const heightPercent = (bucket.count / bucket.maxCount) * 100; + const isInRange = + bucket.start >= currentRange[0] && bucket.end <= currentRange[1]; + const isPartiallyInRange = + bucket.end > currentRange[0] && bucket.start < currentRange[1]; + + return ( +
+ +
+
+
handleMouseDown(e, 'range')} + /> +
handleMouseDown(e, 'left')} + /> +
handleMouseDown(e, 'right')} + /> +
+ +
+ {formatValue(currentRange[0])} + {formatValue(currentRange[1])} +
+ + {selectedRange && ( + + )} +
+ ); +} diff --git a/Source/Filter/index.ts b/Source/Filter/index.ts new file mode 100644 index 0000000..d8bc58c --- /dev/null +++ b/Source/Filter/index.ts @@ -0,0 +1,18 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +export { FilterPanel } from './FilterPanel'; +export type { FilterPanelProps } from './FilterPanel'; +export { RangeHistogramFilter } from './RangeHistogramFilter'; +export type { RangeHistogramFilterProps } from './RangeHistogramFilter'; +export { useFilterState } from './useFilterState'; +export type { UseFilterStateResult } from './useFilterState'; +export type { + FilterValue, + FilterOption, + FilterEditorProps, + FilterDefinition, + FilterValues, + RangeValues, + CustomFilterValues, +} from './types'; diff --git a/Source/Filter/types.ts b/Source/Filter/types.ts new file mode 100644 index 0000000..3349bd5 --- /dev/null +++ b/Source/Filter/types.ts @@ -0,0 +1,44 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type { ReactNode } from 'react'; + +export type FilterValue = string | number | boolean | Date | null | undefined; + +export interface FilterOption { + key: string; + label: string; + value: FilterValue; + count?: number; +} + +export interface FilterEditorProps { + value: unknown; + onChange: (value: unknown) => void; +} + +export interface FilterDefinition { + key: string; + label: string; + /** Filter type. Defaults to 'string'. Use 'number' for range/histogram. Use 'custom' with renderEditor for fully custom UI. */ + type?: 'string' | 'number' | 'date' | 'custom'; + /** Allow selecting multiple options (checkbox behaviour). Defaults to false (radio behaviour). */ + multi?: boolean; + /** Pre-computed options for string/date filters. */ + options?: FilterOption[]; + /** Numeric range data for 'number' type filters. */ + numericRange?: { min: number; max: number; values: FilterValue[] }; + /** Number of histogram buckets. Defaults to 20. */ + buckets?: number; + /** Custom editor renderer. Used for 'custom' type, or to override any other type. */ + renderEditor?: (props: FilterEditorProps) => ReactNode; +} + +/** Selected string/option values for each filter, keyed by FilterDefinition.key. */ +export type FilterValues = Record>; + +/** Selected numeric ranges for each filter, keyed by FilterDefinition.key. */ +export type RangeValues = Record; + +/** Custom values for filters that use renderEditor, keyed by FilterDefinition.key. */ +export type CustomFilterValues = Record; diff --git a/Source/Filter/useFilterState.ts b/Source/Filter/useFilterState.ts new file mode 100644 index 0000000..79e3d9b --- /dev/null +++ b/Source/Filter/useFilterState.ts @@ -0,0 +1,138 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { useCallback, useEffect, useState } from 'react'; +import type { FilterDefinition, FilterValues, RangeValues, CustomFilterValues } from './types'; + +function buildFilterValues(filters: FilterDefinition[] | undefined): FilterValues { + const state: FilterValues = {}; + filters?.forEach((filter) => { + if (!filter.type || filter.type === 'string' || filter.type === 'date') { + state[filter.key] = new Set(); + } + }); + return state; +} + +function buildRangeValues(filters: FilterDefinition[] | undefined): RangeValues { + const state: RangeValues = {}; + filters?.forEach((filter) => { + if (filter.type === 'number') { + state[filter.key] = null; + } + }); + return state; +} + +export interface UseFilterStateResult { + filterValues: FilterValues; + rangeValues: RangeValues; + customValues: CustomFilterValues; + expandedFilterKey: string | null; + setExpandedFilterKey: (key: string | null) => void; + handleToggleFilter: (filterKey: string, optionKey: string, multi: boolean) => void; + handleClearFilter: (filterKey: string) => void; + handleRangeChange: (filterKey: string, range: [number, number] | null) => void; + handleCustomValueChange: (filterKey: string, value: unknown) => void; +} + +/** + * State management hook for the standalone FilterPanel. + * + * Tracks selected options, numeric ranges, and custom values for every + * filter in the provided `filters` array. Pass the returned values and + * handlers directly to . + */ +export function useFilterState(filters: FilterDefinition[] | undefined): UseFilterStateResult { + const [filterValues, setFilterValues] = useState(() => buildFilterValues(filters)); + const [rangeValues, setRangeValues] = useState(() => buildRangeValues(filters)); + const [customValues, setCustomValues] = useState({}); + const [expandedFilterKey, setExpandedFilterKey] = useState( + filters?.[0]?.key ?? null + ); + + // Sync state when the filter definitions change + useEffect(() => { + setFilterValues((prev) => { + const next = buildFilterValues(filters); + if (!filters) return next; + filters.forEach((filter) => { + if (prev[filter.key]) { + next[filter.key] = new Set(prev[filter.key]); + } + }); + return next; + }); + + setRangeValues((prev) => { + const next = buildRangeValues(filters); + if (!filters) return next; + filters.forEach((filter) => { + if (filter.type === 'number' && filter.key in prev) { + next[filter.key] = prev[filter.key]; + } + }); + return next; + }); + }, [filters]); + + useEffect(() => { + if (!filters?.length) { + setExpandedFilterKey(null); + return; + } + setExpandedFilterKey((current) => { + if (current && filters.some((f) => f.key === current)) return current; + return filters[0]?.key ?? null; + }); + }, [filters]); + + const handleToggleFilter = useCallback((filterKey: string, optionKey: string, multi: boolean) => { + setFilterValues((prev) => { + const next: FilterValues = { ...prev }; + const current = new Set(prev[filterKey] ?? []); + + if (multi) { + if (current.has(optionKey)) { + current.delete(optionKey); + } else { + current.add(optionKey); + } + } else { + if (current.has(optionKey)) { + current.clear(); + } else { + current.clear(); + current.add(optionKey); + } + } + + next[filterKey] = current; + return next; + }); + }, []); + + const handleClearFilter = useCallback((filterKey: string) => { + setFilterValues((prev) => ({ ...prev, [filterKey]: new Set() })); + }, []); + + const handleRangeChange = useCallback((filterKey: string, range: [number, number] | null) => { + setRangeValues((prev) => ({ ...prev, [filterKey]: range })); + }, []); + + const handleCustomValueChange = useCallback((filterKey: string, value: unknown) => { + setCustomValues((prev) => ({ ...prev, [filterKey]: value })); + }, []); + + return { + filterValues, + rangeValues, + customValues, + expandedFilterKey, + setExpandedFilterKey, + handleToggleFilter, + handleClearFilter, + handleRangeChange, + handleCustomValueChange, + }; +} diff --git a/Source/PivotViewer/components/FilterPanel.tsx b/Source/PivotViewer/components/FilterPanel.tsx index 209c71f..f2540fe 100644 --- a/Source/PivotViewer/components/FilterPanel.tsx +++ b/Source/PivotViewer/components/FilterPanel.tsx @@ -1,13 +1,10 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -import { useEffect, useRef, useState } from 'react'; -import { createPortal } from 'react-dom'; -import { AnimatePresence, motion } from 'framer-motion'; +import { FilterPanel as StandaloneFilterPanel } from '../../Filter/FilterPanel'; +import type { FilterDefinition } from '../../Filter/types'; import type { PivotFilter, PivotFilterOption, PivotPrimitive } from '../types'; import type { FilterState, RangeFilterState } from '../utils/utils'; -import { renderOptionCount } from '../utils/utils'; -import { RangeHistogramFilter } from './RangeHistogramFilter'; export interface FilterPanelProps { isOpen: boolean; @@ -44,146 +41,40 @@ export function FilterPanel({ onRangeChange, onExpandedFilterChange, }: FilterPanelProps) { - const panelRef = useRef(null); - const [position, setPosition] = useState({ top: 0, left: 0 }); + // Adapt from PivotViewer-specific types to the generic FilterDefinition format + const filters: FilterDefinition[] = filterOptions.map(({ filter, options, numericRange }) => ({ + key: filter.key, + label: filter.label, + type: filter.type, + multi: filter.multi, + options: options.map((o) => ({ + key: o.key, + label: o.label, + value: o.value, + count: o.count, + })), + numericRange, + buckets: filter.buckets, + renderEditor: filter.renderEditor, + })); - // Calculate position when opening - useEffect(() => { - if (isOpen && anchorRef.current) { - const rect = anchorRef.current.getBoundingClientRect(); - setPosition({ - top: rect.bottom + 8, - left: rect.left, - }); - } - }, [isOpen, anchorRef]); - - // Handle click outside to close - useEffect(() => { - if (!isOpen) return; - - const handleClickOutside = (event: MouseEvent) => { - const target = event.target as Node; - const panel = panelRef.current; - const anchor = anchorRef.current; - - if (panel && !panel.contains(target) && anchor && !anchor.contains(target)) { - onClose(); - } - }; - - // Use capture phase to ensure we catch the event before any other handlers - // Use timeout to avoid closing immediately when clicking the button to open - const timeoutId = setTimeout(() => { - document.addEventListener('mousedown', handleClickOutside, true); - }, 0); - - return () => { - clearTimeout(timeoutId); - document.removeEventListener('mousedown', handleClickOutside, true); - }; - }, [isOpen, anchorRef, onClose]); - - return createPortal( - - {isOpen && ( - -
-
- onSearchChange(event.target.value)} - /> -
-
- {filterOptions.map(({ filter, options, numericRange }) => { - const selections = filterState[filter.key] ?? new Set(); - const rangeSelection = rangeFilterState[filter.key]; - const isExpanded = expandedFilterKey === filter.key; - const isNumeric = filter.type === 'number'; - - return ( -
- -
- {isNumeric && numericRange ? ( - onRangeChange(filter.key, range)} - /> - ) : ( - <> -
    - {options.map((option) => { - const optionKey = option.key; - const checked = selections.has(optionKey); - return ( -
  • - -
  • - ); - })} -
- {selections.size > 0 && ( - - )} - - )} -
-
- ); - })} -
-
-
- )} -
, - document.body + return ( + ); } + diff --git a/Source/PivotViewer/types.ts b/Source/PivotViewer/types.ts index ccad154..20c6e30 100644 --- a/Source/PivotViewer/types.ts +++ b/Source/PivotViewer/types.ts @@ -2,6 +2,7 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. import type { ReactNode } from 'react'; +import type { FilterEditorProps } from '../Filter/types'; export type PivotPrimitive = string | number | boolean | Date | null | undefined; @@ -68,9 +69,11 @@ export interface PivotFilter { options?: PivotFilterOption[]; sort?: (a: PivotFilterOption, b: PivotFilterOption) => number; /** For numeric filters, enables range picker with histogram */ - type?: 'string' | 'number' | 'date'; + type?: 'string' | 'number' | 'date' | 'custom'; /** Number of buckets for the histogram in range filters */ buckets?: number; + /** Custom filter editor renderer. When provided, replaces the default filter UI for this filter. */ + renderEditor?: (props: FilterEditorProps) => ReactNode; } export interface PivotViewerProps { diff --git a/Source/index.ts b/Source/index.ts index aabe187..e38edc6 100644 --- a/Source/index.ts +++ b/Source/index.ts @@ -9,6 +9,7 @@ import * as DataPage from './DataPage'; import * as DataTables from './DataTables'; import * as Dialogs from './Dialogs'; import * as Dropdown from './Dropdown'; +import * as Filter from './Filter'; import * as ObjectContentEditor from './ObjectContentEditor'; import * as ObjectNavigationalBar from './ObjectNavigationalBar'; import * as PivotViewer from './PivotViewer'; @@ -26,6 +27,7 @@ export { DataTables, Dialogs, Dropdown, + Filter, ObjectContentEditor, ObjectNavigationalBar, PivotViewer, diff --git a/Source/package.json b/Source/package.json index 2a2885a..bbf356d 100644 --- a/Source/package.json +++ b/Source/package.json @@ -74,6 +74,11 @@ "require": "./dist/cjs/Dropdown/index.js", "import": "./dist/esm/Dropdown/index.js" }, + "./Filter": { + "types": "./dist/esm/Filter/index.d.ts", + "require": "./dist/cjs/Filter/index.js", + "import": "./dist/esm/Filter/index.js" + }, "./ObjectContentEditor": { "types": "./dist/esm/ObjectContentEditor/index.d.ts", "require": "./dist/cjs/ObjectContentEditor/index.js", @@ -127,13 +132,13 @@ "build-storybook": "storybook build" }, "dependencies": { - "react-icons": "5.6.0", - "ts-deepmerge": "8.0.0", "allotment": "1.20.5", "framer-motion": "12.40.0", "pixi.js": "^8.17.1", "primeicons": "7.0.0", - "primereact": "10.9.8" + "primereact": "10.9.8", + "react-icons": "5.6.0", + "ts-deepmerge": "8.0.0" }, "devDependencies": { "@cratis/arc.vite": "^20.3.1" From b9fd46514c0ea6605d0c231624099b1b2395fa6f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 1 Jun 2026 18:31:36 +0000 Subject: [PATCH 3/6] feat: Add documentation, specs, and Storybook build fix for FilterPanel --- Documentation/Filter/index.md | 225 ++++++++++++++++++ Documentation/Filter/toc.yml | 2 + Documentation/toc.yml | 18 ++ Source/.storybook/main.ts | 1 + ...ter_values_excluding_non_string_filters.ts | 34 +++ ...lding_filter_values_with_string_filters.ts | 38 +++ ...ng_range_values_with_no_numeric_filters.ts | 21 ++ ...lding_range_values_with_numeric_filters.ts | 36 +++ Source/Filter/index.ts | 1 + Source/Filter/useFilterState.ts | 21 +- Source/Filter/utils.ts | 26 ++ 11 files changed, 403 insertions(+), 20 deletions(-) create mode 100644 Documentation/Filter/index.md create mode 100644 Documentation/Filter/toc.yml create mode 100644 Source/Filter/for_useFilterState/when_building_filter_values_excluding_non_string_filters.ts create mode 100644 Source/Filter/for_useFilterState/when_building_filter_values_with_string_filters.ts create mode 100644 Source/Filter/for_useFilterState/when_building_range_values_with_no_numeric_filters.ts create mode 100644 Source/Filter/for_useFilterState/when_building_range_values_with_numeric_filters.ts create mode 100644 Source/Filter/utils.ts diff --git a/Documentation/Filter/index.md b/Documentation/Filter/index.md new file mode 100644 index 0000000..94cf387 --- /dev/null +++ b/Documentation/Filter/index.md @@ -0,0 +1,225 @@ +# FilterPanel + +The `FilterPanel` component provides a standalone, reusable filter UI that can be placed next to any data view. It renders as a positioned dropdown anchored below a trigger button and supports single-select, multi-select, numeric range (with histogram), and fully custom filter editors. + +## Components and Exports + +| Export | Description | +|---|---| +| `FilterPanel` | Main dropdown panel component | +| `RangeHistogramFilter` | Standalone numeric range slider with histogram bars | +| `useFilterState` | State management hook — tracks selections, ranges, and custom values | +| `FilterDefinition` | Type describing a single filter group | +| `FilterEditorProps` | Props passed to a custom `renderEditor` function | +| `FilterValues` | `Record>` — selected option keys per filter | +| `RangeValues` | `Record` — selected ranges per filter | +| `CustomFilterValues` | `Record` — values for custom editor filters | + +## Quick Start + +```tsx +import { FilterPanel, useFilterState } from '@cratis/components/Filter'; +import type { FilterDefinition } from '@cratis/components/Filter'; + +const filters: FilterDefinition[] = [ + { + key: 'status', + label: 'Status', + type: 'string', + options: [ + { key: 'active', label: 'Active', value: 'active', count: 42 }, + { key: 'inactive', label: 'Inactive', value: 'inactive', count: 18 }, + ], + }, +]; + +function MyView() { + const buttonRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); + + const { + filterValues, + rangeValues, + expandedFilterKey, + setExpandedFilterKey, + handleToggleFilter, + handleClearFilter, + handleRangeChange, + } = useFilterState(filters); + + return ( + <> + + setIsOpen(false)} + onFilterToggle={handleToggleFilter} + onFilterClear={handleClearFilter} + onRangeChange={handleRangeChange} + onExpandedFilterChange={setExpandedFilterKey} + /> + + ); +} +``` + +## Filter Types + +### Single-select (`type: 'string'`, `multi: false`) + +Renders a radio-button list. Clicking an already-selected option deselects it. + +```tsx +{ + key: 'status', + label: 'Status', + type: 'string', + options: [ + { key: 'active', label: 'Active', value: 'active', count: 42 }, + ], +} +``` + +### Multi-select (`type: 'string'`, `multi: true`) + +Renders a checkbox list — multiple values may be selected simultaneously. + +```tsx +{ + key: 'department', + label: 'Department', + type: 'string', + multi: true, + options: [ + { key: 'engineering', label: 'Engineering', value: 'engineering', count: 120 }, + { key: 'design', label: 'Design', value: 'design', count: 32 }, + ], +} +``` + +### Numeric range with histogram (`type: 'number'`) + +Renders a `RangeHistogramFilter` — a draggable range slider overlaid on a histogram of the actual data distribution. + +```tsx +{ + key: 'salary', + label: 'Salary', + type: 'number', + buckets: 15, + numericRange: { + min: 40_000, + max: 200_000, + values: salaryDataPoints, // FilterValue[] used to draw the histogram + }, +} +``` + +### Custom editor (`type: 'custom'`) + +Provide a `renderEditor` function to replace the default UI entirely. The value is stored in `customValues` keyed by the filter's `key`. + +```tsx +{ + key: 'rating', + label: 'Rating', + type: 'custom', + renderEditor: ({ value, onChange }) => ( + + ), +} +``` + +Pass `customValues` and `onCustomValueChange` to `FilterPanel` when using custom editors: + +```tsx +const { customValues, handleCustomValueChange, ...rest } = useFilterState(filters); + + +``` + +## `FilterPanel` Props + +| Prop | Type | Required | Description | +|---|---|---|---| +| `isOpen` | `boolean` | ✓ | Whether the panel is visible | +| `filters` | `FilterDefinition[]` | ✓ | Filter group definitions | +| `filterValues` | `FilterValues` | ✓ | Current string/option selections | +| `rangeValues` | `RangeValues` | ✓ | Current numeric range selections | +| `customValues` | `CustomFilterValues` | — | Values for custom-editor filters | +| `search` | `string` | — | Current search-box value | +| `searchPlaceholder` | `string` | — | Placeholder for search input (default: `'Search…'`) | +| `expandedFilterKey` | `string \| null` | — | Which filter group is open | +| `anchorRef` | `RefObject` | ✓ | Button the panel anchors below | +| `onClose` | `() => void` | ✓ | Called when panel should close | +| `onSearchChange` | `(value: string) => void` | — | If provided, shows a search box | +| `onFilterToggle` | `(filterKey, optionKey, multi) => void` | ✓ | Called when an option is toggled | +| `onFilterClear` | `(filterKey) => void` | ✓ | Called when all selections for a filter are cleared | +| `onRangeChange` | `(filterKey, range) => void` | ✓ | Called when a numeric range changes | +| `onExpandedFilterChange` | `(key \| null) => void` | ✓ | Called when the expanded group changes | +| `onCustomValueChange` | `(filterKey, value) => void` | — | Called when a custom editor value changes | + +## `useFilterState` Hook + +`useFilterState(filters)` initialises and manages all filter state in one call. Its return value can be spread directly into `FilterPanel`: + +```tsx +const state = useFilterState(filters); + + setOpen(false)} + onFilterToggle={state.handleToggleFilter} + onFilterClear={state.handleClearFilter} + onRangeChange={state.handleRangeChange} + onExpandedFilterChange={state.setExpandedFilterKey} + onCustomValueChange={state.handleCustomValueChange} +/> +``` + +The hook re-syncs state when the `filters` array reference changes — existing selections are preserved for filter keys that are still present. + +## `RangeHistogramFilter` Props + +`RangeHistogramFilter` can also be used standalone, independently of `FilterPanel`. + +| Prop | Type | Required | Description | +|---|---|---|---| +| `values` | `FilterValue[]` | ✓ | Raw data values used to compute the histogram | +| `min` | `number` | ✓ | Lower bound of the full range | +| `max` | `number` | ✓ | Upper bound of the full range | +| `buckets` | `number` | ✓ | Number of histogram bars | +| `selectedRange` | `[number, number] \| null` | ✓ | Currently selected range, or `null` for none | +| `onChange` | `(range: [number, number] \| null) => void` | ✓ | Called when the range changes | + +## Importing + +The Filter module is available at its own subpath — you do not need to import from the root package: + +```tsx +import { FilterPanel, useFilterState } from '@cratis/components/Filter'; +import type { FilterDefinition } from '@cratis/components/Filter'; +``` + +It is also re-exported from the package root: + +```tsx +import { FilterPanel, useFilterState } from '@cratis/components'; +``` diff --git a/Documentation/Filter/toc.yml b/Documentation/Filter/toc.yml new file mode 100644 index 0000000..d13fc60 --- /dev/null +++ b/Documentation/Filter/toc.yml @@ -0,0 +1,2 @@ +- name: FilterPanel + href: index.md diff --git a/Documentation/toc.yml b/Documentation/toc.yml index aca6a09..8e927e3 100644 --- a/Documentation/toc.yml +++ b/Documentation/toc.yml @@ -128,6 +128,22 @@ - name: Simple Schema href: storybook.md?story=components-schemaeditor--simple-schema href: storybook.md?story=components-objectcontenteditor--default + - name: Filter + items: + - name: FilterPanel + href: storybook.md?story=filter-filterpanel--single-select-filter + items: + - name: Single-select string filter + href: storybook.md?story=filter-filterpanel--single-select-filter + - name: Multi-select string filter + href: storybook.md?story=filter-filterpanel--multi-select-filter + - name: Numeric range filter (histogram) + href: storybook.md?story=filter-filterpanel--numeric-range-filter + - name: Custom filter editor + href: storybook.md?story=filter-filterpanel--custom-editor + - name: Mixed filter types + href: storybook.md?story=filter-filterpanel--mixed-filters + href: storybook.md?story=filter-filterpanel--single-select-filter - name: PivotViewer items: - name: Default @@ -202,6 +218,8 @@ href: Dialogs/toc.yml - name: Dropdown href: Dropdown/index.md + - name: FilterPanel + href: Filter/toc.yml - name: Toolbar href: Toolbar/toc.yml - name: Specialized Components diff --git a/Source/.storybook/main.ts b/Source/.storybook/main.ts index 5ec4cfa..dee5562 100644 --- a/Source/.storybook/main.ts +++ b/Source/.storybook/main.ts @@ -22,6 +22,7 @@ const config: StorybookConfig = { async viteFinal(existingConfig: ViteConfig) { const cfg: ViteConfig = { ...existingConfig }; cfg.server = { ...(cfg.server || {}), open: false } as unknown; + cfg.build = { ...(cfg.build || {}), cssMinify: false }; return cfg; } }; diff --git a/Source/Filter/for_useFilterState/when_building_filter_values_excluding_non_string_filters.ts b/Source/Filter/for_useFilterState/when_building_filter_values_excluding_non_string_filters.ts new file mode 100644 index 0000000..db03cdb --- /dev/null +++ b/Source/Filter/for_useFilterState/when_building_filter_values_excluding_non_string_filters.ts @@ -0,0 +1,34 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { buildFilterValues } from '../utils'; +import type { FilterDefinition } from '../types'; + +describe('when building filter values excluding non-string filters', () => { + let result: ReturnType; + + beforeEach(() => { + const filters: FilterDefinition[] = [ + { key: 'name', label: 'Name', type: 'string', options: [] }, + { key: 'salary', label: 'Salary', type: 'number' }, + { key: 'hired', label: 'Hired', type: 'custom' }, + ]; + result = buildFilterValues(filters); + }); + + it('should only include string-typed filters', () => { + Object.keys(result).should.have.lengthOf(1); + }); + + it('should include the string filter key', () => { + ('name' in result).should.be.true; + }); + + it('should not include the number filter key', () => { + ('salary' in result).should.be.false; + }); + + it('should not include the custom filter key', () => { + ('hired' in result).should.be.false; + }); +}); diff --git a/Source/Filter/for_useFilterState/when_building_filter_values_with_string_filters.ts b/Source/Filter/for_useFilterState/when_building_filter_values_with_string_filters.ts new file mode 100644 index 0000000..2078f5e --- /dev/null +++ b/Source/Filter/for_useFilterState/when_building_filter_values_with_string_filters.ts @@ -0,0 +1,38 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { buildFilterValues } from '../utils'; +import type { FilterDefinition } from '../types'; + +describe('when building filter values with string filters', () => { + let result: ReturnType; + + beforeEach(() => { + const filters: FilterDefinition[] = [ + { + key: 'status', + label: 'Status', + type: 'string', + options: [{ key: 'active', label: 'Active', value: 'active' }], + }, + { + key: 'category', + label: 'Category', + type: 'string', + options: [{ key: 'a', label: 'A', value: 'a' }], + }, + ]; + result = buildFilterValues(filters); + }); + + it('should create an entry for each string filter key', () => { + Object.keys(result).should.have.lengthOf(2); + }); + + it('should initialise each entry as an empty Set', () => { + result['status'].should.be.instanceOf(Set); + result['status'].size.should.equal(0); + result['category'].should.be.instanceOf(Set); + result['category'].size.should.equal(0); + }); +}); diff --git a/Source/Filter/for_useFilterState/when_building_range_values_with_no_numeric_filters.ts b/Source/Filter/for_useFilterState/when_building_range_values_with_no_numeric_filters.ts new file mode 100644 index 0000000..8bd7eea --- /dev/null +++ b/Source/Filter/for_useFilterState/when_building_range_values_with_no_numeric_filters.ts @@ -0,0 +1,21 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { buildRangeValues } from '../utils'; +import type { FilterDefinition } from '../types'; + +describe('when building range values with no numeric filters', () => { + let result: ReturnType; + + beforeEach(() => { + const filters: FilterDefinition[] = [ + { key: 'status', label: 'Status', type: 'string', options: [] }, + { key: 'hired', label: 'Hired', type: 'custom' }, + ]; + result = buildRangeValues(filters); + }); + + it('should return an empty object', () => { + Object.keys(result).should.have.lengthOf(0); + }); +}); diff --git a/Source/Filter/for_useFilterState/when_building_range_values_with_numeric_filters.ts b/Source/Filter/for_useFilterState/when_building_range_values_with_numeric_filters.ts new file mode 100644 index 0000000..68a8d2b --- /dev/null +++ b/Source/Filter/for_useFilterState/when_building_range_values_with_numeric_filters.ts @@ -0,0 +1,36 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { buildRangeValues } from '../utils'; +import type { FilterDefinition } from '../types'; + +describe('when building range values with numeric filters', () => { + let result: ReturnType; + + beforeEach(() => { + const filters: FilterDefinition[] = [ + { + key: 'price', + label: 'Price', + type: 'number', + numericRange: { min: 0, max: 100, values: [] }, + }, + { + key: 'age', + label: 'Age', + type: 'number', + numericRange: { min: 18, max: 65, values: [] }, + }, + ]; + result = buildRangeValues(filters); + }); + + it('should create an entry for each numeric filter key', () => { + Object.keys(result).should.have.lengthOf(2); + }); + + it('should initialise each numeric range as null', () => { + (result['price'] === null).should.be.true; + (result['age'] === null).should.be.true; + }); +}); diff --git a/Source/Filter/index.ts b/Source/Filter/index.ts index d8bc58c..1ab02b2 100644 --- a/Source/Filter/index.ts +++ b/Source/Filter/index.ts @@ -7,6 +7,7 @@ export { RangeHistogramFilter } from './RangeHistogramFilter'; export type { RangeHistogramFilterProps } from './RangeHistogramFilter'; export { useFilterState } from './useFilterState'; export type { UseFilterStateResult } from './useFilterState'; +export { buildFilterValues, buildRangeValues } from './utils'; export type { FilterValue, FilterOption, diff --git a/Source/Filter/useFilterState.ts b/Source/Filter/useFilterState.ts index 79e3d9b..9ede428 100644 --- a/Source/Filter/useFilterState.ts +++ b/Source/Filter/useFilterState.ts @@ -3,26 +3,7 @@ import { useCallback, useEffect, useState } from 'react'; import type { FilterDefinition, FilterValues, RangeValues, CustomFilterValues } from './types'; - -function buildFilterValues(filters: FilterDefinition[] | undefined): FilterValues { - const state: FilterValues = {}; - filters?.forEach((filter) => { - if (!filter.type || filter.type === 'string' || filter.type === 'date') { - state[filter.key] = new Set(); - } - }); - return state; -} - -function buildRangeValues(filters: FilterDefinition[] | undefined): RangeValues { - const state: RangeValues = {}; - filters?.forEach((filter) => { - if (filter.type === 'number') { - state[filter.key] = null; - } - }); - return state; -} +import { buildFilterValues, buildRangeValues } from './utils'; export interface UseFilterStateResult { filterValues: FilterValues; diff --git a/Source/Filter/utils.ts b/Source/Filter/utils.ts new file mode 100644 index 0000000..d101468 --- /dev/null +++ b/Source/Filter/utils.ts @@ -0,0 +1,26 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type { FilterDefinition, FilterValues, RangeValues } from './types'; + +/** Initialise the string/option selection map for all string/date filters. */ +export function buildFilterValues(filters: FilterDefinition[] | undefined): FilterValues { + const state: FilterValues = {}; + filters?.forEach((filter) => { + if (!filter.type || filter.type === 'string' || filter.type === 'date') { + state[filter.key] = new Set(); + } + }); + return state; +} + +/** Initialise the numeric range map for all number filters. */ +export function buildRangeValues(filters: FilterDefinition[] | undefined): RangeValues { + const state: RangeValues = {}; + filters?.forEach((filter) => { + if (filter.type === 'number') { + state[filter.key] = null; + } + }); + return state; +} From 50bbadd32fd4024d234e8376716b8d6de3fd2744 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 1 Jun 2026 19:10:05 +0000 Subject: [PATCH 4/6] Apply remaining changes --- Source/Filter/FilterEditor.tsx | 34 +++++++++++++ Source/Filter/FilterPanel.stories.tsx | 33 ++++++++----- Source/Filter/FilterPanel.tsx | 48 +++++++++++++++++-- Source/Filter/index.ts | 2 + Source/Filter/types.ts | 9 ++-- Source/PivotViewer/components/FilterPanel.tsx | 12 ++++- 6 files changed, 114 insertions(+), 24 deletions(-) create mode 100644 Source/Filter/FilterEditor.tsx diff --git a/Source/Filter/FilterEditor.tsx b/Source/Filter/FilterEditor.tsx new file mode 100644 index 0000000..2105549 --- /dev/null +++ b/Source/Filter/FilterEditor.tsx @@ -0,0 +1,34 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type { ReactNode } from 'react'; +import type { FilterEditorProps } from './types'; + +export interface FilterEditorSlotProps { + /** Must match the `key` of the corresponding `FilterDefinition`. */ + filterKey: string; + /** Render prop that receives `{ value, onChange }` and returns the editor UI. */ + children: (props: FilterEditorProps) => ReactNode; +} + +/** + * Declares a custom editor for a specific filter group inside ``. + * + * Place one or more `` elements as children of ``. + * The panel will slot each editor into the filter group whose key matches + * the `filterKey` prop. + * + * ```tsx + * + * + * {({ value, onChange }) => } + * + * + * ``` + * + * This component renders nothing itself — it is only used as a slot descriptor + * by `FilterPanel`. + */ +export function FilterEditor(_props: FilterEditorSlotProps): null { + return null; +} diff --git a/Source/Filter/FilterPanel.stories.tsx b/Source/Filter/FilterPanel.stories.tsx index 46a8aa8..6ef5a77 100644 --- a/Source/Filter/FilterPanel.stories.tsx +++ b/Source/Filter/FilterPanel.stories.tsx @@ -4,6 +4,7 @@ import React, { useRef, useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; import { FilterPanel } from './FilterPanel'; +import { FilterEditor } from './FilterEditor'; import { useFilterState } from './useFilterState'; import type { FilterDefinition } from './types'; @@ -388,17 +389,11 @@ export const CustomEditor: Story = { key: 'rating', label: 'Rating', type: 'custom', - renderEditor: ({ value, onChange }) => ( - - ), }, { key: 'createdAt', label: 'Created Date', type: 'custom', - renderEditor: ({ value, onChange }) => ( - - ), }, ]; @@ -414,7 +409,7 @@ export const CustomEditor: Story = {

Custom Filter Editors

- Provide a renderEditor on any filter to replace the default UI with your own component. + Declare a <FilterEditor> child inside <FilterPanel> to provide a custom editor for any filter group.

@@ -447,7 +442,18 @@ export const CustomEditor: Story = { onRangeChange={handleRangeChange} onExpandedFilterChange={setExpandedFilterKey} onCustomValueChange={handleCustomValueChange} - /> + > + + {({ value, onChange }) => ( + + )} + + + {({ value, onChange }) => ( + + )} + +
); }, @@ -508,9 +514,6 @@ export const MixedFilters: Story = { key: 'hired', label: 'Hire Date', type: 'custom', - renderEditor: ({ value, onChange }) => ( - - ), }, ]; @@ -561,7 +564,13 @@ export const MixedFilters: Story = { onRangeChange={handleRangeChange} onExpandedFilterChange={setExpandedFilterKey} onCustomValueChange={handleCustomValueChange} - /> + > + + {({ value, onChange }) => ( + + )} + +
); }, diff --git a/Source/Filter/FilterPanel.tsx b/Source/Filter/FilterPanel.tsx index 26b9cc3..6ff0232 100644 --- a/Source/Filter/FilterPanel.tsx +++ b/Source/Filter/FilterPanel.tsx @@ -1,15 +1,18 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -import { useEffect, useRef, useState } from 'react'; +import { Children, isValidElement, useEffect, useRef, useState } from 'react'; +import type { ReactNode } from 'react'; import { createPortal } from 'react-dom'; import { AnimatePresence, motion } from 'framer-motion'; import type { FilterDefinition, + FilterEditorProps, FilterValues, RangeValues, CustomFilterValues, } from './types'; +import { FilterEditor } from './FilterEditor'; import { RangeHistogramFilter } from './RangeHistogramFilter'; import './FilterPanel.css'; @@ -22,7 +25,7 @@ export interface FilterPanelProps { filterValues: FilterValues; /** Current numeric range selections, keyed by FilterDefinition.key. */ rangeValues: RangeValues; - /** Current values for filters using renderEditor, keyed by FilterDefinition.key. */ + /** Current values for filters using a custom `` child, keyed by FilterDefinition.key. */ customValues?: CustomFilterValues; /** Current search text shown in the search box. */ search?: string; @@ -46,6 +49,37 @@ export interface FilterPanelProps { onExpandedFilterChange: (key: string | null) => void; /** Called when a custom-editor value changes. */ onCustomValueChange?: (filterKey: string, value: unknown) => void; + /** + * `` elements that provide custom UI for specific filter groups. + * + * ```tsx + * + * + * {({ value, onChange }) => } + * + * + * ``` + */ + children?: ReactNode; +} + +/** Build a map of filterKey → render function from any children. */ +function buildEditorMap( + children: ReactNode | undefined +): Record ReactNode> { + const map: Record ReactNode> = {}; + Children.forEach(children, (child) => { + if (isValidElement(child) && child.type === FilterEditor) { + const { filterKey, children: renderFn } = child.props as { + filterKey: string; + children: (props: FilterEditorProps) => ReactNode; + }; + if (filterKey && typeof renderFn === 'function') { + map[filterKey] = renderFn; + } + } + }); + return map; } function renderOptionCount(count: number | undefined): string | number { @@ -69,10 +103,13 @@ export function FilterPanel({ onRangeChange, onExpandedFilterChange, onCustomValueChange, + children, }: FilterPanelProps) { const panelRef = useRef(null); const [position, setPosition] = useState({ top: 0, left: 0 }); + const editorMap = buildEditorMap(children); + // Calculate position when opening useEffect(() => { if (isOpen && anchorRef.current) { @@ -144,7 +181,8 @@ export function FilterPanel({ const customValue = customValues?.[filter.key]; const isExpanded = expandedFilterKey === filter.key; const isNumeric = filter.type === 'number'; - const isCustom = filter.type === 'custom' || filter.renderEditor !== undefined; + const editorRender = editorMap[filter.key]; + const isCustom = filter.type === 'custom' || editorRender !== undefined; return (
@@ -168,8 +206,8 @@ export function FilterPanel({
- {isCustom && filter.renderEditor ? ( - filter.renderEditor({ + {isCustom && editorRender ? ( + editorRender({ value: customValue, onChange: (value) => onCustomValueChange?.(filter.key, value), }) diff --git a/Source/Filter/index.ts b/Source/Filter/index.ts index 1ab02b2..8073dc5 100644 --- a/Source/Filter/index.ts +++ b/Source/Filter/index.ts @@ -3,6 +3,8 @@ export { FilterPanel } from './FilterPanel'; export type { FilterPanelProps } from './FilterPanel'; +export { FilterEditor } from './FilterEditor'; +export type { FilterEditorSlotProps } from './FilterEditor'; export { RangeHistogramFilter } from './RangeHistogramFilter'; export type { RangeHistogramFilterProps } from './RangeHistogramFilter'; export { useFilterState } from './useFilterState'; diff --git a/Source/Filter/types.ts b/Source/Filter/types.ts index 3349bd5..11ae41c 100644 --- a/Source/Filter/types.ts +++ b/Source/Filter/types.ts @@ -1,8 +1,6 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -import type { ReactNode } from 'react'; - export type FilterValue = string | number | boolean | Date | null | undefined; export interface FilterOption { @@ -20,7 +18,10 @@ export interface FilterEditorProps { export interface FilterDefinition { key: string; label: string; - /** Filter type. Defaults to 'string'. Use 'number' for range/histogram. Use 'custom' with renderEditor for fully custom UI. */ + /** + * Filter type. Defaults to 'string'. Use 'number' for range/histogram. + * Use 'custom' when providing a `` child inside `` for fully custom UI. + */ type?: 'string' | 'number' | 'date' | 'custom'; /** Allow selecting multiple options (checkbox behaviour). Defaults to false (radio behaviour). */ multi?: boolean; @@ -30,8 +31,6 @@ export interface FilterDefinition { numericRange?: { min: number; max: number; values: FilterValue[] }; /** Number of histogram buckets. Defaults to 20. */ buckets?: number; - /** Custom editor renderer. Used for 'custom' type, or to override any other type. */ - renderEditor?: (props: FilterEditorProps) => ReactNode; } /** Selected string/option values for each filter, keyed by FilterDefinition.key. */ diff --git a/Source/PivotViewer/components/FilterPanel.tsx b/Source/PivotViewer/components/FilterPanel.tsx index f2540fe..8b499e2 100644 --- a/Source/PivotViewer/components/FilterPanel.tsx +++ b/Source/PivotViewer/components/FilterPanel.tsx @@ -2,6 +2,7 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. import { FilterPanel as StandaloneFilterPanel } from '../../Filter/FilterPanel'; +import { FilterEditor } from '../../Filter/FilterEditor'; import type { FilterDefinition } from '../../Filter/types'; import type { PivotFilter, PivotFilterOption, PivotPrimitive } from '../types'; import type { FilterState, RangeFilterState } from '../utils/utils'; @@ -55,7 +56,6 @@ export function FilterPanel({ })), numericRange, buckets: filter.buckets, - renderEditor: filter.renderEditor, })); return ( @@ -74,7 +74,15 @@ export function FilterPanel({ onFilterClear={onFilterClear} onRangeChange={onRangeChange} onExpandedFilterChange={onExpandedFilterChange} - /> + > + {filterOptions + .filter(({ filter }) => filter.renderEditor !== undefined) + .map(({ filter }) => ( + + {(editorProps) => filter.renderEditor!(editorProps)} + + ))} + ); } From 3de4e81b014824a2a5f8b61ca5304ce8e59f998e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 1 Jun 2026 19:12:11 +0000 Subject: [PATCH 5/6] =?UTF-8?q?feat:=20FilterEditor=20children=20API=20?= =?UTF-8?q?=E2=80=94=20declarative=20custom=20editors=20as=20FilterPanel?= =?UTF-8?q?=20children?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Documentation/Filter/index.md | 60 ++++++++++++++++++++++++++--------- 1 file changed, 45 insertions(+), 15 deletions(-) diff --git a/Documentation/Filter/index.md b/Documentation/Filter/index.md index 94cf387..22f7055 100644 --- a/Documentation/Filter/index.md +++ b/Documentation/Filter/index.md @@ -1,16 +1,18 @@ # FilterPanel -The `FilterPanel` component provides a standalone, reusable filter UI that can be placed next to any data view. It renders as a positioned dropdown anchored below a trigger button and supports single-select, multi-select, numeric range (with histogram), and fully custom filter editors. +The `FilterPanel` component provides a standalone, reusable filter UI that can be placed next to any data view. It renders as a positioned dropdown anchored below a trigger button and supports single-select, multi-select, numeric range (with histogram), and fully custom filter editors declared as children. ## Components and Exports | Export | Description | |---|---| | `FilterPanel` | Main dropdown panel component | +| `FilterEditor` | Slot component — declares a custom editor for a specific filter group | | `RangeHistogramFilter` | Standalone numeric range slider with histogram bars | | `useFilterState` | State management hook — tracks selections, ranges, and custom values | | `FilterDefinition` | Type describing a single filter group | -| `FilterEditorProps` | Props passed to a custom `renderEditor` function | +| `FilterEditorProps` | Props passed to a `FilterEditor` render-prop child (`{ value, onChange }`) | +| `FilterEditorSlotProps` | Props for the `FilterEditor` component itself | | `FilterValues` | `Record>` — selected option keys per filter | | `RangeValues` | `Record` — selected ranges per filter | | `CustomFilterValues` | `Record` — values for custom editor filters | @@ -124,17 +126,27 @@ Renders a `RangeHistogramFilter` — a draggable range slider overlaid on a hist ### Custom editor (`type: 'custom'`) -Provide a `renderEditor` function to replace the default UI entirely. The value is stored in `customValues` keyed by the filter's `key`. +Declare `type: 'custom'` in the `FilterDefinition`, then place a matching `` child inside ``. The value is stored in `customValues` keyed by the filter's `key`. ```tsx -{ - key: 'rating', - label: 'Rating', - type: 'custom', - renderEditor: ({ value, onChange }) => ( - - ), -} +// 1. Declare the filter group (no editor function here) +const filters: FilterDefinition[] = [ + { key: 'rating', label: 'Rating', type: 'custom' }, +]; + +// 2. Attach the editor declaratively as a child of FilterPanel + + + {({ value, onChange }) => ( + + )} + + ``` Pass `customValues` and `onCustomValueChange` to `FilterPanel` when using custom editors: @@ -146,7 +158,11 @@ const { customValues, handleCustomValueChange, ...rest } = useFilterState(filter {...rest} customValues={customValues} onCustomValueChange={handleCustomValueChange} -/> +> + + {({ value, onChange }) => } + + ``` ## `FilterPanel` Props @@ -169,6 +185,16 @@ const { customValues, handleCustomValueChange, ...rest } = useFilterState(filter | `onRangeChange` | `(filterKey, range) => void` | ✓ | Called when a numeric range changes | | `onExpandedFilterChange` | `(key \| null) => void` | ✓ | Called when the expanded group changes | | `onCustomValueChange` | `(filterKey, value) => void` | — | Called when a custom editor value changes | +| `children` | `ReactNode` | — | `` slot elements for custom filter groups | + +## `FilterEditor` Props + +`FilterEditor` is a declarative slot component. It renders nothing itself — `FilterPanel` discovers it from `children` and slots the editor into the correct filter group. + +| Prop | Type | Required | Description | +|---|---|---|---| +| `filterKey` | `string` | ✓ | Must match the `key` of the corresponding `FilterDefinition` | +| `children` | `(props: FilterEditorProps) => ReactNode` | ✓ | Render prop receiving `{ value, onChange }` | ## `useFilterState` Hook @@ -191,7 +217,11 @@ const state = useFilterState(filters); onRangeChange={state.handleRangeChange} onExpandedFilterChange={state.setExpandedFilterKey} onCustomValueChange={state.handleCustomValueChange} -/> +> + + {({ value, onChange }) => } + + ``` The hook re-syncs state when the `filters` array reference changes — existing selections are preserved for filter keys that are still present. @@ -214,12 +244,12 @@ The hook re-syncs state when the `filters` array reference changes — existing The Filter module is available at its own subpath — you do not need to import from the root package: ```tsx -import { FilterPanel, useFilterState } from '@cratis/components/Filter'; +import { FilterPanel, FilterEditor, useFilterState } from '@cratis/components/Filter'; import type { FilterDefinition } from '@cratis/components/Filter'; ``` It is also re-exported from the package root: ```tsx -import { FilterPanel, useFilterState } from '@cratis/components'; +import { FilterPanel, FilterEditor, useFilterState } from '@cratis/components'; ``` From f1a90afe5fac83d036d061cddf131c9beb6210e1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 1 Jun 2026 19:46:09 +0000 Subject: [PATCH 6/6] fix: add argsIgnorePattern to eslint no-unused-vars rule to allow _-prefixed params --- eslint.config.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/eslint.config.mjs b/eslint.config.mjs index 533eae5..9a156f6 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -55,6 +55,7 @@ const rules = { 'error', { ignoreRestSiblings: true, + argsIgnorePattern: '^_', }, ],