Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
562 changes: 562 additions & 0 deletions apps/www/src/app/examples/datatable/page.tsx

Large diffs are not rendered by default.

16 changes: 16 additions & 0 deletions apps/www/src/content/docs/components/datatable/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,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
<DataTable
data={data}
columns={columns}
defaultSort={{ name: "name", order: "asc" }}
stickyGroupHeader={true}
>
<DataTable.Toolbar />
<DataTable.Content />
</DataTable>
```

### Server-side Integration

```tsx
Expand Down
7 changes: 7 additions & 0 deletions apps/www/src/content/docs/components/datatable/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ export interface DataTableProps {
onColumnVisibilityChange?: (
columnVisibility: Record<string, boolean>
) => void;

/**
* When true (default), 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
*/
Comment thread
paanSinghCoder marked this conversation as resolved.
stickyGroupHeader?: boolean;
}

export interface DataTableQuery {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -575,4 +575,61 @@ describe('DataTable', () => {
expect(screen.queryByTestId('zero-state')).not.toBeInTheDocument();
});
});

describe('stickyGroupHeader (anchor group title)', () => {
const columnsWithGrouping: DataTableColumnDef<TestData, unknown>[] = [
{
id: 'name',
accessorKey: 'name',
header: 'Name',
cell: ({ getValue }) => getValue(),
enableGrouping: true
},
{
id: 'status',
accessorKey: 'status',
header: 'Status',
cell: ({ getValue }) => getValue(),
enableGrouping: true
}
];

it('applies sticky class to group section header when stickyGroupHeader is true', () => {
const { container } = render(
<DataTable
data={mockData}
columns={columnsWithGrouping}
defaultSort={{ name: 'name', order: 'asc' }}
query={{ group_by: ['status'] }}
stickyGroupHeader
>
<DataTable.Content />
</DataTable>
);

const sectionHeaderCell = container.querySelector(
`th.${styles.stickySectionHeader}`
);
expect(sectionHeaderCell).toBeInTheDocument();
});

it('does not apply sticky class when stickyGroupHeader is false (default)', () => {
const { container } = render(
<DataTable
data={mockData}
columns={columnsWithGrouping}
defaultSort={{ name: 'name', order: 'asc' }}
query={{ group_by: ['status'] }}
stickyGroupHeader={false}
>
<DataTable.Content />
</DataTable>
);

const sectionHeaderCell = container.querySelector(
`th.${styles.stickySectionHeader}`
);
expect(sectionHeaderCell).not.toBeInTheDocument();
});
});
});
20 changes: 16 additions & 4 deletions packages/raystack/components/data-table/components/content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ interface RowsProps<TData> {
row?: string;
};
lastRowRef?: React.RefObject<HTMLTableRowElement | null>;
stickyGroupHeader?: boolean;
}

function LoaderRows({
Expand All @@ -86,13 +87,20 @@ function LoaderRows({

function GroupHeader<TData>({
colSpan,
data
data,
stickySectionHeader
}: {
colSpan: number;
data: GroupedData<TData>;
stickySectionHeader?: boolean;
}) {
return (
<Table.SectionHeader colSpan={colSpan}>
<Table.SectionHeader
colSpan={colSpan}
classNames={
stickySectionHeader ? { cell: styles.stickySectionHeader } : undefined
}
>
<Flex gap={3} align='center'>
{data?.label}
{data.showGroupCount ? (
Expand All @@ -107,7 +115,8 @@ function Rows<TData>({
rows = [],
onRowClick,
classNames,
lastRowRef
lastRowRef,
stickyGroupHeader = false
}: RowsProps<TData>) {
return rows.map((row, idx) => {
const isSelected = row.getIsSelected();
Expand All @@ -121,6 +130,7 @@ function Rows<TData>({
key={row.id}
colSpan={cells.length}
data={row.original as GroupedData<unknown>}
stickySectionHeader={stickyGroupHeader}
/>
);
}
Expand Down Expand Up @@ -174,7 +184,8 @@ export function Content({
loadMoreData,
loadingRowCount = 3,
tableQuery,
defaultSort
defaultSort,
stickyGroupHeader = false
} = useDataTable();

const headerGroups = table?.getHeaderGroups();
Expand Down Expand Up @@ -248,6 +259,7 @@ export function Content({
classNames={{
row: classNames.row
}}
stickyGroupHeader={stickyGroupHeader}
/>
{isLoading ? (
<LoaderRows
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { HeaderGroup, Row } from '@tanstack/react-table';
import { flexRender } from '@tanstack/react-table';
import { useVirtualizer } from '@tanstack/react-virtual';
import { cx } from 'class-variance-authority';
import { useCallback, useRef } from 'react';
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
import tableStyles from '~/components/table/table.module.css';
import { Badge } from '../../badge';
import { EmptyState } from '../../empty-state';
Expand All @@ -14,6 +14,7 @@ import { Skeleton } from '../../skeleton';
import styles from '../data-table.module.css';
import {
DataTableColumnDef,
defaultGroupOption,
GroupedData,
VirtualizedContentProps
} from '../data-table.types';
Expand Down Expand Up @@ -222,14 +223,33 @@ export function VirtualizedContent({
loadMoreData,
tableQuery,
defaultSort,
loadingRowCount = 3
loadingRowCount = 3,
stickyGroupHeader = false
} = useDataTable();

const headerGroups = table?.getHeaderGroups();
const rowModel = table?.getRowModel();
const { rows = [] } = rowModel || {};

const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const headerRef = useRef<HTMLDivElement | null>(null);
const [stickyGroup, setStickyGroup] = useState<GroupedData<unknown> | 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<unknown> }[] = [];
rows.forEach((row, i) => {
if (row.subRows && row.subRows.length > 0) {
list.push({ index: i, data: row.original as GroupedData<unknown> });
}
});
return list;
}, [rows]);

const showLoaderRows = isLoading && rows.length > 0;

Expand All @@ -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);
Expand All @@ -281,10 +332,26 @@ export function VirtualizedContent({
onScroll={handleVirtualScroll}
>
<div role='table' className={cx(styles.virtualTable, classNames.table)}>
<VirtualHeaders
headerGroups={headerGroups}
className={cx(styles.stickyHeader, classNames.header)}
/>
<div ref={headerRef}>
<VirtualHeaders
headerGroups={headerGroups}
className={cx(styles.stickyHeader, classNames.header)}
/>
</div>
{stickyGroupHeader && isGrouped && stickyGroup && (
<div
role='row'
className={styles.stickyGroupAnchor}
style={{ top: headerHeight }}
>
<Flex gap={3} align='center'>
{stickyGroup.label}
{stickyGroup.showGroupCount ? (
<Badge variant='neutral'>{stickyGroup.count}</Badge>
) : null}
</Flex>
</div>
)}
<div
role='rowgroup'
className={cx(styles.virtualBodyGroup, classNames.body)}
Expand Down
28 changes: 25 additions & 3 deletions packages/raystack/components/data-table/data-table.module.css
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -21,12 +20,13 @@
.display-popover-properties-select {
width: var(--select-width);
}

.display-popover-properties-select[with-icon-button] {
/* Reduce Icon button with "--rs-space-7" and flex gap "--rs-space-2" */
width: calc(var(--select-width) - var(--rs-space-7) - var(--rs-space-2));
}

.display-popover-properties-select > span {
.display-popover-properties-select>span {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
Expand Down Expand Up @@ -145,6 +145,19 @@
padding: var(--rs-space-3) var(--rs-space-4);
}

/* Sticky group anchor: shows current group label while scrolling (virtualized) */
.stickyGroupAnchor {
position: sticky;
z-index: 1;
display: flex;
align-items: center;
background: var(--rs-color-background-base-secondary);
font-weight: 500;
padding: var(--rs-space-3);
border-bottom: 0.5px solid var(--rs-color-border-base-primary);
box-shadow: 0 1px 0 0 var(--rs-color-border-base-primary);
}

.stickyLoaderContainer {
position: sticky;
bottom: 0;
Expand All @@ -155,3 +168,12 @@
.loaderRow {
position: relative;
}

/* Non-virtualized: sticky section header under table header */
.stickySectionHeader {
position: sticky;
top: var(--rs-space-10);
z-index: 1;
background: var(--rs-color-background-base-secondary);
box-shadow: 0 1px 0 0 var(--rs-color-border-base-primary);
}
9 changes: 6 additions & 3 deletions packages/raystack/components/data-table/data-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ function DataTableRoot<TData, TValue>({
onTableQueryChange,
onLoadMore,
onRowClick,
onColumnVisibilityChange
onColumnVisibilityChange,
stickyGroupHeader = false
}: React.PropsWithChildren<DataTableProps<TData, TValue>>) {
const defaultTableQuery = useMemo(
() => getDefaultTableQuery(defaultSort, query),
Expand Down Expand Up @@ -193,7 +194,8 @@ function DataTableRoot<TData, TValue>({
defaultSort,
loadingRowCount,
onRowClick,
shouldShowFilters
shouldShowFilters,
stickyGroupHeader
};
}, [
table,
Expand All @@ -207,7 +209,8 @@ function DataTableRoot<TData, TValue>({
defaultSort,
loadingRowCount,
onRowClick,
shouldShowFilters
shouldShowFilters,
stickyGroupHeader
]);

return (
Expand Down
Loading