diff --git a/apps/www/src/app/examples/datatable-virtual/page.tsx b/apps/www/src/app/examples/datatable-virtual/page.tsx new file mode 100644 index 000000000..3af50eb8b --- /dev/null +++ b/apps/www/src/app/examples/datatable-virtual/page.tsx @@ -0,0 +1,604 @@ +'use client'; + +import { + Button, + DataTable, + DataTableColumnDef, + EmptyState, + Flex, + IconButton, + Navbar, + Search, + Sidebar, + Text +} from '@raystack/apsara'; +import { BellIcon, FilterIcon, SidebarIcon } from '@raystack/apsara/icons'; +import { useCallback, useMemo, useState } from 'react'; + +const sampleData = [ + { + id: '1', + name: 'Alice', + email: 'alice@example.com', + role: 'Admin', + department: 'Engineering', + team: 'Frontend', + location: 'NYC', + phone: '+1-555-0101', + status: 'Active', + joined: '2022-01-15' + }, + { + id: '2', + name: 'Bob', + email: 'bob@example.com', + role: 'User', + department: 'Product', + team: 'Design', + location: 'SF', + phone: '+1-555-0102', + status: 'Active', + joined: '2022-03-20' + }, + { + id: '3', + name: 'Carol', + email: 'carol@example.com', + role: 'Manager', + department: 'Engineering', + team: 'Backend', + location: 'NYC', + phone: '+1-555-0103', + status: 'Active', + joined: '2021-11-08' + }, + { + id: '4', + name: 'Dave', + email: 'dave@example.com', + role: 'User', + department: 'Sales', + team: 'East', + location: 'Boston', + phone: '+1-555-0104', + status: 'Away', + joined: '2023-02-14' + }, + { + id: '5', + name: 'Eve', + email: 'eve@example.com', + role: 'Admin', + department: 'Engineering', + team: 'Frontend', + location: 'Remote', + phone: '+1-555-0105', + status: 'Active', + joined: '2020-06-01' + }, + { + id: '6', + name: 'Frank', + email: 'frank@example.com', + role: 'User', + department: 'Support', + team: 'Tier 1', + location: 'Austin', + phone: '+1-555-0106', + status: 'Active', + joined: '2023-05-10' + }, + { + id: '7', + name: 'Grace', + email: 'grace@example.com', + role: 'Manager', + department: 'Product', + team: 'Design', + location: 'SF', + phone: '+1-555-0107', + status: 'Active', + joined: '2021-09-22' + }, + { + id: '8', + name: 'Henry', + email: 'henry@example.com', + role: 'Admin', + department: 'Engineering', + team: 'Backend', + location: 'Seattle', + phone: '+1-555-0108', + status: 'Away', + joined: '2019-12-05' + }, + { + id: '9', + name: 'Ivy', + email: 'ivy@example.com', + role: 'User', + department: 'Marketing', + team: 'Content', + location: 'NYC', + phone: '+1-555-0109', + status: 'Active', + joined: '2022-08-30' + }, + { + id: '10', + name: 'Jack', + email: 'jack@example.com', + role: 'User', + department: 'Engineering', + team: 'Frontend', + location: 'Remote', + phone: '+1-555-0110', + status: 'Active', + joined: '2023-01-12' + }, + { + id: '11', + name: 'Kate', + email: 'kate@example.com', + role: 'Manager', + department: 'Sales', + team: 'West', + location: 'LA', + phone: '+1-555-0111', + status: 'Active', + joined: '2020-04-18' + }, + { + id: '12', + name: 'Leo', + email: 'leo@example.com', + role: 'Admin', + department: 'Engineering', + team: 'DevOps', + location: 'NYC', + phone: '+1-555-0112', + status: 'Active', + joined: '2021-07-07' + }, + { + id: '13', + name: 'Mia', + email: 'mia@example.com', + role: 'User', + department: 'Product', + team: 'Design', + location: 'Chicago', + phone: '+1-555-0113', + status: 'Away', + joined: '2022-11-25' + }, + { + id: '14', + name: 'Noah', + email: 'noah@example.com', + role: 'User', + department: 'Support', + team: 'Tier 2', + location: 'Austin', + phone: '+1-555-0114', + status: 'Active', + joined: '2023-03-03' + }, + { + id: '15', + name: 'Olivia', + email: 'olivia@example.com', + role: 'Manager', + department: 'Engineering', + team: 'Frontend', + location: 'SF', + phone: '+1-555-0115', + status: 'Active', + joined: '2020-10-11' + }, + { + id: '16', + name: 'Paul', + email: 'paul@example.com', + role: 'Admin', + department: 'Sales', + team: 'East', + location: 'Boston', + phone: '+1-555-0116', + status: 'Active', + joined: '2019-08-19' + }, + { + id: '17', + name: 'Quinn', + email: 'quinn@example.com', + role: 'User', + department: 'Marketing', + team: 'Growth', + location: 'Remote', + phone: '+1-555-0117', + status: 'Active', + joined: '2022-05-06' + }, + { + id: '18', + name: 'Ryan', + email: 'ryan@example.com', + role: 'User', + department: 'Engineering', + team: 'Backend', + location: 'Seattle', + phone: '+1-555-0118', + status: 'Away', + joined: '2021-02-28' + }, + { + id: '19', + name: 'Sara', + email: 'sara@example.com', + role: 'Manager', + department: 'Support', + team: 'Tier 1', + location: 'Austin', + phone: '+1-555-0119', + status: 'Active', + joined: '2020-01-14' + }, + { + id: '20', + name: 'Tom', + email: 'tom@example.com', + role: 'Admin', + department: 'Product', + team: 'Design', + location: 'NYC', + phone: '+1-555-0120', + status: 'Active', + joined: '2018-12-01' + }, + { + id: '21', + name: 'Uma', + email: 'uma@example.com', + role: 'User', + department: 'Engineering', + team: 'Frontend', + location: 'Remote', + phone: '+1-555-0121', + status: 'Active', + joined: '2023-04-17' + }, + { + id: '22', + name: 'Victor', + email: 'victor@example.com', + role: 'User', + department: 'Sales', + team: 'West', + location: 'LA', + phone: '+1-555-0122', + status: 'Active', + joined: '2022-09-09' + }, + { + id: '23', + name: 'Wendy', + email: 'wendy@example.com', + role: 'Manager', + department: 'Engineering', + team: 'Backend', + location: 'SF', + phone: '+1-555-0123', + status: 'Away', + joined: '2021-06-21' + }, + { + id: '24', + name: 'Xavier', + email: 'xavier@example.com', + role: 'Admin', + department: 'Marketing', + team: 'Content', + location: 'Chicago', + phone: '+1-555-0124', + status: 'Active', + joined: '2019-03-12' + }, + { + id: '25', + name: 'Yara', + email: 'yara@example.com', + role: 'User', + department: 'Product', + team: 'Design', + location: 'Remote', + phone: '+1-555-0125', + status: 'Active', + joined: '2022-07-04' + }, + { + id: '26', + name: 'Zane', + email: 'zane@example.com', + role: 'User', + department: 'Support', + team: 'Tier 2', + location: 'Austin', + phone: '+1-555-0126', + status: 'Active', + joined: '2023-02-22' + }, + { + id: '27', + name: 'Amy', + email: 'amy@example.com', + role: 'Manager', + department: 'Engineering', + team: 'DevOps', + location: 'NYC', + phone: '+1-555-0127', + status: 'Active', + joined: '2020-11-30' + }, + { + id: '28', + name: 'Ben', + email: 'ben@example.com', + role: 'Admin', + department: 'Sales', + team: 'East', + location: 'Boston', + phone: '+1-555-0128', + status: 'Away', + joined: '2021-04-05' + }, + { + id: '29', + name: 'Chloe', + email: 'chloe@example.com', + role: 'User', + department: 'Marketing', + team: 'Growth', + location: 'SF', + phone: '+1-555-0129', + status: 'Active', + joined: '2022-12-19' + }, + { + id: '30', + name: 'Dan', + email: 'dan@example.com', + role: 'User', + department: 'Engineering', + team: 'Frontend', + location: 'Seattle', + phone: '+1-555-0130', + status: 'Active', + joined: '2023-06-08' + } +]; + +const PAGE_SIZE = 25; +const TOTAL_ROWS = 200; + +const fullDataset = Array.from({ length: TOTAL_ROWS }, (_, i) => { + const base = sampleData[i % sampleData.length]; + return { + ...base, + id: String(i + 1), + name: `${base.name} ${Math.floor(i / sampleData.length) + 1}`, + email: `${base.name.toLowerCase()}${i + 1}@example.com`, + phone: `+1-555-${String(1000 + i).padStart(4, '0')}` + }; +}); + +const columns: DataTableColumnDef<(typeof fullDataset)[number], unknown>[] = [ + { + accessorKey: 'name', + header: 'Name', + enableSorting: true, + enableColumnFilter: true, + filterType: 'string' as const, + enableGrouping: true, + showGroupCount: true, + enableHiding: true + }, + { + accessorKey: 'email', + header: 'Email', + enableSorting: true, + enableColumnFilter: true, + filterType: 'string' as const, + enableHiding: true + }, + { + accessorKey: 'role', + header: 'Role', + enableSorting: true, + enableColumnFilter: true, + filterType: 'select' as const, + enableGrouping: true, + showGroupCount: true, + enableHiding: true, + filterOptions: [ + { value: 'Admin', label: 'Admin' }, + { value: 'User', label: 'User' }, + { value: 'Manager', label: 'Manager' } + ] + }, + { + accessorKey: 'department', + header: 'Department', + enableSorting: true, + enableGrouping: true, + showGroupCount: true, + enableHiding: true + }, + { + accessorKey: 'team', + header: 'Team', + enableSorting: true, + enableGrouping: true, + showGroupCount: true, + enableHiding: true + }, + { + accessorKey: 'location', + header: 'Location', + enableSorting: true, + enableGrouping: true, + showGroupCount: true, + enableHiding: true + }, + { accessorKey: 'phone', header: 'Phone', enableHiding: true }, + { + accessorKey: 'status', + header: 'Status', + enableSorting: true, + enableGrouping: true, + showGroupCount: true, + enableColumnFilter: true, + filterType: 'select' as const, + enableHiding: true, + filterOptions: [ + { value: 'Active', label: 'Active' }, + { value: 'Away', label: 'Away' } + ] + }, + { + accessorKey: 'joined', + header: 'Joined', + enableSorting: true, + enableHiding: true + } +]; + +const Page = () => { + const [navbarSearch, setNavbarSearch] = useState(''); + const [data, setData] = useState(() => fullDataset.slice(0, PAGE_SIZE)); + const [isLoading, setIsLoading] = useState(false); + + const handleLoadMore = useCallback(async () => { + if (data.length >= TOTAL_ROWS || isLoading) return; + setIsLoading(true); + await new Promise(resolve => setTimeout(resolve, 1500)); + setData(prev => fullDataset.slice(0, prev.length + PAGE_SIZE)); + setIsLoading(false); + }, [data.length, isLoading]); + + const tableColumns = useMemo(() => columns, []); + + return ( + + + + + {}} aria-label='Logo'> + + + + Raystack + + + + + }> + Examples + + } + > + DataTable – Virtualized + + } + > + DataTable – Content + + + + Help & Support + Preferences + + + + + + + + DataTable – Virtualized – {TOTAL_ROWS} rows w/ infinite scroll, + grouping & sorting + + + + ) => + setNavbarSearch(e.target.value) + } + onClear={() => setNavbarSearch('')} + size='small' + style={{ width: '200px' }} + /> + + + + + + + + + + } + heading='No results' + variant='empty1' + subHeading='Try adjusting your filters or search.' + /> + } + /> + + + + + + ); +}; + +export default Page; diff --git a/apps/www/src/app/examples/datatable/page.tsx b/apps/www/src/app/examples/datatable/page.tsx index 37485e13d..6d72e06a1 100644 --- a/apps/www/src/app/examples/datatable/page.tsx +++ b/apps/www/src/app/examples/datatable/page.tsx @@ -386,14 +386,16 @@ const columns: DataTableColumnDef<(typeof sampleData)[number], unknown>[] = [ filterType: 'string' as const, enableGrouping: true, showGroupCount: true, - enableSorting: true + enableSorting: true, + enableHiding: true }, { accessorKey: 'email', header: 'Email', enableColumnFilter: true, filterType: 'string' as const, - enableSorting: true + enableSorting: true, + enableHiding: true }, { accessorKey: 'role', @@ -403,6 +405,7 @@ const columns: DataTableColumnDef<(typeof sampleData)[number], unknown>[] = [ enableGrouping: true, showGroupCount: true, enableSorting: true, + enableHiding: true, filterOptions: [ { value: 'Admin', label: 'Admin' }, { value: 'User', label: 'User' }, @@ -414,43 +417,72 @@ const columns: DataTableColumnDef<(typeof sampleData)[number], unknown>[] = [ header: 'Department', enableGrouping: true, showGroupCount: true, - enableSorting: true + enableSorting: true, + enableHiding: true }, { accessorKey: 'team', header: 'Team', enableGrouping: true, showGroupCount: true, - enableSorting: true + enableSorting: true, + enableHiding: true }, { accessorKey: 'location', header: 'Location', enableGrouping: true, showGroupCount: true, - enableSorting: true + enableSorting: true, + enableHiding: true }, - { accessorKey: 'phone', header: 'Phone' }, + { accessorKey: 'phone', header: 'Phone', enableHiding: true }, { accessorKey: 'status', header: 'Status', enableGrouping: true, showGroupCount: true, - enableSorting: true - }, - { accessorKey: 'joined', header: 'Joined', enableSorting: true }, - { accessorKey: 'name', id: 'name_2', header: 'Name (2)' }, - { accessorKey: 'email', id: 'email_2', header: 'Email (2)' }, - { accessorKey: 'role', id: 'role_2', header: 'Role (2)' }, - { accessorKey: 'department', id: 'dept_2', header: 'Department (2)' }, - { accessorKey: 'team', id: 'team_2', header: 'Team (2)' }, - { accessorKey: 'location', id: 'loc_2', header: 'Location (2)' }, - { accessorKey: 'phone', id: 'phone_2', header: 'Phone (2)' }, - { accessorKey: 'status', id: 'status_2', header: 'Status (2)' }, - { accessorKey: 'joined', id: 'joined_2', header: 'Joined (2)' }, - { accessorKey: 'name', id: 'name_3', header: 'Name (3)' }, - { accessorKey: 'email', id: 'email_3', header: 'Email (3)' }, - { accessorKey: 'role', id: 'role_3', header: 'Role (3)' } + enableSorting: true, + enableHiding: true + }, + { + accessorKey: 'joined', + header: 'Joined', + enableSorting: true, + enableHiding: true + }, + { accessorKey: 'name', id: 'name_2', header: 'Name', enableHiding: true }, + { accessorKey: 'email', id: 'email_2', header: 'Email', enableHiding: true }, + { accessorKey: 'role', id: 'role_2', header: 'Role', enableHiding: true }, + { + accessorKey: 'department', + id: 'dept_2', + header: 'Department', + enableHiding: true + }, + { accessorKey: 'team', id: 'team_2', header: 'Team', enableHiding: true }, + { + accessorKey: 'location', + id: 'loc_2', + header: 'Location', + enableHiding: true + }, + { accessorKey: 'phone', id: 'phone_2', header: 'Phone', enableHiding: true }, + { + accessorKey: 'status', + id: 'status_2', + header: 'Status', + enableHiding: true + }, + { + accessorKey: 'joined', + id: 'joined_2', + header: 'Joined', + enableHiding: true + }, + { accessorKey: 'name', id: 'name_3', header: 'Name', enableHiding: true }, + { accessorKey: 'email', id: 'email_3', header: 'Email', enableHiding: true }, + { accessorKey: 'role', id: 'role_3', header: 'Role', enableHiding: true } ]; const Page = () => { diff --git a/packages/raystack/components/data-table/components/virtualized-content.tsx b/packages/raystack/components/data-table/components/virtualized-content.tsx index 3dd4ff55f..11da4fbc5 100644 --- a/packages/raystack/components/data-table/components/virtualized-content.tsx +++ b/packages/raystack/components/data-table/components/virtualized-content.tsx @@ -19,6 +19,7 @@ import { VirtualizedContentProps } from '../data-table.types'; import { useDataTable } from '../hooks/useDataTable'; +import { useStickyGroupAnchor } from '../hooks/useStickyGroupAnchor'; import { hasActiveQuery } from '../utils'; function VirtualHeaders({ @@ -64,13 +65,12 @@ function VirtualHeaders({ function VirtualGroupHeader({ data, - style + ...rest }: { data: GroupedData; - style?: React.CSSProperties; -}) { +} & React.HTMLAttributes) { return ( -
+
{data?.label} {data.showGroupCount ? ( @@ -85,16 +85,18 @@ function VirtualRows({ rows, virtualizer, onRowClick, - classNames + classNames, + hiddenGroupIndex }: { rows: Row[]; virtualizer: ReturnType; onRowClick?: (row: TData) => void; classNames?: { row?: string }; + hiddenGroupIndex?: number | null; }) { const items = virtualizer.getVirtualItems(); - return items.map((item, idx) => { + return items.map(item => { const row = rows[item.index]; if (!row) return null; @@ -109,11 +111,17 @@ function VirtualRows({ }; if (isGroupHeader) { + const isHidden = item.index === hiddenGroupIndex; return ( } - style={positionStyle} + aria-hidden={isHidden ? 'true' : undefined} + style={ + isHidden + ? { ...positionStyle, visibility: 'hidden' } + : positionStyle + } /> ); } @@ -209,7 +217,7 @@ const DefaultEmptyComponent = () => ( export function VirtualizedContent({ rowHeight = 40, - groupHeaderHeight, + groupHeaderHeight = 40, overscan = 5, loadMoreOffset = 100, emptyState, @@ -233,23 +241,31 @@ export function VirtualizedContent({ const scrollContainerRef = useRef(null); const headerRef = useRef(null); - const [stickyGroup, setStickyGroup] = useState | null>( - null - ); const [headerHeight, setHeaderHeight] = useState(40); const groupBy = tableQuery?.group_by?.[0]; const isGrouped = Boolean(groupBy) && groupBy !== defaultGroupOption.id; const groupHeaderList = useMemo(() => { - const list: { index: number; data: GroupedData }[] = []; + const list: { + index: number; + start: number; + data: GroupedData; + }[] = []; + let offset = 0; rows.forEach((row, i) => { - if (row.subRows && row.subRows.length > 0) { - list.push({ index: i, data: row.original as GroupedData }); + const isGroupHeader = row.subRows && row.subRows.length > 0; + if (isGroupHeader) { + list.push({ + index: i, + start: offset, + data: row.original as GroupedData + }); } + offset += isGroupHeader ? groupHeaderHeight : rowHeight; }); return list; - }, [rows]); + }, [rows, groupHeaderHeight, rowHeight]); const showLoaderRows = isLoading && rows.length > 0; @@ -259,28 +275,27 @@ export function VirtualizedContent({ estimateSize: index => { const row = rows[index]; const isGroupHeader = row?.subRows && row.subRows.length > 0; - return isGroupHeader ? (groupHeaderHeight ?? rowHeight) : rowHeight; + return isGroupHeader ? groupHeaderHeight : rowHeight; }, overscan }); - const updateStickyGroup = useCallback(() => { - if (!stickyGroupHeader || !isGrouped || groupHeaderList.length === 0) { - setStickyGroup(null); - return; - } - const items = virtualizer.getVirtualItems(); - const firstIndex = items[0]?.index ?? 0; - const current = groupHeaderList - .filter(g => g.index <= firstIndex) - .pop()?.data; - setStickyGroup(current ?? null); - }, [stickyGroupHeader, isGrouped, groupHeaderList, virtualizer]); + const anchorPixelHeight = groupHeaderHeight; + + const { + stickyGroup, + stickyGroupIndex, + recompute: recomputeStickyGroup + } = useStickyGroupAnchor({ + enabled: stickyGroupHeader && isGrouped, + groupHeaderList, + scrollContainerRef + }); const handleVirtualScroll = useCallback(() => { const el = scrollContainerRef.current; if (!el) return; - if (stickyGroupHeader) updateStickyGroup(); + if (stickyGroupHeader) recomputeStickyGroup(); if (isLoading) return; const { scrollTop, scrollHeight, clientHeight } = el; if (scrollHeight - scrollTop - clientHeight < loadMoreOffset) { @@ -291,7 +306,7 @@ export function VirtualizedContent({ isLoading, loadMoreData, loadMoreOffset, - updateStickyGroup + recomputeStickyGroup ]); const totalHeight = virtualizer.getTotalSize(); @@ -302,10 +317,6 @@ export function VirtualizedContent({ } }, [headerGroups]); - useLayoutEffect(() => { - if (stickyGroupHeader) updateStickyGroup(); - }, [stickyGroupHeader, updateStickyGroup, groupHeaderList, isGrouped]); - const hasData = rows?.length > 0 || isLoading; const hasChanges = hasActiveQuery(tableQuery || {}, defaultSort); @@ -340,9 +351,13 @@ export function VirtualizedContent({
{stickyGroupHeader && isGrouped && stickyGroup && (
diff --git a/packages/raystack/components/data-table/data-table.module.css b/packages/raystack/components/data-table/data-table.module.css index 46a7a123e..45017da4e 100644 --- a/packages/raystack/components/data-table/data-table.module.css +++ b/packages/raystack/components/data-table/data-table.module.css @@ -1,6 +1,5 @@ .toolbar { - padding: var(--rs-space-3) var(--rs-space-7) var(--rs-space-3) - var(--rs-space-5); + padding: var(--rs-space-3) var(--rs-space-7) var(--rs-space-3) var(--rs-space-5); align-self: stretch; border-bottom: 0.5px solid var(--rs-color-border-base-primary); @@ -93,6 +92,10 @@ border-spacing: 0; } +.contentRoot thead { + z-index: 2; +} + .row { background: var(--rs-color-background-base-primary); } @@ -131,7 +134,7 @@ .stickyHeader { position: sticky; top: 0; - z-index: 1; + z-index: 2; background: var(--rs-color-background-base-primary); } @@ -181,13 +184,18 @@ position: absolute; width: 100%; left: 0; + z-index: 1; display: flex; align-items: center; background: var(--rs-color-background-base-secondary); + color: var(--rs-color-foreground-base-primary); font-size: var(--rs-font-size-small); font-weight: var(--rs-font-weight-medium); line-height: var(--rs-line-height-small); + letter-spacing: var(--rs-letter-spacing-small); padding: var(--rs-space-3); + box-sizing: border-box; + box-shadow: 0 0.5px 0 0 var(--rs-color-border-base-primary); } /* Sticky group anchor: shows current group label while scrolling (virtualized) */ @@ -197,15 +205,14 @@ display: flex; align-items: center; background: var(--rs-color-background-base-secondary); + color: var(--rs-color-foreground-base-primary); font-size: var(--rs-font-size-small); font-weight: var(--rs-font-weight-medium); line-height: var(--rs-line-height-small); - padding: 0 var(--rs-space-3); - height: var(--rs-space-10); - margin-bottom: calc(-1 * var(--rs-space-10)); + letter-spacing: var(--rs-letter-spacing-small); + padding: var(--rs-space-3); box-sizing: border-box; - border-bottom: 0.5px solid var(--rs-color-border-base-primary); - box-shadow: 0 1px 0 0 var(--rs-color-border-base-primary); + box-shadow: 0 0.5px 0 0 var(--rs-color-border-base-primary); } .loaderContainer { @@ -217,10 +224,13 @@ } /* Non-virtualized: sticky section header under table header */ -.stickySectionHeader { +.contentRoot .stickySectionHeader { position: sticky; - top: var(--rs-space-8); + top: var(--rs-space-9); z-index: 1; + height: var(--rs-space-10); + padding: var(--rs-space-3); + box-sizing: border-box; background: var(--rs-color-background-base-secondary); - box-shadow: 0 1px 0 0 var(--rs-color-border-base-primary); + box-shadow: 0 0.5px 0 0 var(--rs-color-border-base-primary); } \ No newline at end of file diff --git a/packages/raystack/components/data-table/data-table.types.tsx b/packages/raystack/components/data-table/data-table.types.tsx index 156708841..1af28b3c3 100644 --- a/packages/raystack/components/data-table/data-table.types.tsx +++ b/packages/raystack/components/data-table/data-table.types.tsx @@ -139,7 +139,7 @@ export type DataTableContentProps = DataTableContentBaseProps; export type VirtualizedContentProps = DataTableContentBaseProps & { /** Height of each row in pixels. */ rowHeight?: number; - /** Height of group header rows in pixels. Falls back to rowHeight if not set. */ + /** Height of group header rows in pixels. Defaults to 40 (matches the non-virtualized section header height). */ groupHeaderHeight?: number; /** Number of rows to render outside visible area. */ overscan?: number; diff --git a/packages/raystack/components/data-table/hooks/useStickyGroupAnchor.tsx b/packages/raystack/components/data-table/hooks/useStickyGroupAnchor.tsx new file mode 100644 index 000000000..7c3f56d15 --- /dev/null +++ b/packages/raystack/components/data-table/hooks/useStickyGroupAnchor.tsx @@ -0,0 +1,79 @@ +import { useCallback, useLayoutEffect, useState } from 'react'; +import { GroupedData } from '../data-table.types'; + +interface UseStickyGroupAnchorParams { + enabled: boolean; + groupHeaderList: { + index: number; + start: number; + data: GroupedData; + }[]; + scrollContainerRef: React.RefObject; +} + +interface UseStickyGroupAnchorResult { + stickyGroup: GroupedData | null; + stickyGroupIndex: number | null; + recompute: () => void; +} + +/** + * Tracks the active group for the virtualized sticky group anchor. + * + * Picks the latest group whose `start` offset has been scrolled past, so the + * anchor's content stays in sync with the natural section header underneath. + * + * Returns the current group's data plus the row index of its natural section + * header — the consumer hides that row in the virtualized body so the natural + * header doesn't visually slide past the anchor (matching the non-virtualized + * table where CSS sticky pins each section header at the offset). + * + * `start` is supplied by the caller (computed from row sizes) rather than read + * from the virtualizer's measurements cache, so it stays correct across data + * loads even before the virtualizer has measured shifted rows. + */ +export function useStickyGroupAnchor({ + enabled, + groupHeaderList, + scrollContainerRef +}: UseStickyGroupAnchorParams): UseStickyGroupAnchorResult { + const [stickyGroup, setStickyGroup] = useState | null>( + null + ); + const [stickyGroupIndex, setStickyGroupIndex] = useState(null); + + const recompute = useCallback(() => { + if (!enabled || groupHeaderList.length === 0) { + setStickyGroup(null); + setStickyGroupIndex(null); + return; + } + const el = scrollContainerRef.current; + if (!el) return; + const scrollTop = el.scrollTop; + + let currentIdx = -1; + for (let i = 0; i < groupHeaderList.length; i++) { + if (groupHeaderList[i].start <= scrollTop) { + currentIdx = i; + } else { + break; + } + } + + if (currentIdx < 0) { + setStickyGroup(null); + setStickyGroupIndex(null); + return; + } + + setStickyGroup(groupHeaderList[currentIdx].data); + setStickyGroupIndex(groupHeaderList[currentIdx].index); + }, [enabled, groupHeaderList, scrollContainerRef]); + + useLayoutEffect(() => { + recompute(); + }, [recompute]); + + return { stickyGroup, stickyGroupIndex, recompute }; +}