From c982cc8ac157b9a96f943dda6ab6bb9c4ff09cf6 Mon Sep 17 00:00:00 2001 From: Evelyna Bellamy Date: Sat, 22 Aug 2026 20:59:11 -0700 Subject: [PATCH 1/8] feat(FilterSummaryBar): active-filter summary bar for data grids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ported from waggleline's grid pages: 'X of Y records — N filters active · Clear all', hidden while idle unless showWhenIdle, tinted with the primary accent when narrowing. Teal hardcodes become primary tokens, FontAwesome becomes lucide, and every visible string is overridable for i18n. --- .../FilterSummaryBar.stories.tsx | 114 ++++++++++++++ .../FilterSummaryBar.test.tsx | 110 ++++++++++++++ .../FilterSummaryBar/FilterSummaryBar.tsx | 142 ++++++++++++++++++ src/components/FilterSummaryBar/index.ts | 4 + src/index.ts | 1 + tsup.config.ts | 2 + 6 files changed, 373 insertions(+) create mode 100644 src/components/FilterSummaryBar/FilterSummaryBar.stories.tsx create mode 100644 src/components/FilterSummaryBar/FilterSummaryBar.test.tsx create mode 100644 src/components/FilterSummaryBar/FilterSummaryBar.tsx create mode 100644 src/components/FilterSummaryBar/index.ts diff --git a/src/components/FilterSummaryBar/FilterSummaryBar.stories.tsx b/src/components/FilterSummaryBar/FilterSummaryBar.stories.tsx new file mode 100644 index 00000000..dfd389df --- /dev/null +++ b/src/components/FilterSummaryBar/FilterSummaryBar.stories.tsx @@ -0,0 +1,114 @@ +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { Button } from '../Button'; +import { FilterSummaryBar } from './FilterSummaryBar'; + +const meta: Meta = { + title: 'Components/Data Display/FilterSummaryBar', + component: FilterSummaryBar, + parameters: { + layout: 'padded', + docs: { + description: { + component: + 'The bar that anchors a filtered grid: "1,204 of 8,911 records — 3 filters active ' + + '· Clear all". Hidden while idle unless `showWhenIdle`; tinted with the primary ' + + 'accent whenever filters or search narrow the view. All visible strings are ' + + 'overridable for i18n.', + }, + }, + }, + tags: ['autodocs'], + argTypes: { + filteredCount: { + description: 'Rows visible after filtering.', + control: 'number', + }, + totalCount: { + description: 'Total records before filtering.', + control: 'number', + }, + activeFilterCount: { + description: 'Active filter conditions.', + control: 'number', + }, + hasSearchText: { + description: 'Whether a search query is active.', + control: 'boolean', + }, + showWhenIdle: { + description: 'Show even with nothing active.', + control: 'boolean', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + filteredCount: 1204, + totalCount: 8911, + activeFilterCount: 3, + onClearAll: () => {}, + }, +}; + +export const FiltersPlusSearch: Story = { + args: { + filteredCount: 42, + totalCount: 8911, + activeFilterCount: 2, + hasSearchText: true, + onClearAll: () => {}, + }, +}; + +export const SearchOnly: Story = { + args: { + filteredCount: 310, + totalCount: 8911, + activeFilterCount: 0, + hasSearchText: true, + onClearAll: () => {}, + }, +}; + +export const Idle: Story = { + args: { + filteredCount: 8911, + totalCount: 8911, + activeFilterCount: 0, + showWhenIdle: true, + onClearAll: () => {}, + }, +}; + +export const Interactive: Story = { + render: () => , +}; + +function InteractiveExample() { + const [filters, setFilters] = useState(3); + const filtered = Math.max(120, 8911 - filters * 2600); + return ( +
+ 0 ? filtered : 8911} + totalCount={8911} + activeFilterCount={filters} + showWhenIdle + onClearAll={() => setFilters(0)} + /> + +
+ ); +} diff --git a/src/components/FilterSummaryBar/FilterSummaryBar.test.tsx b/src/components/FilterSummaryBar/FilterSummaryBar.test.tsx new file mode 100644 index 00000000..cc262840 --- /dev/null +++ b/src/components/FilterSummaryBar/FilterSummaryBar.test.tsx @@ -0,0 +1,110 @@ +import { describe, it, expect, vi } from 'vitest'; +import { screen, fireEvent } from '@testing-library/react'; +import { renderWithTheme } from '../../test/test-utils'; +import { FilterSummaryBar } from './FilterSummaryBar'; + +describe('FilterSummaryBar', () => { + it('hides while idle by default', () => { + renderWithTheme( + {}} + /> + ); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); + + it('shows idle summary with showWhenIdle', () => { + renderWithTheme( + {}} + /> + ); + expect(screen.getByRole('status')).toHaveTextContent('all records visible'); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('summarizes counts and pluralizes filters', () => { + renderWithTheme( + {}} + /> + ); + const status = screen.getByRole('status'); + expect(status).toHaveTextContent('1,204 of 8,911 records'); + expect(status).toHaveTextContent('3 filters active'); + }); + + it('uses the singular filter label', () => { + renderWithTheme( + {}} + /> + ); + expect(screen.getByRole('status')).toHaveTextContent('1 filter active'); + }); + + it('describes search-only and search+filters states', () => { + const { rerender } = renderWithTheme( + {}} + /> + ); + expect(screen.getByRole('status')).toHaveTextContent('search active'); + + rerender( + {}} + /> + ); + expect(screen.getByRole('status')).toHaveTextContent( + '2 filters active + search' + ); + }); + + it('clears all filters', () => { + const onClearAll = vi.fn(); + renderWithTheme( + + ); + fireEvent.click(screen.getByRole('button', { name: /clear all/i })); + expect(onClearAll).toHaveBeenCalled(); + }); + + it('supports i18n label overrides', () => { + renderWithTheme( + {}} + recordsLabel="contacts" + filterLabel="condition" + clearLabel="Reset" + /> + ); + expect(screen.getByRole('status')).toHaveTextContent('5 contacts'); + expect(screen.getByRole('status')).toHaveTextContent('1 condition active'); + expect(screen.getByRole('button', { name: /reset/i })).toBeInTheDocument(); + }); +}); diff --git a/src/components/FilterSummaryBar/FilterSummaryBar.tsx b/src/components/FilterSummaryBar/FilterSummaryBar.tsx new file mode 100644 index 00000000..8db6b171 --- /dev/null +++ b/src/components/FilterSummaryBar/FilterSummaryBar.tsx @@ -0,0 +1,142 @@ +'use client'; + +import * as React from 'react'; +import { Filter, X } from 'lucide-react'; +import { cn } from '../../utils/cn'; + +export interface FilterSummaryBarProps extends React.HTMLAttributes { + /** Rows currently visible after filtering. */ + filteredCount: number; + /** Total records before filtering. Omit (or pass 0) when unknown. */ + totalCount?: number; + /** Number of active filter conditions. */ + activeFilterCount: number; + /** Whether a search query is also active. */ + hasSearchText?: boolean; + /** Keep the summary visible even when no filters/search are active. */ + showWhenIdle?: boolean; + /** Called when the clear action is clicked. */ + onClearAll: () => void; + /** Visible strings, overridable for i18n. */ + recordsLabel?: string; + filterLabel?: string; + filtersLabel?: string; + activeLabel?: string; + searchActiveLabel?: string; + allVisibleLabel?: string; + clearLabel?: string; +} + +/** + * The bar that anchors a filtered grid: "1,204 of 8,911 records — 3 + * filters active · Clear all". Hidden while idle unless `showWhenIdle`, + * and tinted with the primary accent whenever filters or search narrow + * the view. Pair with any data grid or list whose filter state lives in + * the host. + * + * @example + * ```tsx + * 0} + * onClearAll={resetFilters} + * /> + * ``` + */ +export const FilterSummaryBar = React.forwardRef< + HTMLDivElement, + FilterSummaryBarProps +>(function FilterSummaryBar( + { + filteredCount, + totalCount, + activeFilterCount, + hasSearchText = false, + showWhenIdle = false, + onClearAll, + recordsLabel = 'records', + filterLabel = 'filter', + filtersLabel = 'filters', + activeLabel = 'active', + searchActiveLabel = 'search active', + allVisibleLabel = 'all records visible', + clearLabel = 'Clear all', + className, + ...props + }, + ref +) { + const isFiltering = activeFilterCount > 0 || hasSearchText; + if (!isFiltering && !showWhenIdle) return null; + + return ( +
+
+ ); +}); diff --git a/src/components/FilterSummaryBar/index.ts b/src/components/FilterSummaryBar/index.ts new file mode 100644 index 00000000..de7c27c3 --- /dev/null +++ b/src/components/FilterSummaryBar/index.ts @@ -0,0 +1,4 @@ +export { + FilterSummaryBar, + type FilterSummaryBarProps, +} from './FilterSummaryBar'; diff --git a/src/index.ts b/src/index.ts index a45ee23f..0c762054 100644 --- a/src/index.ts +++ b/src/index.ts @@ -69,6 +69,7 @@ export * from './components/EmployerView'; export * from './components/EmployerServiceModal'; export * from './components/ErrorPage'; export * from './components/FileManager'; +export * from './components/FilterSummaryBar'; export * from './components/FloatingWindow'; export * from './components/HealthSurveillance'; export * from './components/HelpSupportPanel'; diff --git a/tsup.config.ts b/tsup.config.ts index d4e1d637..40ab0156 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -28,6 +28,8 @@ export default defineConfig({ 'components/CountryCodeDropdown/index': 'src/components/CountryCodeDropdown/index.ts', 'components/DateInput/index': 'src/components/DateInput/index.ts', 'components/Dropdown/index': 'src/components/Dropdown/index.ts', + 'components/FilterSummaryBar/index': + 'src/components/FilterSummaryBar/index.ts', 'components/FloatingWindow/index': 'src/components/FloatingWindow/index.ts', 'components/Input/index': 'src/components/Input/index.ts', 'components/Label/index': 'src/components/Label/index.ts', From 6b05cd1e74ee45171006913149a016408812eea4 Mon Sep 17 00:00:00 2001 From: Evelyna Bellamy Date: Sat, 22 Aug 2026 21:32:36 -0700 Subject: [PATCH 2/8] =?UTF-8?q?fix(FilterSummaryBar):=20address=20review?= =?UTF-8?q?=20=E2=80=94=20ofLabel=20and=20plusSearchLabel=20props=20for=20?= =?UTF-8?q?full=20i18n=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/FilterSummaryBar/FilterSummaryBar.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/components/FilterSummaryBar/FilterSummaryBar.tsx b/src/components/FilterSummaryBar/FilterSummaryBar.tsx index 8db6b171..7a4481b5 100644 --- a/src/components/FilterSummaryBar/FilterSummaryBar.tsx +++ b/src/components/FilterSummaryBar/FilterSummaryBar.tsx @@ -25,6 +25,10 @@ export interface FilterSummaryBarProps extends React.HTMLAttributes {' '} - of{' '} + {ofLabel}{' '} {totalCount.toLocaleString()} ) : null}{' '} @@ -121,7 +127,7 @@ export const FilterSummaryBar = React.forwardRef< {hasSearchText && activeFilterCount === 0 && ( <> — {searchActiveLabel} )} - {hasSearchText && activeFilterCount > 0 && <> + search} + {hasSearchText && activeFilterCount > 0 && <> {plusSearchLabel}} {!isFiltering && showWhenIdle && <> — {allVisibleLabel}} {isFiltering && ( From 2821b3588123d8908a5b546ff0dbd3d66a985eb8 Mon Sep 17 00:00:00 2001 From: william garrity Date: Thu, 3 Sep 2026 19:46:13 -0400 Subject: [PATCH 3/8] fix(FilterSummaryBar): clear-button contrast (primary-700 -> 800) and add safelist coverage to preset twins --- .../FilterSummaryBar/FilterSummaryBar.tsx | 2 +- src/tailwind-preset.cjs | 14 ++++++++++++++ src/tailwind-preset.ts | 6 ++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/components/FilterSummaryBar/FilterSummaryBar.tsx b/src/components/FilterSummaryBar/FilterSummaryBar.tsx index 7a4481b5..73c36f5b 100644 --- a/src/components/FilterSummaryBar/FilterSummaryBar.tsx +++ b/src/components/FilterSummaryBar/FilterSummaryBar.tsx @@ -136,7 +136,7 @@ export const FilterSummaryBar = React.forwardRef< onClick={onClearAll} className={cn( 'ms-auto flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium transition-colors', - 'text-primary-700 hover:bg-primary-500/15 dark:text-primary-300' + 'text-primary-800 hover:bg-primary-500/15 hover:text-primary-700 dark:text-primary-300 dark:hover:text-primary-200' )} >