diff --git a/apps/www/src/app/examples/datatable/page.tsx b/apps/www/src/app/examples/datatable/page.tsx new file mode 100644 index 000000000..aa2662f73 --- /dev/null +++ b/apps/www/src/app/examples/datatable/page.tsx @@ -0,0 +1,562 @@ +'use client'; + +import { + Button, + DataTable, + EmptyState, + Flex, + IconButton, + Navbar, + Search, + Sidebar, + Text +} from '@raystack/apsara'; +import { BellIcon, FilterIcon, SidebarIcon } from '@raystack/apsara/icons'; +import { 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 columns = [ + { + accessorKey: 'name', + header: 'Name', + enableColumnFilter: true, + filterType: 'string' as const, + enableGrouping: true + }, + { + accessorKey: 'email', + header: 'Email', + enableColumnFilter: true, + filterType: 'string' as const + }, + { + accessorKey: 'role', + header: 'Role', + enableColumnFilter: true, + filterType: 'select' as const, + enableGrouping: true, + showGroupCount: true, + filterOptions: [ + { value: 'Admin', label: 'Admin' }, + { value: 'User', label: 'User' }, + { value: 'Manager', label: 'Manager' } + ] + }, + { accessorKey: 'department', header: 'Department' }, + { accessorKey: 'team', header: 'Team' }, + { accessorKey: 'location', header: 'Location' }, + { accessorKey: 'phone', header: 'Phone' }, + { accessorKey: 'status', header: 'Status' }, + { accessorKey: 'joined', header: 'Joined' }, + { 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)' } +]; + +const PAGE_SIZE = 10; +const MAX_ROWS = 50; + +const Page = () => { + const [navbarSearch, setNavbarSearch] = useState(''); + const [data, setData] = useState(() => sampleData.slice(0, PAGE_SIZE)); + const [loading, setLoading] = useState(false); + const [hasMore, setHasMore] = useState(true); + + const loadMore = (): Promise => { + if (loading || !hasMore) return Promise.resolve(); + setLoading(true); + return new Promise(resolve => { + setTimeout(() => { + setData(prev => { + const next = sampleData.slice(prev.length, prev.length + PAGE_SIZE); + if (next.length < PAGE_SIZE) setHasMore(false); + return prev.length + next.length <= MAX_ROWS + ? [...prev, ...next] + : prev; + }); + setLoading(false); + resolve(); + }, 500); + }); + }; + + return ( + + + + + {}} aria-label='Logo'> + + + + Raystack + + + + + }> + Examples + + } + > + DataTable + + }> + Reports + Activities + + + Settings + Notifications + + + + Help & Support + Preferences + + + + + + + + DataTable – Infinite scroll + + + + ) => + 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/content/docs/components/datatable/index.mdx b/apps/www/src/content/docs/components/datatable/index.mdx index 8d11fa3e9..47ac6fee2 100644 --- a/apps/www/src/content/docs/components/datatable/index.mdx +++ b/apps/www/src/content/docs/components/datatable/index.mdx @@ -214,6 +214,22 @@ const columns = [ ]; ``` +#### Anchor group title + +When grouping is enabled, you can make the current group label stick under the table header while scrolling (anchor group title) in both `DataTable.Content` and `DataTable.VirtualizedContent` by setting `stickyGroupHeader={true}` on the root. It is off by default. + +```tsx + + + + +``` + ### Server-side Integration ```tsx diff --git a/apps/www/src/content/docs/components/datatable/props.ts b/apps/www/src/content/docs/components/datatable/props.ts index ba677cc8b..ee8cb3e08 100644 --- a/apps/www/src/content/docs/components/datatable/props.ts +++ b/apps/www/src/content/docs/components/datatable/props.ts @@ -36,6 +36,13 @@ export interface DataTableProps { columnVisibility: Record ) => void; + /** + * When true, the current group label sticks under the table header while scrolling (anchor group title). + * Applies to both Content and VirtualizedContent when grouping is enabled. + * @defaultValue false + */ + stickyGroupHeader?: boolean; + /** * Return a stable unique id for each row (used as React key). * Use for sortable/filterable tables to avoid key issues when rows reorder. diff --git a/packages/raystack/components/data-table/components/content.tsx b/packages/raystack/components/data-table/components/content.tsx index c7850639d..9f035a7df 100644 --- a/packages/raystack/components/data-table/components/content.tsx +++ b/packages/raystack/components/data-table/components/content.tsx @@ -60,6 +60,7 @@ interface RowsProps { row?: string; }; lastRowRef?: React.RefObject; + stickyGroupHeader?: boolean; } function LoaderRows({ @@ -86,13 +87,20 @@ function LoaderRows({ function GroupHeader({ colSpan, - data + data, + stickySectionHeader }: { colSpan: number; data: GroupedData; + stickySectionHeader?: boolean; }) { return ( - + {data?.label} {data.showGroupCount ? ( @@ -107,7 +115,8 @@ function Rows({ rows = [], onRowClick, classNames, - lastRowRef + lastRowRef, + stickyGroupHeader = false }: RowsProps) { return rows.map((row, idx) => { const isSelected = row.getIsSelected(); @@ -121,6 +130,7 @@ function Rows({ key={row.id} colSpan={cells.length} data={row.original as GroupedData} + stickySectionHeader={stickyGroupHeader} /> ); } @@ -174,7 +184,8 @@ export function Content({ loadMoreData, loadingRowCount = 3, tableQuery, - defaultSort + defaultSort, + stickyGroupHeader = false } = useDataTable(); const headerGroups = table?.getHeaderGroups(); @@ -251,6 +262,7 @@ export function Content({ classNames={{ row: classNames.row }} + stickyGroupHeader={stickyGroupHeader} /> {isLoading ? ( (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 }[] = []; + rows.forEach((row, i) => { + if (row.subRows && row.subRows.length > 0) { + list.push({ index: i, data: row.original as GroupedData }); + } + }); + return list; + }, [rows]); const showLoaderRows = isLoading && rows.length > 0; @@ -244,17 +264,48 @@ export function VirtualizedContent({ 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 handleVirtualScroll = useCallback(() => { - if (!scrollContainerRef.current || isLoading) return; - const { scrollTop, scrollHeight, clientHeight } = - scrollContainerRef.current; + const el = scrollContainerRef.current; + if (!el) return; + if (stickyGroupHeader) updateStickyGroup(); + if (isLoading) return; + const { scrollTop, scrollHeight, clientHeight } = el; if (scrollHeight - scrollTop - clientHeight < loadMoreOffset) { loadMoreData(); } - }, [isLoading, loadMoreData, loadMoreOffset]); + }, [ + stickyGroupHeader, + isLoading, + loadMoreData, + loadMoreOffset, + updateStickyGroup + ]); const totalHeight = virtualizer.getTotalSize(); + useLayoutEffect(() => { + if (headerRef.current) { + setHeaderHeight(headerRef.current.getBoundingClientRect().height); + } + }, [headerGroups]); + + useLayoutEffect(() => { + if (stickyGroupHeader) updateStickyGroup(); + }, [stickyGroupHeader, updateStickyGroup, groupHeaderList, isGrouped]); + const hasData = rows?.length > 0 || isLoading; const hasChanges = hasActiveQuery(tableQuery || {}, defaultSort); @@ -281,10 +332,26 @@ export function VirtualizedContent({ onScroll={handleVirtualScroll} >
- +
+ +
+ {stickyGroupHeader && isGrouped && stickyGroup && ( +
+ + {stickyGroup.label} + {stickyGroup.showGroupCount ? ( + {stickyGroup.count} + ) : null} + +
+ )}
({ onLoadMore, onRowClick, onColumnVisibilityChange, + stickyGroupHeader = false, getRowId }: React.PropsWithChildren>) { const defaultTableQuery = useMemo( @@ -195,7 +196,8 @@ function DataTableRoot({ defaultSort, loadingRowCount, onRowClick, - shouldShowFilters + shouldShowFilters, + stickyGroupHeader }; }, [ table, @@ -209,7 +211,8 @@ function DataTableRoot({ defaultSort, loadingRowCount, onRowClick, - shouldShowFilters + shouldShowFilters, + stickyGroupHeader ]); return ( diff --git a/packages/raystack/components/data-table/data-table.types.tsx b/packages/raystack/components/data-table/data-table.types.tsx index 49172ea24..b1ea82154 100644 --- a/packages/raystack/components/data-table/data-table.types.tsx +++ b/packages/raystack/components/data-table/data-table.types.tsx @@ -112,6 +112,8 @@ export interface DataTableProps { onLoadMore?: () => Promise; onRowClick?: (row: TData) => void; onColumnVisibilityChange?: (columnVisibility: VisibilityState) => void; + /** When true, group headers stick under the table header while scrolling. Default is false. */ + stickyGroupHeader?: boolean; /** Return a stable unique id for each row (used as React key). Use for sortable/filterable tables. */ getRowId?: (row: TData, index: number) => string; } @@ -158,6 +160,7 @@ export type TableContextType = { updateTableQuery: (fn: TableQueryUpdateFn) => void; onRowClick?: (row: TData) => void; shouldShowFilters?: boolean; + stickyGroupHeader?: boolean; }; export interface ColumnData { diff --git a/packages/raystack/components/sidebar/sidebar-item.tsx b/packages/raystack/components/sidebar/sidebar-item.tsx index a5e4a96eb..85d70bd6a 100644 --- a/packages/raystack/components/sidebar/sidebar-item.tsx +++ b/packages/raystack/components/sidebar/sidebar-item.tsx @@ -53,7 +53,7 @@ export const SidebarItem = forwardRef( align: 'center', gap: 3, className: cx(styles['nav-leading-icon'], classNames?.leadingIcon), - ariaHidden: true + 'aria-hidden': true } as const; const content = cloneElement(