From 67b74f25997a381ab215ad93c9680a3b3e4dc163 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Mon, 8 Jun 2026 16:20:24 +0530 Subject: [PATCH 1/2] fix: datatable filter design --- .../components/data-table/components/content.tsx | 5 ++++- .../components/data-table/data-table.module.css | 11 +++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/raystack/components/data-table/components/content.tsx b/packages/raystack/components/data-table/components/content.tsx index 05fdc73bd..86d2ac505 100644 --- a/packages/raystack/components/data-table/components/content.tsx +++ b/packages/raystack/components/data-table/components/content.tsx @@ -313,7 +313,10 @@ export function Content({ {showFilterSummary ? ( diff --git a/packages/raystack/components/data-table/data-table.module.css b/packages/raystack/components/data-table/data-table.module.css index 9d8b69d6b..42583f19c 100644 --- a/packages/raystack/components/data-table/data-table.module.css +++ b/packages/raystack/components/data-table/data-table.module.css @@ -63,6 +63,17 @@ box-sizing: border-box; } +/* Empty-state variant of the footer: the same row hugs its content and gains a + border so it reads as a compact panel instead of a full-width bar. */ +.filterSummaryFooterEmpty { + width: fit-content; + gap: var(--rs-space-3); + margin: var(--rs-space-6) auto var(--rs-space-9); + padding: var(--rs-space-3) var(--rs-space-4); + border: 0.5px solid var(--rs-color-border-base-primary); + border-radius: var(--rs-radius-2); +} + .filterSummaryCount { color: var(--rs-color-foreground-base-primary); font-family: var(--rs-font-body); From 0d6564802c9e9818c5148af1d0da0e72f8722530 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Mon, 8 Jun 2026 16:33:11 +0530 Subject: [PATCH 2/2] feat: update dataview component too --- .../src/app/examples/dataview-beta/page.tsx | 1 + apps/www/src/components/dataview-demo.tsx | 2 + .../content/docs/components/dataview/demo.ts | 2 + .../docs/components/dataview/index.mdx | 27 +++++ .../content/docs/components/dataview/props.ts | 5 + .../data-view/__tests__/data-view.test.tsx | 33 +++++- .../data-view/components/clear-filters.tsx | 112 ++++++++++++++++++ .../components/data-view/components/list.tsx | 59 +-------- .../components/data-view/data-view.module.css | 11 ++ .../components/data-view/data-view.tsx | 2 + .../raystack/components/data-view/index.ts | 1 + 11 files changed, 195 insertions(+), 60 deletions(-) create mode 100644 packages/raystack/components/data-view/components/clear-filters.tsx diff --git a/apps/www/src/app/examples/dataview-beta/page.tsx b/apps/www/src/app/examples/dataview-beta/page.tsx index e57df04f3..fcb7b6723 100644 --- a/apps/www/src/app/examples/dataview-beta/page.tsx +++ b/apps/www/src/app/examples/dataview-beta/page.tsx @@ -610,6 +610,7 @@ const Page = () => { subHeading='Add your first teammate to get started.' /> + diff --git a/apps/www/src/components/dataview-demo.tsx b/apps/www/src/components/dataview-demo.tsx index 66c741ad0..d92246970 100644 --- a/apps/www/src/components/dataview-demo.tsx +++ b/apps/www/src/components/dataview-demo.tsx @@ -227,6 +227,7 @@ export function DataViewSearchDemo() { No people match your search. + @@ -288,6 +289,7 @@ export function DataViewEmptyZeroDemo() { Nothing here yet. + diff --git a/apps/www/src/content/docs/components/dataview/demo.ts b/apps/www/src/content/docs/components/dataview/demo.ts index 596f6228e..5d4e4e16c 100644 --- a/apps/www/src/content/docs/components/dataview/demo.ts +++ b/apps/www/src/content/docs/components/dataview/demo.ts @@ -95,6 +95,7 @@ export const emptyZeroPreview = { Nothing here yet. + ` } ] @@ -325,6 +326,7 @@ export const searchPreview = { No people match your search. + ` } ] diff --git a/apps/www/src/content/docs/components/dataview/index.mdx b/apps/www/src/content/docs/components/dataview/index.mdx index dd7be6dfb..21d35d158 100644 --- a/apps/www/src/content/docs/components/dataview/index.mdx +++ b/apps/www/src/content/docs/components/dataview/index.mdx @@ -49,6 +49,7 @@ import { {/* no matches */} {/* no data yet */} + ``` @@ -86,10 +87,30 @@ Empty/zero is computed once on context (`isEmptyState`, `isZeroState`) and expos Nothing here yet. + ``` Renderers return `null` when `!hasData` — siblings render the messaging. +### Clear filters + +When rows are hidden by filters, `DataView.List` automatically renders a flat +footer summarising the hidden count with a **Clear Filters** action — the +default treatment. `DataView.ClearFilters` is a separate sibling entry that +surfaces the same action as a **bordered panel** in the empty state (a query +returned no rows). It reads context, resets `filters`/`search` on click, and +renders nothing outside the empty state — so place it once alongside +`DataView.List`, separate from `DataView.EmptyState`, and it self-manages +visibility. + +```tsx + + + No matches for your filters. + + +``` + ### Display Properties (column visibility) Visibility is a **single global map** on context. `DataView.List` honours it for free (TanStack column visibility hides the grid track). For free-form renderers, wrap fields in `DataView.DisplayAccess`: @@ -154,6 +175,12 @@ The popover housing the view switcher, Ordering, Grouping, and Display Propertie +### DataView.ClearFilters + +A context-driven "Clear Filters" affordance for the empty state. Place it as a sibling of `DataView.List` (separate from `DataView.EmptyState`); it renders a bordered panel when a query returns no rows and nothing otherwise. The flat footer for the data state is rendered automatically by `DataView.List`. + + + ## Examples ### Search diff --git a/apps/www/src/content/docs/components/dataview/props.ts b/apps/www/src/content/docs/components/dataview/props.ts index 5ddae3f84..ed92f4d1b 100644 --- a/apps/www/src/content/docs/components/dataview/props.ts +++ b/apps/www/src/content/docs/components/dataview/props.ts @@ -205,6 +205,11 @@ export interface DataViewZeroStateProps { children: ReactNode; } +export interface DataViewClearFiltersProps { + /** Class applied to the filter-summary row. */ + className?: string; +} + export interface DataViewDisplayControlsProps { /** Custom trigger element for the popover. */ trigger?: ReactNode; diff --git a/packages/raystack/components/data-view/__tests__/data-view.test.tsx b/packages/raystack/components/data-view/__tests__/data-view.test.tsx index 21091511e..052ce042b 100644 --- a/packages/raystack/components/data-view/__tests__/data-view.test.tsx +++ b/packages/raystack/components/data-view/__tests__/data-view.test.tsx @@ -648,12 +648,39 @@ describe('DataView', () => {
no matches
+ ); - // In empty state, filter summary is *not* shown by the List (renderer - // returns null when !hasData). EmptyState sibling handles it. Just - // confirm the empty state path is taken. + // In the empty state the List renders nothing; the sibling + // DataView.ClearFilters surfaces the affordance. expect(screen.getByTestId('empty')).toBeInTheDocument(); + const clearButton = screen.getByText('Clear Filters'); + expect(clearButton).toBeInTheDocument(); + + // Clearing exits the empty state and reveals the rows. + await user.click(clearButton); + expect(screen.queryByTestId('empty')).not.toBeInTheDocument(); + expect(screen.getByText('John Doe')).toBeInTheDocument(); + }); + + it('renders the flat footer summary when rows are hidden by filters', () => { + render( + + + + ); + // List auto-renders DataViewClearFilters as the footer (data present). + expect(screen.getByText('items hidden by filters')).toBeInTheDocument(); + expect(screen.getByText('Clear Filters')).toBeInTheDocument(); }); }); diff --git a/packages/raystack/components/data-view/components/clear-filters.tsx b/packages/raystack/components/data-view/components/clear-filters.tsx new file mode 100644 index 000000000..2a8b61ae1 --- /dev/null +++ b/packages/raystack/components/data-view/components/clear-filters.tsx @@ -0,0 +1,112 @@ +'use client'; + +import { Cross2Icon } from '@radix-ui/react-icons'; +import { cx } from 'class-variance-authority'; +import { useCallback } from 'react'; +import { Button } from '../../button'; +import { Flex } from '../../flex'; +import styles from '../data-view.module.css'; +import { useDataView } from '../hooks/useDataView'; +import { + countLeafRows, + getClientHiddenLeafRowCount, + hasActiveTableFiltering +} from '../utils'; + +export interface DataViewClearFiltersProps { + className?: string; +} + +/** + * The filter-summary row plus a "Clear Filters" action. Reads everything from + * `DataView` context and renders nothing when there is nothing to clear. + * + * Flat by default (the footer `DataView.List` renders when rows are hidden by + * filters); a bordered panel in the empty state. Shared between the List footer + * and `DataView.ClearFilters` so the markup lives in one place. + * + * Internal — not exported from the package. + */ +export function FilterSummary({ className }: DataViewClearFiltersProps) { + const { + table, + mode, + isLoading, + totalRowCount, + isEmptyState, + updateTableQuery + } = useDataView(); + + const rows = table?.getRowModel()?.rows ?? []; + const hiddenLeafRowCount = + mode === 'client' + ? getClientHiddenLeafRowCount(table) + : totalRowCount !== undefined + ? Math.max(0, totalRowCount - countLeafRows(rows)) + : null; + const hasActiveFiltering = !isLoading && hasActiveTableFiltering(table); + const showFilterSummary = + hasActiveFiltering && + (mode === 'server' || + (typeof hiddenLeafRowCount === 'number' && hiddenLeafRowCount > 0)); + + const handleClearFilters = useCallback(() => { + updateTableQuery(prev => ({ ...prev, filters: [], search: '' })); + }, [updateTableQuery]); + + // Matches DataTable: render only when rows are hidden by filters. + // `isEmptyState` controls styling (bordered panel), not visibility. + if (!showFilterSummary) return null; + + return ( + + {mode === 'server' && hiddenLeafRowCount === null ? ( + + Some items might be hidden by filters + + ) : ( + + + {hiddenLeafRowCount} + + + items hidden by filters + + + )} + + + ); +} + +FilterSummary.displayName = 'DataView.FilterSummary'; + +/** + * Surfaces the bordered "Clear Filters" panel in the empty state (a query + * returned no rows). Place it as a sibling of `DataView.List` — separate from + * `DataView.EmptyState`. Renders nothing outside the empty state; the flat + * footer for the data state is rendered automatically by `DataView.List`. + */ +export function DataViewClearFilters({ className }: DataViewClearFiltersProps) { + const { isEmptyState } = useDataView(); + if (!isEmptyState) return null; + return ; +} + +DataViewClearFilters.displayName = 'DataView.ClearFilters'; diff --git a/packages/raystack/components/data-view/components/list.tsx b/packages/raystack/components/data-view/components/list.tsx index 6ed467009..e8dccbf53 100644 --- a/packages/raystack/components/data-view/components/list.tsx +++ b/packages/raystack/components/data-view/components/list.tsx @@ -1,14 +1,11 @@ 'use client'; -import { Cross2Icon } from '@radix-ui/react-icons'; import type { Header, Row } from '@tanstack/react-table'; import { flexRender } from '@tanstack/react-table'; import { cx } from 'class-variance-authority'; import { CSSProperties, useCallback, useEffect, useMemo, useRef } from 'react'; import { Badge } from '../../badge'; -import { Button } from '../../button'; -import { Flex } from '../../flex'; import { Skeleton } from '../../skeleton'; import styles from '../data-view.module.css'; import { @@ -22,11 +19,7 @@ import { useElementHeight } from '../hooks/useElementHeight'; import { useInfiniteScroll } from '../hooks/useInfiniteScroll'; import { useStickyGroupAnchor } from '../hooks/useStickyGroupAnchor'; import { useVirtualRows } from '../hooks/useVirtualRows'; -import { - countLeafRows, - getClientHiddenLeafRowCount, - hasActiveTableFiltering -} from '../utils'; +import { FilterSummary } from './clear-filters'; function formatGridWidth(width: string | number | undefined) { if (width === undefined) return '1fr'; @@ -60,8 +53,6 @@ export function DataViewList({ loadingRowCount = 3, loadMoreData, tableQuery, - totalRowCount, - updateTableQuery, activeView, registerFieldsForView, hasData @@ -188,22 +179,6 @@ export function DataViewList({ onLoadMore: loadMoreData }); - const hiddenLeafRowCount = - mode === 'client' - ? getClientHiddenLeafRowCount(table) - : totalRowCount !== undefined - ? Math.max(0, totalRowCount - countLeafRows(rows)) - : null; - const hasActiveFiltering = !isLoading && hasActiveTableFiltering(table); - const showFilterSummary = - hasActiveFiltering && - (mode === 'server' || - (typeof hiddenLeafRowCount === 'number' && hiddenLeafRowCount > 0)); - - const handleClearFilters = useCallback(() => { - updateTableQuery(prev => ({ ...prev, filters: [], search: '' })); - }, [updateTableQuery]); - // Sticky group anchor needs to recompute on scroll only. rAF-throttled so // the binary search runs at most once per frame regardless of how fast the // scroll events fire (mousewheel can dispatch dozens per frame on macOS). @@ -507,37 +482,7 @@ export function DataViewList({ aria-hidden='true' /> - {showFilterSummary ? ( - - {mode === 'server' && hiddenLeafRowCount === null ? ( - - Some items might be hidden by filters - - ) : ( - - - {hiddenLeafRowCount} - - - items hidden by filters - - - )} - - - ) : null} + ); } diff --git a/packages/raystack/components/data-view/data-view.module.css b/packages/raystack/components/data-view/data-view.module.css index f053083d7..b61bbe31f 100644 --- a/packages/raystack/components/data-view/data-view.module.css +++ b/packages/raystack/components/data-view/data-view.module.css @@ -75,6 +75,17 @@ box-sizing: border-box; } +/* Empty-state variant of the footer: the same row hugs its content and gains a + border so it reads as a compact panel instead of a full-width bar. */ +.filterSummaryFooterEmpty { + width: fit-content; + gap: var(--rs-space-3); + margin: var(--rs-space-6) auto var(--rs-space-9); + padding: var(--rs-space-3) var(--rs-space-4); + border: 0.5px solid var(--rs-color-border-base-primary); + border-radius: var(--rs-radius-2); +} + .filterSummaryCount { color: var(--rs-color-foreground-base-primary); font-family: var(--rs-font-body); diff --git a/packages/raystack/components/data-view/data-view.tsx b/packages/raystack/components/data-view/data-view.tsx index 74ea565c2..01f9aa075 100644 --- a/packages/raystack/components/data-view/data-view.tsx +++ b/packages/raystack/components/data-view/data-view.tsx @@ -11,6 +11,7 @@ import { } from '@tanstack/react-table'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { DataViewClearFilters } from './components/clear-filters'; import { DataViewCustom } from './components/custom'; import { DisplayAccess } from './components/display-access'; import { DisplayControls } from './components/display-controls'; @@ -308,6 +309,7 @@ export const DataView = Object.assign(DataViewRoot, { DisplayAccess: DisplayAccess, EmptyState: DataViewEmptyState, ZeroState: DataViewZeroState, + ClearFilters: DataViewClearFilters, Toolbar: Toolbar, Search: DataViewSearch, Filters: Filters, diff --git a/packages/raystack/components/data-view/index.ts b/packages/raystack/components/data-view/index.ts index ca3466e6d..08bc40dc7 100644 --- a/packages/raystack/components/data-view/index.ts +++ b/packages/raystack/components/data-view/index.ts @@ -1,5 +1,6 @@ export { EmptyFilterValue } from '~/types/filters'; +export type { DataViewClearFiltersProps } from './components/clear-filters'; export type { DataViewCustomProps } from './components/custom'; export type { DataViewDisplayAccessProps } from './components/display-access'; export type { DataViewEmptyStateProps } from './components/empty-state';