diff --git a/apps/www/src/app/examples/page.tsx b/apps/www/src/app/examples/page.tsx
index 01344bcac..409c0e402 100644
--- a/apps/www/src/app/examples/page.tsx
+++ b/apps/www/src/app/examples/page.tsx
@@ -2104,11 +2104,74 @@ const Page = () => {
weight='medium'
style={{ marginTop: '32px', marginBottom: '16px' }}
>
- DataTable Examples
+ DataTable - Zero State and Empty State Examples
+
+ Zero State - No Data, No Filters/Search
+
+
+ Filter bar is hidden, search is disabled. Shows zeroState when
+ no data is fetched initially.
+
+
+
+
+ }
+ heading='Zero state'
+ variant='empty2'
+ subHeading='Get started by creating your first user. Filter bar and search are hidden in zero state.'
+ />
+ }
+ emptyState={
+ }
+ heading='Empty state'
+ variant='empty1'
+ subHeading="We couldn't find any matches for that keyword or filter."
+ />
+ }
+ />
+
+
+
+
+
+ Empty State - Filters Applied, No Results
+
+
+ Filter bar is visible, search is enabled. Shows emptyState when
+ filters are applied but no results match.
+ {
{
accessorKey: 'name',
header: 'Name',
- enableColumnFilter: true
+ enableColumnFilter: true,
+ filterType: 'string'
},
{
accessorKey: 'email',
header: 'Email',
- enableColumnFilter: true
+ enableColumnFilter: true,
+ filterType: 'string'
},
{
accessorKey: 'role',
header: 'Role',
- enableColumnFilter: true
+ enableColumnFilter: true,
+ filterType: 'select',
+ filterOptions: [
+ { value: 'Admin', label: 'Admin' },
+ { value: 'User', label: 'User' },
+ { value: 'Manager', label: 'Manager' }
+ ]
}
]}
mode='client'
defaultSort={{ name: 'name', order: 'asc' }}
>
+ }
+ heading='zero state'
+ variant='empty2'
+ subHeading='Get started by creating your first user.'
+ />
+ }
emptyState={
}
- heading='No users found'
- subHeading="We couldn't find any matches for that keyword or filter. Try alternative terms or check for typos."
+ heading='empty state'
+ variant='empty1'
+ subHeading="We couldn't find any matches for that filter. Try adjusting your filters or search query. Filter bar remains visible so you can modify filters."
/>
}
/>
@@ -2164,35 +2245,279 @@ const Page = () => {
+
+ Empty State - Search Applied, No Results (Filter Bar Hidden)
+
+
+ Filter bar stays hidden when only search is applied (no
+ filters). Search is enabled. Shows emptyState.
+
+
+
+
+ }
+ heading='zero state'
+ variant='empty2'
+ subHeading='Get started by creating your first user.'
+ />
+ }
+ emptyState={
+ }
+ heading='empty state'
+ variant='empty1'
+ subHeading="We couldn't find any matches for that search. Try a different search term. Filter bar stays hidden when only search is applied."
+ />
+ }
+ />
+
+
+
+
+
+ Zero State with Custom Content
+
+
+ Custom zeroState with action button. Filter bar and search are
+ hidden.
+
+
+
+
+
+ No data available
+
+
+ There are no users in the system. Create your first
+ user to get started.
+
+
+
+
+ }
+ emptyState={
+ }
+ heading='empty state'
+ variant='empty1'
+ subHeading='Try adjusting your filters or search query.'
+ />
+ }
+ />
+
+
+
+
+
+ Search Override - Always Enabled in Zero State
+
+
+ Override auto-disable with autoDisableInZeroState={false}.
+ Filter bar stays hidden when only search is applied.
+
+
+
+
+ }
+ heading='zero state'
+ variant='empty2'
+ subHeading='Search is enabled even in zero state. Start typing to see empty state. Filter bar will only appear when filters are applied.'
+ />
+ }
+ emptyState={
+ }
+ heading='empty state'
+ variant='empty1'
+ subHeading='Search applied but no results. Filter bar stays hidden when only search is used.'
+ />
+ }
+ />
+
+
+
+
+
+ Normal State - Data Present
+
+
+ Filter bar and search are enabled when data exists.
+
+
+
+
+ }
+ heading='zero state'
+ variant='empty2'
+ subHeading='Get started by creating your first user.'
+ />
+ }
emptyState={
}
- heading='No users found'
- subHeading="We couldn't find any matches for that keyword or filter. Try alternative terms or check for typos."
+ heading='empty state'
+ variant='empty1'
+ subHeading="We couldn't find any matches for that keyword or filter."
/>
}
/>
diff --git a/apps/www/src/content/docs/components/datatable/index.mdx b/apps/www/src/content/docs/components/datatable/index.mdx
index 754c3fc50..5f6c2d8b5 100644
--- a/apps/www/src/content/docs/components/datatable/index.mdx
+++ b/apps/www/src/content/docs/components/datatable/index.mdx
@@ -32,6 +32,10 @@ import {
+## DataTable.Content Props
+
+
+
## Examples
### Basic Usage
@@ -204,3 +208,138 @@ onTableQueryChange={handleQueryChange}>
```
+
+### Using DataTable Search
+
+The `DataTable.Search` component provides search functionality that automatically integrates with the table query. By default, it is disabled in zero state (when no data and no filters/search applied).
+
+```tsx
+
+
+
+
+```
+
+#### Search Auto-Disable Behavior
+
+By default, `DataTable.Search` is automatically disabled in zero state to provide a better user experience. You can override this behavior:
+
+```tsx
+// Default: disabled in zero state
+
+
+// Override: always enabled
+
+
+// Manual control: explicitly disable
+
+```
+
+The search will be automatically enabled when:
+- Data exists in the table
+- Filters are applied
+- A search query is already present
+
+### Empty States
+
+The DataTable supports two types of empty states to provide better user experience:
+
+#### Zero State
+
+Zero state is shown when no data has been fetched initially (no filters or search applied). In this state, the filter bar is automatically hidden.
+
+```tsx
+import { DataTable, EmptyState } from "@raystack/apsara";
+import { OrganizationIcon } from "@raystack/apsara/icons";
+
+
+
+ }
+ heading="No users yet"
+ subHeading="Get started by creating your first user."
+ />
+ }
+ />
+
+```
+
+#### Empty State
+
+Empty state is shown when initial data exists but no results match after applying filters or search. In this state, the filter bar remains visible so users can adjust their filters.
+
+```tsx
+import { DataTable, EmptyState } from "@raystack/apsara";
+import { OrganizationIcon, FilterIcon } from "@raystack/apsara/icons";
+
+
+
+
+ }
+ heading="No users yet"
+ subHeading="Get started by creating your first user."
+ />
+ }
+ emptyState={
+ }
+ heading="No users found"
+ subHeading="We couldn't find any matches for that keyword or filter. Try alternative terms or check for typos."
+ />
+ }
+ />
+
+```
+
+#### Fallback Behavior
+
+- If `zeroState` is not provided, it falls back to `emptyState`
+- If neither `zeroState` nor `emptyState` is provided, a default empty state is shown
+- The filter bar visibility is automatically controlled based on the state
+
+#### Custom Empty State Content
+
+You can provide custom React components for both states:
+
+```tsx
+import { DataTable, EmptyState, Flex, Text, Button } from "@raystack/apsara";
+import { OrganizationIcon, FilterIcon } from "@raystack/apsara/icons";
+
+
+
+
+
+ No data available
+
+
+ There are no users in the system. Create your first user to get started.
+
+
+
+
+ }
+ emptyState={
+ }
+ heading="No matches found"
+ subHeading="Try adjusting your filters or search query."
+ />
+ }
+/>
+```
diff --git a/apps/www/src/content/docs/components/datatable/props.ts b/apps/www/src/content/docs/components/datatable/props.ts
index 366925f3b..ab40b0ddd 100644
--- a/apps/www/src/content/docs/components/datatable/props.ts
+++ b/apps/www/src/content/docs/components/datatable/props.ts
@@ -9,7 +9,7 @@ export interface DataTableProps {
* Data processing mode
* @defaultValue "client"
*/
- mode?: "client" | "server";
+ mode?: 'client' | 'server';
/**
* Loading state
@@ -38,7 +38,7 @@ export interface DataTableQuery {
}>;
sort?: Array<{
key: string;
- order: "asc" | "desc";
+ order: 'asc' | 'desc';
}>;
group_by?: string[];
search?: string;
@@ -52,7 +52,7 @@ export interface DataTableColumnDef {
header: string;
/** Data type */
- columnType: "text" | "number" | "date" | "select";
+ columnType: 'text' | 'number' | 'date' | 'select';
/** Enable sorting */
enableSorting?: boolean;
@@ -72,3 +72,26 @@ export interface DataTableColumnDef {
/** Hide column by default */
defaultHidden?: boolean;
}
+
+export interface DataTableContentProps {
+ /**
+ * Custom empty state shown when initial data exists but no results match after filters/search.
+ * Filter bar remains visible in this state.
+ */
+ emptyState?: React.ReactNode;
+
+ /**
+ * Custom zero state shown when no data has been fetched initially (no filters/search applied).
+ * Filter bar is automatically hidden in this state.
+ */
+ zeroState?: React.ReactNode;
+
+ /** Custom class names for styling */
+ classNames?: {
+ root?: string;
+ table?: string;
+ header?: string;
+ body?: string;
+ row?: string;
+ };
+}
diff --git a/packages/raystack/components/data-table/__tests__/data-table.test.tsx b/packages/raystack/components/data-table/__tests__/data-table.test.tsx
index 7134067ad..522213254 100644
--- a/packages/raystack/components/data-table/__tests__/data-table.test.tsx
+++ b/packages/raystack/components/data-table/__tests__/data-table.test.tsx
@@ -151,4 +151,262 @@ describe('DataTable', () => {
expect(screen.getByLabelText('Search')).toBeInTheDocument();
});
});
+
+ describe('Zero State and Empty State', () => {
+ const columnsWithFilters: DataTableColumnDef[] = [
+ {
+ id: 'name',
+ accessorKey: 'name',
+ header: 'Name',
+ cell: ({ getValue }) => getValue(),
+ enableColumnFilter: true,
+ filterType: 'string'
+ },
+ {
+ id: 'email',
+ accessorKey: 'email',
+ header: 'Email',
+ cell: ({ getValue }) => getValue(),
+ enableColumnFilter: true,
+ filterType: 'string'
+ },
+ {
+ id: 'status',
+ accessorKey: 'status',
+ header: 'Status',
+ cell: ({ getValue }) => getValue(),
+ enableColumnFilter: true,
+ filterType: 'string'
+ }
+ ];
+
+ it('shows zero state when no data and no filters/search applied', () => {
+ const zeroStateText = 'No data available';
+ render(
+
+
+ {zeroStateText}}
+ emptyState={
No results found
}
+ />
+
+ );
+
+ expect(screen.getByTestId('zero-state')).toBeInTheDocument();
+ expect(screen.getByText(zeroStateText)).toBeInTheDocument();
+ expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument();
+ });
+
+ it('hides filter bar in zero state (no data, no filters/search)', () => {
+ const { container } = render(
+
+
+ No data} />
+
+ );
+
+ // Toolbar should not be rendered when shouldShowFilters is false
+ expect(
+ container.querySelector(`div.${styles.toolbar}`)
+ ).not.toBeInTheDocument();
+ });
+
+ it('shows empty state when filters are applied but no results', () => {
+ const emptyStateText = 'No results found';
+
+ // Apply a filter that will result in no matches using eq operator
+ // Note: We don't render Toolbar to avoid filter UI complexity in tests
+ render(
+
+ No data}
+ emptyState={
{emptyStateText}
}
+ />
+
+ );
+
+ // After applying filter with no matches, empty state should show
+ expect(screen.getByTestId('empty-state')).toBeInTheDocument();
+ expect(screen.getByText(emptyStateText)).toBeInTheDocument();
+ expect(screen.queryByTestId('zero-state')).not.toBeInTheDocument();
+ // Data should not be visible
+ expect(screen.queryByText('John Doe')).not.toBeInTheDocument();
+ });
+
+ it('shows filter bar when filters are applied (empty state scenario)', () => {
+ // This test verifies that when filters are applied, shouldShowFilters is true
+ // We test this indirectly by verifying empty state shows (which requires hasFiltersOrSearch to be true)
+ render(
+
+ No data}
+ emptyState={
No results
}
+ />
+
+ );
+
+ // If empty state shows, it means hasFiltersOrSearch is true, which means shouldShowFilters would be true
+ expect(screen.getByTestId('empty-state')).toBeInTheDocument();
+ });
+
+ it('shows empty state when search is applied but no results', () => {
+ const emptyStateText = 'No search results';
+
+ // Note: We don't render Toolbar to avoid filter UI complexity in tests
+ render(
+
+ No data}
+ emptyState={
{emptyStateText}
}
+ />
+
+ );
+
+ expect(screen.getByTestId('empty-state')).toBeInTheDocument();
+ expect(screen.getByText(emptyStateText)).toBeInTheDocument();
+ expect(screen.queryByTestId('zero-state')).not.toBeInTheDocument();
+ });
+
+ it('shows filter bar when search is applied (empty state scenario)', () => {
+ // This test verifies that when search is applied, shouldShowFilters is true
+ // We test this indirectly by verifying the component handles search correctly
+ render(
+
+ No data}
+ emptyState={
No results
}
+ />
+
+ );
+
+ // If empty state shows, it means hasFiltersOrSearch is true, which means shouldShowFilters would be true
+ expect(screen.getByTestId('empty-state')).toBeInTheDocument();
+ });
+
+ it('falls back to emptyState when zeroState is not provided', () => {
+ const emptyStateText = 'Fallback empty state';
+
+ render(
+
+
+ {emptyStateText}}
+ />
+
+ );
+
+ // Should show emptyState as fallback
+ expect(screen.getByTestId('empty-state')).toBeInTheDocument();
+ expect(screen.getByText(emptyStateText)).toBeInTheDocument();
+ });
+
+ it('falls back to default empty component when neither zeroState nor emptyState provided', () => {
+ render(
+
+
+
+
+ );
+
+ // Should show default empty state
+ expect(screen.getByText('No Data')).toBeInTheDocument();
+ });
+
+ it('shows data normally when filters/search match results', () => {
+ // Note: We don't render Toolbar to avoid filter UI complexity in tests
+ render(
+
+ No data}
+ emptyState={
No results
}
+ />
+
+ );
+
+ // Should show matching data, not empty state
+ expect(screen.getByText('John Doe')).toBeInTheDocument();
+ expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('zero-state')).not.toBeInTheDocument();
+ });
+
+ it('shows filter bar when data exists', () => {
+ // This test verifies that when data exists, shouldShowFilters is true
+ // We test this indirectly by verifying data is displayed (which means hasData is true)
+ render(
+
+
+
+ );
+
+ // If data is displayed, it means hasData is true, which means shouldShowFilters would be true
+ expect(screen.getByText('John Doe')).toBeInTheDocument();
+ });
+ });
});
diff --git a/packages/raystack/components/data-table/components/content.tsx b/packages/raystack/components/data-table/components/content.tsx
index 605a7eb5d..95c953b06 100644
--- a/packages/raystack/components/data-table/components/content.tsx
+++ b/packages/raystack/components/data-table/components/content.tsx
@@ -166,6 +166,7 @@ const DefaultEmptyComponent = () => (
export function Content({
emptyState,
+ zeroState,
classNames = {}
}: DataTableContentProps) {
const {
@@ -174,7 +175,8 @@ export function Content({
mode,
isLoading,
loadMoreData,
- loadingRowCount = 3
+ loadingRowCount = 3,
+ tableQuery
} = useDataTable();
const headerGroups = table?.getHeaderGroups();
const rowModel = table?.getRowModel();
@@ -220,6 +222,21 @@ export function Content({
const hasData = rows?.length > 0 || isLoading;
+ // Determine if we're in zero state (no filters/search applied) or empty state (filters/search applied)
+ const hasFiltersOrSearch =
+ (tableQuery?.filters && tableQuery.filters.length > 0) ||
+ Boolean(tableQuery?.search && tableQuery.search.trim() !== '');
+
+ const isZeroState = !hasData && !hasFiltersOrSearch;
+ const isEmptyState = !hasData && hasFiltersOrSearch;
+
+ // Show zeroState when no data and no filters/search, otherwise show emptyState
+ const stateToShow: React.ReactNode = isZeroState
+ ? (zeroState ?? emptyState ?? )
+ : isEmptyState
+ ? (emptyState ?? )
+ : null;
+
return (
@@ -250,7 +267,7 @@ export function Content({
colSpan={visibleColumnsLength}
className={styles.emptyStateCell}
>
- {emptyState || }
+ {stateToShow}
)}
diff --git a/packages/raystack/components/data-table/components/search.tsx b/packages/raystack/components/data-table/components/search.tsx
index acdf8c27a..371bde378 100644
--- a/packages/raystack/components/data-table/components/search.tsx
+++ b/packages/raystack/components/data-table/components/search.tsx
@@ -5,9 +5,21 @@ import { Search } from '../../search';
import { SearchProps } from '../../search/search';
import { useDataTable } from '../hooks/useDataTable';
-export const TableSearch = forwardRef(
- ({ ...props }, ref) => {
- const { updateTableQuery, tableQuery } = useDataTable();
+export interface DataTableSearchProps extends SearchProps {
+ /**
+ * Automatically disable search in zero state (when no data and no filters/search applied).
+ * @defaultValue true
+ */
+ autoDisableInZeroState?: boolean;
+}
+
+export const TableSearch = forwardRef(
+ ({ autoDisableInZeroState = true, disabled, ...props }, ref) => {
+ const {
+ updateTableQuery,
+ tableQuery,
+ shouldShowFilters = false
+ } = useDataTable();
const handleSearch = (e: React.ChangeEvent) => {
const value = e.target.value;
@@ -28,6 +40,14 @@ export const TableSearch = forwardRef(
});
};
+ // Auto-disable in zero state if enabled, but allow manual override
+ // Once search is applied, keep it enabled (even if shouldShowFilters is false)
+ const hasSearch = Boolean(
+ tableQuery?.search && tableQuery.search.trim() !== ''
+ );
+ const isDisabled =
+ disabled ?? (autoDisableInZeroState && !shouldShowFilters && !hasSearch);
+
return (
(
onChange={handleSearch}
value={tableQuery?.search}
onClear={handleClear}
+ disabled={isDisabled}
/>
);
}
);
+
+TableSearch.displayName = 'TableSearch';
diff --git a/packages/raystack/components/data-table/components/toolbar.tsx b/packages/raystack/components/data-table/components/toolbar.tsx
index e7d705c6c..75d13011a 100644
--- a/packages/raystack/components/data-table/components/toolbar.tsx
+++ b/packages/raystack/components/data-table/components/toolbar.tsx
@@ -3,10 +3,17 @@
import { cx } from 'class-variance-authority';
import { Flex } from '../../flex';
import styles from '../data-table.module.css';
+import { useDataTable } from '../hooks/useDataTable';
import { DisplaySettings } from './display-settings';
import { Filters } from './filters';
export function Toolbar({ className }: { className?: string }) {
+ const { shouldShowFilters = false } = useDataTable();
+
+ if (!shouldShowFilters) {
+ return null;
+ }
+
return (
({
}
}, [searchQuery]);
+ // Determine if filters should be visible
+ // Filters should be visible if there is data OR if filters are applied (empty state)
+ // Filters should NOT be visible if no data AND no filters (zero state)
+ // Note: Search alone does not show the filter bar
+ const shouldShowFilters = useMemo(() => {
+ const hasFilters = tableQuery?.filters && tableQuery.filters.length > 0;
+
+ try {
+ const rowModel = table.getRowModel();
+ const hasData = (rowModel?.rows?.length ?? 0) > 0;
+ return hasData || hasFilters;
+ } catch {
+ // If table is not ready yet, check if we have initial data
+ // If no filters and no data, don't show filters
+ return hasFilters || data.length > 0;
+ }
+ }, [table, tableQuery, data.length]);
+
const contextValue: TableContextType = useMemo(() => {
return {
table,
@@ -146,7 +164,8 @@ function DataTableRoot({
onDisplaySettingsReset,
defaultSort,
loadingRowCount,
- onRowClick
+ onRowClick,
+ shouldShowFilters
};
}, [
table,
@@ -159,7 +178,8 @@ function DataTableRoot({
onDisplaySettingsReset,
defaultSort,
loadingRowCount,
- onRowClick
+ onRowClick,
+ shouldShowFilters
]);
return (
diff --git a/packages/raystack/components/data-table/data-table.types.tsx b/packages/raystack/components/data-table/data-table.types.tsx
index 45754796f..791c1cfe4 100644
--- a/packages/raystack/components/data-table/data-table.types.tsx
+++ b/packages/raystack/components/data-table/data-table.types.tsx
@@ -107,6 +107,7 @@ export interface DataTableProps {
export type DataTableContentProps = {
emptyState?: React.ReactNode;
+ zeroState?: React.ReactNode;
classNames?: {
root?: string;
table?: string;
@@ -130,6 +131,7 @@ export type TableContextType = {
onDisplaySettingsReset: () => void;
updateTableQuery: (fn: TableQueryUpdateFn) => void;
onRowClick?: (row: TData) => void;
+ shouldShowFilters?: boolean;
};
export interface ColumnData {
diff --git a/packages/raystack/components/data-table/index.ts b/packages/raystack/components/data-table/index.ts
index 743bf4f90..5b172f024 100644
--- a/packages/raystack/components/data-table/index.ts
+++ b/packages/raystack/components/data-table/index.ts
@@ -1,11 +1,12 @@
-export { DataTable } from "./data-table";
+export { DataTable } from './data-table';
export {
DataTableColumnDef,
InternalQuery,
DataTableQuery,
DataTableSort,
InternalFilter,
- DataTableFilter,
-} from "./data-table.types";
-export { EmptyFilterValue } from "~/types/filters";
-export { useDataTable } from "./hooks/useDataTable";
+ DataTableFilter
+} from './data-table.types';
+export { EmptyFilterValue } from '~/types/filters';
+export type { DataTableSearchProps } from './components/search';
+export { useDataTable } from './hooks/useDataTable';