Skip to content

Commit ba6db51

Browse files
feat(DataTable): add scrolling and sticky header functionality to DataTable component
- Introduced DataTableWithScrolling component to demonstrate vertical scrolling and sticky header behavior. - Enhanced DataTablePagination with dynamic height adjustments. - Updated DataTable to support scrolling and improved layout for better user experience. - Added tests for scrolling functionality and initial render of the new component.
1 parent 7788aa7 commit ba6db51

File tree

4 files changed

+236
-11
lines changed

4 files changed

+236
-11
lines changed

apps/docs/src/remix-hook-form/data-table/data-table-server-driven.stories.tsx

Lines changed: 221 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import type { Meta, StoryObj } from '@storybook/react';
1+
import type { Meta, StoryContext, StoryObj } from '@storybook/react';
2+
import { expect, within } from '@storybook/test';
23
import { useMemo } from 'react';
34
import { type LoaderFunctionArgs, useLoaderData, useSearchParams } from 'react-router';
45
import { columnConfigs, columns } from './data-table-stories.components';
@@ -252,10 +253,200 @@ function DataTableWithBazzaFilters() {
252253
);
253254
}
254255

256+
// --- DataTableWithScrolling ---
257+
function DataTableWithScrolling() {
258+
// Get the loader data (filtered/paginated/sorted data from server)
259+
const loaderData = useLoaderData<DataResponse>();
260+
const [searchParams, setSearchParams] = useSearchParams();
261+
262+
// Initialize data from loader response
263+
const data = loaderData?.data ?? [];
264+
const pageCount = loaderData?.meta.pageCount ?? 0;
265+
const facetedCounts = loaderData?.facetedCounts ?? {};
266+
267+
// Convert facetedCounts to the correct type for useDataTableFilters (Map-based)
268+
const facetedOptionCounts = useMemo(() => {
269+
const result: Partial<Record<string, Map<string, number>>> = {};
270+
Object.entries(facetedCounts).forEach(([col, valueObj]) => {
271+
result[col] = new Map(Object.entries(valueObj));
272+
});
273+
return result;
274+
}, [facetedCounts]);
275+
276+
// --- Bazza UI Filter Setup ---
277+
// 1. Initialize filters state with useFilterSync (syncs with URL)
278+
const [filters, setFilters] = useFilterSync();
279+
280+
// --- Read pagination and sorting directly from URL ---
281+
// Use larger page size to ensure scrolling is needed
282+
const pageIndex = Number.parseInt(searchParams.get('page') ?? '0', 10);
283+
const pageSize = Number.parseInt(searchParams.get('pageSize') ?? '20', 10);
284+
const sortField = searchParams.get('sortField');
285+
const sortOrder = (searchParams.get('sortOrder') || 'asc') as 'asc' | 'desc';
286+
287+
// --- Pagination and Sorting State ---
288+
const pagination = { pageIndex, pageSize };
289+
const sorting = sortField ? [{ id: sortField, desc: sortOrder === 'desc' }] : [];
290+
291+
// --- Event Handlers: update URL directly ---
292+
const handlePaginationChange: OnChangeFn<PaginationState> = (updaterOrValue) => {
293+
const next = typeof updaterOrValue === 'function' ? updaterOrValue(pagination) : updaterOrValue;
294+
searchParams.set('page', next.pageIndex.toString());
295+
searchParams.set('pageSize', next.pageSize.toString());
296+
setSearchParams(searchParams);
297+
};
298+
299+
const handleSortingChange: OnChangeFn<SortingState> = (updaterOrValue) => {
300+
const next = typeof updaterOrValue === 'function' ? updaterOrValue(sorting) : updaterOrValue;
301+
if (next.length > 0) {
302+
searchParams.set('sortField', next[0].id);
303+
searchParams.set('sortOrder', next[0].desc ? 'desc' : 'asc');
304+
} else {
305+
searchParams.delete('sortField');
306+
searchParams.delete('sortOrder');
307+
}
308+
setSearchParams(searchParams);
309+
};
310+
311+
// --- Bazza UI Filter Setup ---
312+
const bazzaProcessedColumns = useMemo(() => columnConfigs, []);
313+
314+
// Define a filter strategy (replace with your actual strategy if needed)
315+
const filterStrategy = 'server' as const;
316+
317+
// Setup filter actions and strategy (controlled mode)
318+
const {
319+
columns: filterColumns,
320+
actions,
321+
strategy,
322+
} = useDataTableFilters({
323+
columnsConfig: bazzaProcessedColumns,
324+
filters,
325+
onFiltersChange: setFilters,
326+
faceted: facetedOptionCounts,
327+
strategy: filterStrategy,
328+
data,
329+
});
330+
331+
// --- TanStack Table Setup ---
332+
const table = useReactTable({
333+
data,
334+
columns,
335+
pageCount,
336+
state: {
337+
pagination,
338+
sorting,
339+
},
340+
onPaginationChange: handlePaginationChange,
341+
onSortingChange: handleSortingChange,
342+
getCoreRowModel: getCoreRowModel(),
343+
getPaginationRowModel: getPaginationRowModel(),
344+
getSortedRowModel: getSortedRowModel(),
345+
manualPagination: true,
346+
manualSorting: true,
347+
});
348+
349+
return (
350+
<div className="space-y-4">
351+
<div>
352+
<h1 className="text-2xl font-bold mb-4">Data Table with Scrolling and Sticky Header</h1>
353+
<p className="text-gray-600 mb-6">
354+
This demonstrates the table with vertical scrolling and a sticky header that remains visible while scrolling
355+
through table rows. The table is contained within a fixed-height container.
356+
</p>
357+
</div>
358+
359+
{/* Bazza UI Filter Interface */}
360+
<DataTableFilter columns={filterColumns} filters={filters} actions={actions} strategy={strategy} />
361+
362+
<div className="h-[500px] overflow-hidden">
363+
{/* Data Table */}
364+
<DataTable table={table} columns={columns.length} pageCount={pageCount} />
365+
</div>
366+
</div>
367+
);
368+
}
369+
255370
// --- Test Functions ---
256371
const testInitialRenderServerSide = testInitialRender('Issues Table (Bazza UI Server Filters via Loader)');
257372
const testPaginationServerSide = testPagination({ serverSide: true });
258373

374+
/**
375+
* Test scrolling functionality and sticky header
376+
*/
377+
const testScrolling = async ({ canvasElement }: StoryContext) => {
378+
const canvas = within(canvasElement);
379+
380+
// Wait for table to render
381+
await new Promise((resolve) => setTimeout(resolve, 500));
382+
383+
// Find the table container
384+
const tableContainer = canvasElement.querySelector('[class*="rounded-md border"]');
385+
expect(tableContainer).toBeInTheDocument();
386+
387+
// Find the scrollable area (the div inside Table component)
388+
const scrollableArea = tableContainer?.querySelector('[class*="overflow-auto"]') as HTMLElement | null;
389+
expect(scrollableArea).toBeInTheDocument();
390+
391+
// Verify scrollable area exists and has content
392+
if (!scrollableArea) {
393+
throw new Error('Scrollable area not found');
394+
}
395+
396+
// Get initial scroll position
397+
const initialScrollTop = scrollableArea.scrollTop;
398+
expect(initialScrollTop).toBe(0);
399+
400+
// Verify scroll height is greater than client height (content is scrollable)
401+
const isScrollable = scrollableArea.scrollHeight > scrollableArea.clientHeight;
402+
expect(isScrollable).toBe(true);
403+
404+
// Find the table header
405+
const header = canvasElement.querySelector('thead');
406+
expect(header).toBeInTheDocument();
407+
408+
if (!header) {
409+
throw new Error('Table header not found');
410+
}
411+
412+
// Get header position before scrolling
413+
const headerBeforeScroll = header.getBoundingClientRect();
414+
const headerTopBefore = headerBeforeScroll.top;
415+
416+
// Scroll down
417+
scrollableArea.scrollTop = 200;
418+
await new Promise((resolve) => setTimeout(resolve, 100));
419+
420+
// Verify that we scrolled (browser may round the scroll position, so check for reasonable scroll amount)
421+
expect(scrollableArea.scrollTop).toBeGreaterThan(0);
422+
expect(scrollableArea.scrollTop).toBeGreaterThan(100); // Verify we scrolled a reasonable amount
423+
424+
// Verify header is still visible and sticky
425+
const headerAfterScroll = header.getBoundingClientRect();
426+
expect(headerAfterScroll).toBeDefined();
427+
428+
// The header should have sticky positioning
429+
const headerStyles = window.getComputedStyle(header);
430+
expect(headerStyles.position).toBe('sticky');
431+
expect(headerStyles.top).toBe('0px');
432+
433+
// Verify header position relative to container hasn't changed (it's sticky)
434+
const headerTopAfter = headerAfterScroll.top;
435+
// Header should remain at the top of the scrollable container
436+
expect(headerTopAfter).toBeGreaterThanOrEqual(headerTopBefore - 10); // Allow small margin for rounding
437+
438+
// Scroll back to top
439+
scrollableArea.scrollTop = 0;
440+
await new Promise((resolve) => setTimeout(resolve, 100));
441+
442+
expect(scrollableArea.scrollTop).toBe(0);
443+
};
444+
445+
/**
446+
* Test initial render for scrolling story
447+
*/
448+
const testInitialRenderScrolling = testInitialRender('Data Table with Scrolling and Sticky Header');
449+
259450
// --- Story Configuration ---
260451
const meta: Meta<typeof DataTableWithBazzaFilters> = {
261452
title: 'Data Table/Server Driven Filters',
@@ -432,3 +623,32 @@ function DataTableWithBazzaFilters() {
432623
await testFiltering(context);
433624
},
434625
};
626+
627+
export const WithScrolling: Story = {
628+
args: {},
629+
parameters: {
630+
docs: {
631+
description: {
632+
story:
633+
'Demonstrates the data table with vertical scrolling and a sticky header. The table is contained within a fixed-height container (500px) and uses a larger page size (20 rows) to ensure scrolling is needed. The header remains visible while scrolling through table rows.',
634+
},
635+
},
636+
},
637+
render: () => <DataTableWithScrolling />,
638+
decorators: [
639+
withReactRouterStubDecorator({
640+
routes: [
641+
{
642+
path: '/',
643+
Component: DataTableWithScrolling,
644+
loader: handleDataFetch,
645+
},
646+
],
647+
}),
648+
],
649+
play: async (context) => {
650+
// Run the tests in sequence
651+
await testInitialRenderScrolling(context);
652+
await testScrolling(context);
653+
},
654+
};

packages/components/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@lambdacurry/forms",
3-
"version": "0.22.5",
3+
"version": "0.23.0-beta.1",
44
"type": "module",
55
"main": "./dist/index.js",
66
"types": "./dist/index.d.ts",

packages/components/src/ui/data-table/data-table-pagination.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { ChevronLeftIcon, ChevronRightIcon, DoubleArrowLeftIcon, DoubleArrowRigh
22
import { useSearchParams } from 'react-router';
33
import { Button } from '../button';
44
import { Select } from '../select';
5+
import { cn } from '../utils';
56

67
interface DataTablePaginationProps {
78
pageCount: number;
@@ -24,9 +25,13 @@ export function DataTablePagination({ pageCount, onPaginationChange }: DataTable
2425
return (
2526
<nav
2627
aria-label="Data table pagination"
27-
className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between px-2 py-2"
28+
className={cn(
29+
"flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between px-2 py-2",
30+
"h-[var(--lc-datatable-pagination-height,140px)]",
31+
"sm:h-[var(--lc-datatable-pagination-height,96px)]",
32+
)}
2833
>
29-
<div className="flex-1 text-sm text-muted-foreground">{pageSize} rows per page</div>
34+
<div className="max-sm:hidden flex-1 text-sm text-muted-foreground">{pageSize} rows per page</div>
3035
<div className="flex flex-col sm:flex-row items-center gap-4 sm:gap-6 lg:gap-8">
3136
<div className="flex items-center gap-2">
3237
<p className="text-sm font-medium whitespace-nowrap">Rows per page</p>

packages/components/src/ui/data-table/data-table.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,10 @@ export function DataTable<TData>({
1919
className,
2020
}: DataTableProps<TData>) {
2121
return (
22-
<div className={cn('space-y-4', className)}>
23-
<div className="rounded-md border">
24-
<Table>
25-
<TableHeader>
22+
<div className={cn('space-y-4 h-full [--lc-datatable-pagination-height:140px] sm:[--lc-datatable-pagination-height:96px]', className)}>
23+
<div className="rounded-md border flex flex-col max-h-[calc(100%-var(--lc-datatable-pagination-height,140px)-1rem)] sm:max-h-[calc(100%-var(--lc-datatable-pagination-height,96px)-1rem)] overflow-hidden">
24+
<Table className="[&>div]:h-full [&>div>table]:h-full">
25+
<TableHeader className="sticky top-0 z-10 bg-background">
2626
{table.getHeaderGroups().map((headerGroup) => (
2727
<TableRow key={headerGroup.id}>
2828
{headerGroup.headers.map((header) => {
@@ -35,7 +35,7 @@ export function DataTable<TData>({
3535
</TableRow>
3636
))}
3737
</TableHeader>
38-
<TableBody>
38+
<TableBody className={cn(!table.getRowModel().rows?.length && 'min-h-[calc(100%-3rem)]')}>
3939
{table.getRowModel().rows?.length ? (
4040
table.getRowModel().rows.map((row) => (
4141
<TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
@@ -45,8 +45,8 @@ export function DataTable<TData>({
4545
</TableRow>
4646
))
4747
) : (
48-
<TableRow>
49-
<TableCell colSpan={columns} className="h-24 text-center">
48+
<TableRow className="h-full">
49+
<TableCell colSpan={columns} className="h-full text-center">
5050
No results.
5151
</TableCell>
5252
</TableRow>

0 commit comments

Comments
 (0)