diff --git a/.github/workflows/github-pages.yml b/.github/workflows/github-pages.yml index de30a644..7eb3402c 100644 --- a/.github/workflows/github-pages.yml +++ b/.github/workflows/github-pages.yml @@ -41,7 +41,7 @@ jobs: run: corepack enable - name: Install dependencies - run: yarn install + run: yarn install --immutable - name: Build Storybook run: yarn build-storybook diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml index ea88c148..e1e3d65c 100644 --- a/.github/workflows/pr-preview.yml +++ b/.github/workflows/pr-preview.yml @@ -40,7 +40,7 @@ jobs: run: corepack enable - name: Install dependencies - run: yarn install + run: yarn install --immutable - name: Build Storybook run: yarn build-storybook diff --git a/apps/docs/src/remix-hook-form/data-table/data-table-server-driven.stories.tsx b/apps/docs/src/remix-hook-form/data-table/data-table-server-driven.stories.tsx index 9ae7162e..25d927ce 100644 --- a/apps/docs/src/remix-hook-form/data-table/data-table-server-driven.stories.tsx +++ b/apps/docs/src/remix-hook-form/data-table/data-table-server-driven.stories.tsx @@ -1,4 +1,5 @@ -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryContext, StoryObj } from '@storybook/react'; +import { expect, within } from '@storybook/test'; import { useMemo } from 'react'; import { type LoaderFunctionArgs, useLoaderData, useSearchParams } from 'react-router'; import { columnConfigs, columns } from './data-table-stories.components'; @@ -252,10 +253,200 @@ function DataTableWithBazzaFilters() { ); } +// --- DataTableWithScrolling --- +function DataTableWithScrolling() { + // Get the loader data (filtered/paginated/sorted data from server) + const loaderData = useLoaderData(); + const [searchParams, setSearchParams] = useSearchParams(); + + // Initialize data from loader response + const data = loaderData?.data ?? []; + const pageCount = loaderData?.meta.pageCount ?? 0; + const facetedCounts = loaderData?.facetedCounts ?? {}; + + // Convert facetedCounts to the correct type for useDataTableFilters (Map-based) + const facetedOptionCounts = useMemo(() => { + const result: Partial>> = {}; + Object.entries(facetedCounts).forEach(([col, valueObj]) => { + result[col] = new Map(Object.entries(valueObj)); + }); + return result; + }, [facetedCounts]); + + // --- Bazza UI Filter Setup --- + // 1. Initialize filters state with useFilterSync (syncs with URL) + const [filters, setFilters] = useFilterSync(); + + // --- Read pagination and sorting directly from URL --- + // Use larger page size to ensure scrolling is needed + const pageIndex = Number.parseInt(searchParams.get('page') ?? '0', 10); + const pageSize = Number.parseInt(searchParams.get('pageSize') ?? '20', 10); + const sortField = searchParams.get('sortField'); + const sortOrder = (searchParams.get('sortOrder') || 'asc') as 'asc' | 'desc'; + + // --- Pagination and Sorting State --- + const pagination = { pageIndex, pageSize }; + const sorting = sortField ? [{ id: sortField, desc: sortOrder === 'desc' }] : []; + + // --- Event Handlers: update URL directly --- + const handlePaginationChange: OnChangeFn = (updaterOrValue) => { + const next = typeof updaterOrValue === 'function' ? updaterOrValue(pagination) : updaterOrValue; + searchParams.set('page', next.pageIndex.toString()); + searchParams.set('pageSize', next.pageSize.toString()); + setSearchParams(searchParams); + }; + + const handleSortingChange: OnChangeFn = (updaterOrValue) => { + const next = typeof updaterOrValue === 'function' ? updaterOrValue(sorting) : updaterOrValue; + if (next.length > 0) { + searchParams.set('sortField', next[0].id); + searchParams.set('sortOrder', next[0].desc ? 'desc' : 'asc'); + } else { + searchParams.delete('sortField'); + searchParams.delete('sortOrder'); + } + setSearchParams(searchParams); + }; + + // --- Bazza UI Filter Setup --- + const bazzaProcessedColumns = useMemo(() => columnConfigs, []); + + // Define a filter strategy (replace with your actual strategy if needed) + const filterStrategy = 'server' as const; + + // Setup filter actions and strategy (controlled mode) + const { + columns: filterColumns, + actions, + strategy, + } = useDataTableFilters({ + columnsConfig: bazzaProcessedColumns, + filters, + onFiltersChange: setFilters, + faceted: facetedOptionCounts, + strategy: filterStrategy, + data, + }); + + // --- TanStack Table Setup --- + const table = useReactTable({ + data, + columns, + pageCount, + state: { + pagination, + sorting, + }, + onPaginationChange: handlePaginationChange, + onSortingChange: handleSortingChange, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + manualPagination: true, + manualSorting: true, + }); + + return ( +
+
+

Data Table with Scrolling and Sticky Header

+

+ This demonstrates the table with vertical scrolling and a sticky header that remains visible while scrolling + through table rows. The table is contained within a fixed-height container. +

+
+ + {/* Bazza UI Filter Interface */} + + +
+ {/* Data Table */} + +
+
+ ); +} + // --- Test Functions --- const testInitialRenderServerSide = testInitialRender('Issues Table (Bazza UI Server Filters via Loader)'); const testPaginationServerSide = testPagination({ serverSide: true }); +/** + * Test scrolling functionality and sticky header + */ +const testScrolling = async ({ canvasElement }: StoryContext) => { + const canvas = within(canvasElement); + + // Wait for table to render + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Find the table container + const tableContainer = canvasElement.querySelector('[class*="rounded-md border"]'); + expect(tableContainer).toBeInTheDocument(); + + // Find the scrollable area (the div inside Table component) + const scrollableArea = tableContainer?.querySelector('[class*="overflow-auto"]') as HTMLElement | null; + expect(scrollableArea).toBeInTheDocument(); + + // Verify scrollable area exists and has content + if (!scrollableArea) { + throw new Error('Scrollable area not found'); + } + + // Get initial scroll position + const initialScrollTop = scrollableArea.scrollTop; + expect(initialScrollTop).toBe(0); + + // Verify scroll height is greater than client height (content is scrollable) + const isScrollable = scrollableArea.scrollHeight > scrollableArea.clientHeight; + expect(isScrollable).toBe(true); + + // Find the table header + const header = canvasElement.querySelector('thead'); + expect(header).toBeInTheDocument(); + + if (!header) { + throw new Error('Table header not found'); + } + + // Get header position before scrolling + const headerBeforeScroll = header.getBoundingClientRect(); + const headerTopBefore = headerBeforeScroll.top; + + // Scroll down + scrollableArea.scrollTop = 200; + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Verify that we scrolled (browser may round the scroll position, so check for reasonable scroll amount) + expect(scrollableArea.scrollTop).toBeGreaterThan(0); + expect(scrollableArea.scrollTop).toBeGreaterThan(100); // Verify we scrolled a reasonable amount + + // Verify header is still visible and sticky + const headerAfterScroll = header.getBoundingClientRect(); + expect(headerAfterScroll).toBeDefined(); + + // The header should have sticky positioning + const headerStyles = window.getComputedStyle(header); + expect(headerStyles.position).toBe('sticky'); + expect(headerStyles.top).toBe('0px'); + + // Verify header position relative to container hasn't changed (it's sticky) + const headerTopAfter = headerAfterScroll.top; + // Header should remain at the top of the scrollable container + expect(headerTopAfter).toBeGreaterThanOrEqual(headerTopBefore - 10); // Allow small margin for rounding + + // Scroll back to top + scrollableArea.scrollTop = 0; + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(scrollableArea.scrollTop).toBe(0); +}; + +/** + * Test initial render for scrolling story + */ +const testInitialRenderScrolling = testInitialRender('Data Table with Scrolling and Sticky Header'); + // --- Story Configuration --- const meta: Meta = { title: 'Data Table/Server Driven Filters', @@ -432,3 +623,32 @@ function DataTableWithBazzaFilters() { await testFiltering(context); }, }; + +export const WithScrolling: Story = { + args: {}, + parameters: { + docs: { + description: { + story: + '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.', + }, + }, + }, + render: () => , + decorators: [ + withReactRouterStubDecorator({ + routes: [ + { + path: '/', + Component: DataTableWithScrolling, + loader: handleDataFetch, + }, + ], + }), + ], + play: async (context) => { + // Run the tests in sequence + await testInitialRenderScrolling(context); + await testScrolling(context); + }, +}; diff --git a/packages/components/package.json b/packages/components/package.json index be7da4d3..e2cabcbf 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@lambdacurry/forms", - "version": "0.22.5", + "version": "0.23.0-beta.1", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/components/src/ui/data-table/data-table-pagination.tsx b/packages/components/src/ui/data-table/data-table-pagination.tsx index ec273073..6c82b9c9 100644 --- a/packages/components/src/ui/data-table/data-table-pagination.tsx +++ b/packages/components/src/ui/data-table/data-table-pagination.tsx @@ -2,6 +2,7 @@ import { ChevronLeftIcon, ChevronRightIcon, DoubleArrowLeftIcon, DoubleArrowRigh import { useSearchParams } from 'react-router'; import { Button } from '../button'; import { Select } from '../select'; +import { cn } from '../utils'; interface DataTablePaginationProps { pageCount: number; @@ -24,9 +25,13 @@ export function DataTablePagination({ pageCount, onPaginationChange }: DataTable return (