Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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 @@ -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
<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 @@ -36,6 +36,13 @@ export interface DataTableProps {
columnVisibility: Record<string, boolean>
) => 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.
Expand Down
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 @@ -251,6 +262,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
22 changes: 22 additions & 0 deletions packages/raystack/components/data-table/data-table.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,19 @@
padding: var(--rs-space-3);
}

/* 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 @@ -154,4 +167,13 @@

.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);
}
7 changes: 5 additions & 2 deletions packages/raystack/components/data-table/data-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ function DataTableRoot<TData, TValue>({
onLoadMore,
onRowClick,
onColumnVisibilityChange,
stickyGroupHeader = false,
getRowId
}: React.PropsWithChildren<DataTableProps<TData, TValue>>) {
const defaultTableQuery = useMemo(
Expand Down Expand Up @@ -195,7 +196,8 @@ function DataTableRoot<TData, TValue>({
defaultSort,
loadingRowCount,
onRowClick,
shouldShowFilters
shouldShowFilters,
stickyGroupHeader
};
}, [
table,
Expand All @@ -209,7 +211,8 @@ function DataTableRoot<TData, TValue>({
defaultSort,
loadingRowCount,
onRowClick,
shouldShowFilters
shouldShowFilters,
stickyGroupHeader
]);

return (
Expand Down
3 changes: 3 additions & 0 deletions packages/raystack/components/data-table/data-table.types.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ export interface DataTableProps<TData, TValue> {
onLoadMore?: () => Promise<void>;
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;
}
Expand Down Expand Up @@ -158,6 +160,7 @@ export type TableContextType<TData, TValue> = {
updateTableQuery: (fn: TableQueryUpdateFn) => void;
onRowClick?: (row: TData) => void;
shouldShowFilters?: boolean;
stickyGroupHeader?: boolean;
};

export interface ColumnData {
Expand Down
2 changes: 1 addition & 1 deletion packages/raystack/components/sidebar/sidebar-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export const SidebarItem = forwardRef<HTMLAnchorElement, SidebarItemProps>(
align: 'center',
gap: 3,
className: cx(styles['nav-leading-icon'], classNames?.leadingIcon),
ariaHidden: true
'aria-hidden': true
} as const;

const content = cloneElement(
Expand Down
Loading