Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
255 changes: 255 additions & 0 deletions Documentation/Filter/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
# 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 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 `FilterEditor` render-prop child (`{ value, onChange }`) |
| `FilterEditorSlotProps` | Props for the `FilterEditor` component itself |
| `FilterValues` | `Record<string, Set<string>>` — selected option keys per filter |
| `RangeValues` | `Record<string, [number, number] \| null>` — selected ranges per filter |
| `CustomFilterValues` | `Record<string, unknown>` — 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<HTMLButtonElement>(null);
const [isOpen, setIsOpen] = useState(false);

const {
filterValues,
rangeValues,
expandedFilterKey,
setExpandedFilterKey,
handleToggleFilter,
handleClearFilter,
handleRangeChange,
} = useFilterState(filters);

return (
<>
<button ref={buttonRef} onClick={() => setIsOpen(v => !v)}>
Filters
</button>
<FilterPanel
isOpen={isOpen}
filters={filters}
filterValues={filterValues}
rangeValues={rangeValues}
expandedFilterKey={expandedFilterKey}
anchorRef={buttonRef}
onClose={() => 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'`)

Declare `type: 'custom'` in the `FilterDefinition`, then place a matching `<FilterEditor>` child inside `<FilterPanel>`. The value is stored in `customValues` keyed by the filter's `key`.

```tsx
// 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
<FilterPanel
filters={filters}
customValues={customValues}
onCustomValueChange={handleCustomValueChange}
{...otherProps}
>
<FilterEditor filterKey="rating">
{({ value, onChange }) => (
<MyStarRatingWidget value={value as number} onChange={onChange} />
)}
</FilterEditor>
</FilterPanel>
```

Pass `customValues` and `onCustomValueChange` to `FilterPanel` when using custom editors:

```tsx
const { customValues, handleCustomValueChange, ...rest } = useFilterState(filters);

<FilterPanel
{...rest}
customValues={customValues}
onCustomValueChange={handleCustomValueChange}
>
<FilterEditor filterKey="rating">
{({ value, onChange }) => <MyStarRatingWidget value={value as number} onChange={onChange} />}
</FilterEditor>
</FilterPanel>
```

## `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<HTMLButtonElement>` | ✓ | 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 |
| `children` | `ReactNode` | — | `<FilterEditor>` 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

`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);

<FilterPanel
isOpen={open}
filters={filters}
anchorRef={buttonRef}
filterValues={state.filterValues}
rangeValues={state.rangeValues}
customValues={state.customValues}
expandedFilterKey={state.expandedFilterKey}
onClose={() => setOpen(false)}
onFilterToggle={state.handleToggleFilter}
onFilterClear={state.handleClearFilter}
onRangeChange={state.handleRangeChange}
onExpandedFilterChange={state.setExpandedFilterKey}
onCustomValueChange={state.handleCustomValueChange}
>
<FilterEditor filterKey="myCustomFilter">
{({ value, onChange }) => <MyEditor value={value} onChange={onChange} />}
</FilterEditor>
</FilterPanel>
```

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, 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, FilterEditor, useFilterState } from '@cratis/components';
```
2 changes: 2 additions & 0 deletions Documentation/Filter/toc.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
- name: FilterPanel
href: index.md
18 changes: 18 additions & 0 deletions Documentation/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions Source/.storybook/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
};
Expand Down
34 changes: 34 additions & 0 deletions Source/Filter/FilterEditor.tsx
Original file line number Diff line number Diff line change
@@ -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 `<FilterPanel>`.
*
* Place one or more `<FilterEditor>` elements as children of `<FilterPanel>`.
* The panel will slot each editor into the filter group whose key matches
* the `filterKey` prop.
*
* ```tsx
* <FilterPanel filters={filters} {...stateProps}>
* <FilterEditor filterKey="rating">
* {({ value, onChange }) => <MyRatingPicker value={value} onChange={onChange} />}
* </FilterEditor>
* </FilterPanel>
* ```
*
* This component renders nothing itself — it is only used as a slot descriptor
* by `FilterPanel`.
*/
export function FilterEditor(_props: FilterEditorSlotProps): null {
return null;
}
Loading
Loading