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
22 changes: 22 additions & 0 deletions Documentation/Filter/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

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.

## Key Features

- **Automatic Clear Buttons**: When a filter has active selections, a round clear button (×) appears in the filter header next to the count badge, making it easy to reset individual filters.
- **Flexible Filter Types**: Supports string/option filters (single or multi-select), numeric range filters with histograms, and fully custom editors.
- **Integrated State Management**: Use the `useFilterState` hook to manage all filter state in one place.

## Components and Exports

| Export | Description |
Expand Down Expand Up @@ -165,6 +171,22 @@ const { customValues, handleCustomValueChange, ...rest } = useFilterState(filter
</FilterPanel>
```

## Clearing Filters

Each filter group automatically displays a round clear button (×) in its header when it has active selections:

- **String/option filters**: The clear button appears next to the selection count badge (e.g., "3 selected")
- **Numeric range filters**: The clear button appears next to the "Range" indicator
- **Custom filters**: The clear button appears when the filter has a value (non-null, non-undefined)

The clear button includes a tooltip describing its action and can be clicked without expanding the filter. When clicked:

- For string/option filters, all selections are cleared via `onFilterClear(filterKey)`
- For range filters, the range is reset to `null` via `onRangeChange(filterKey, null)`
- For custom filters, the value is set to `undefined` via `onCustomValueChange(filterKey, undefined)`

Custom filter editors should not implement their own clear buttons; the header clear button handles this automatically.

## `FilterPanel` Props

| Prop | Type | Required | Description |
Expand Down
25 changes: 13 additions & 12 deletions Source/.storybook/preview.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,6 @@ import lightThemeUrl from 'primereact/resources/themes/lara-light-blue/theme.css
import { CratisComponentsProvider } from '../Common/CratisComponentsProvider';
import { tailwindPtPreset } from './pt-preset';

/**
* Each toolbar entry maps to one of the three documented styling paths in
* Source/README.md. Stories don't need to change — every mode renders the
* same component tree under a different provider/theme/token combination.
*/
const STYLING_MODES = {
'lara-dark': {
title: 'Path A — Lara Dark Blue',
Expand All @@ -29,10 +24,6 @@ const STYLING_MODES = {
},
'cratis-themed': {
title: 'Path B — Themed with custom palette',
// Path B = a PrimeReact theme provides the structural chrome, then the
// body class overrides PrimeReact's own --surface-* / --text-color /
// --primary-color variables (and the --cratis-* siblings) to retint
// every widget with a Cratis-flavored slate + sky palette.
themeUrl: darkThemeUrl,
bodyClass: 'cratis-themed',
providerValue: {},
Expand Down Expand Up @@ -74,7 +65,9 @@ export const globalTypes = {
function applyThemeLink(href) {
let link = document.getElementById('primereact-theme');
if (href === null) {
if (link) link.remove();
if (link) {
link.remove();
}
return;
}
if (!link) {
Expand All @@ -83,12 +76,18 @@ function applyThemeLink(href) {
link.rel = 'stylesheet';
document.head.appendChild(link);
}
if (link.href !== href) link.href = href;
// Changed: use getAttribute instead of .href property to avoid triggering HMR
const currentHref = link.getAttribute('href');
if (currentHref !== href) {
link.setAttribute('href', href);
}
}

function applyBodyClass(className) {
document.body.classList.remove(...ALL_BODY_CLASSES);
if (className) document.body.classList.add(className);
if (className) {
document.body.classList.add(className);
}
}

export const decorators = [
Expand Down Expand Up @@ -119,3 +118,5 @@ export const parameters = {
],
},
};


27 changes: 27 additions & 0 deletions Source/Filter/FilterPanel.css
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,33 @@
font-size: 0.7rem;
}

.pv-filter-clear-header {
appearance: none;
border: none;
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
background: var(--cratis-highlight-bg);
color: var(--cratis-text-color);
font-size: 1.2rem;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 160ms ease, transform 120ms ease;
padding: 0;
}

.pv-filter-clear-header:hover {
background: var(--cratis-surface-hover);
transform: scale(1.1);
}

.pv-filter-clear-header:active {
transform: scale(0.95);
}

.pv-filter-chevron {
width: 0.65rem;
height: 0.65rem;
Expand Down
44 changes: 13 additions & 31 deletions Source/Filter/FilterPanel.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// 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 React, { useMemo, useRef, useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { FilterPanel } from './FilterPanel';
import { FilterEditor } from './FilterEditor';
Expand Down Expand Up @@ -65,7 +65,7 @@ export const SingleSelectFilter: Story = {
const buttonRef = useRef<HTMLButtonElement>(null!);
const [isOpen, setIsOpen] = useState(false);

const filters: FilterDefinition[] = [
const filters: FilterDefinition[] = useMemo(() => [
{
key: 'status',
label: 'Status',
Expand All @@ -77,7 +77,7 @@ export const SingleSelectFilter: Story = {
{ key: 'archived', label: 'Archived', value: 'archived', count: 3 },
],
},
];
], []);

const {
filterValues,
Expand Down Expand Up @@ -141,7 +141,7 @@ export const MultiSelectFilter: Story = {
const buttonRef = useRef<HTMLButtonElement>(null!);
const [isOpen, setIsOpen] = useState(false);

const filters: FilterDefinition[] = [
const filters: FilterDefinition[] = useMemo(() => [
{
key: 'department',
label: 'Department',
Expand All @@ -158,7 +158,7 @@ export const MultiSelectFilter: Story = {
{ key: 'legal', label: 'Legal', value: 'legal', count: 8 },
],
},
];
], []);

const { filterValues, rangeValues, expandedFilterKey, setExpandedFilterKey, handleToggleFilter, handleClearFilter, handleRangeChange } =
useFilterState(filters);
Expand Down Expand Up @@ -227,7 +227,7 @@ export const NumericRangeFilter: Story = {
const buttonRef = useRef<HTMLButtonElement>(null!);
const [isOpen, setIsOpen] = useState(false);

const filters: FilterDefinition[] = [
const filters: FilterDefinition[] = useMemo(() => [
{
key: 'age',
label: 'Age',
Expand All @@ -239,7 +239,7 @@ export const NumericRangeFilter: Story = {
values: ageValues,
},
},
];
], []);

const { filterValues, rangeValues, expandedFilterKey, setExpandedFilterKey, handleToggleFilter, handleClearFilter, handleRangeChange } =
useFilterState(filters);
Expand Down Expand Up @@ -288,7 +288,7 @@ export const NumericRangeFilter: Story = {
function RatingEditor({ value, onChange }: { value: unknown; onChange: (v: unknown) => void }) {
const rating = typeof value === 'number' ? value : 0;
return (
<div style={{ padding: '0.5rem 0', display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<div style={{ padding: '0.5rem 0' }}>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
{[1, 2, 3, 4, 5].map((star) => (
<button
Expand All @@ -314,23 +314,14 @@ function RatingEditor({ value, onChange }: { value: unknown; onChange: (v: unkno
</button>
))}
</div>
{rating > 0 && (
<button
type="button"
className="pv-filter-clear"
onClick={() => onChange(0)}
>
Clear
</button>
)}
</div>
);
}

function DateRangeEditor({ value, onChange }: { value: unknown; onChange: (v: unknown) => void }) {
const range = value as { from?: string; to?: string } | undefined;
return (
<div style={{ padding: '0.5rem 0', display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<div style={{ padding: '0.5rem 0' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.5rem' }}>
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem', fontSize: '0.75rem', opacity: 0.7 }}>
From
Expand Down Expand Up @@ -365,15 +356,6 @@ function DateRangeEditor({ value, onChange }: { value: unknown; onChange: (v: un
/>
</label>
</div>
{(range?.from || range?.to) && (
<button
type="button"
className="pv-filter-clear"
onClick={() => onChange(undefined)}
>
Clear
</button>
)}
</div>
);
}
Expand All @@ -384,7 +366,7 @@ export const CustomEditor: Story = {
const buttonRef = useRef<HTMLButtonElement>(null!);
const [isOpen, setIsOpen] = useState(false);

const filters: FilterDefinition[] = [
const filters: FilterDefinition[] = useMemo(() => [
{
key: 'rating',
label: 'Rating',
Expand All @@ -395,7 +377,7 @@ export const CustomEditor: Story = {
label: 'Created Date',
type: 'custom',
},
];
], []);

const { filterValues, rangeValues, customValues, expandedFilterKey, setExpandedFilterKey, handleToggleFilter, handleClearFilter, handleRangeChange, handleCustomValueChange } =
useFilterState(filters);
Expand Down Expand Up @@ -475,7 +457,7 @@ export const MixedFilters: Story = {
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState('');

const filters: FilterDefinition[] = [
const filters: FilterDefinition[] = useMemo(() => [
{
key: 'department',
label: 'Department',
Expand Down Expand Up @@ -515,7 +497,7 @@ export const MixedFilters: Story = {
label: 'Hire Date',
type: 'custom',
},
];
], []);

const { filterValues, rangeValues, customValues, expandedFilterKey, setExpandedFilterKey, handleToggleFilter, handleClearFilter, handleRangeChange, handleCustomValueChange } =
useFilterState(filters);
Expand Down
Loading
Loading