Add option to sticky header row in tables (#4134)

This commit is contained in:
Peter White
2026-03-25 14:04:35 +01:00
committed by GitHub
parent da648cabd8
commit 16bfafe32b
10 changed files with 385 additions and 163 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Add option to sticky header row in tables
+2 -2
View File
@@ -349,7 +349,7 @@
"react-dom": "catalog:",
},
"catalog": {
"@gitbook/api": "0.172.0",
"@gitbook/api": "0.173.0",
"@scalar/api-client-react": "^1.3.46",
"@tsconfig/node20": "^20.1.6",
"@tsconfig/strictest": "^2.0.6",
@@ -746,7 +746,7 @@
"@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@7.1.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "7.1.0" } }, "sha512-fNxRUk1KhjSbnbuBxlWSnBLKLBNun52ZBTcs22H/xEEzM6Ap81ZFTQ4bZBxVQGQgVY0xugKGoRcCbaKjLQ3XZA=="],
"@gitbook/api": ["@gitbook/api@0.172.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-EoOhOt4cZpwZKPaQ0E5jb3Ea4Fvmi11at5Y/62cFy5ahyB3355jfKp0HPxe1bGsKfm421RkaM4Ico0vtVZwQ6Q=="],
"@gitbook/api": ["@gitbook/api@0.173.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-AUuRgSi9gCZDZ7GcDQ1o+6CGF70H1RizqydAsW/btKc2FTCYJK5ifbH6VTvsdmgHV38WIk3kTqRujczQ9uEegQ=="],
"@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"],
+1 -1
View File
@@ -41,7 +41,7 @@
"catalog": {
"@tsconfig/strictest": "^2.0.6",
"@tsconfig/node20": "^20.1.6",
"@gitbook/api": "0.172.0",
"@gitbook/api": "0.173.0",
"@scalar/api-client-react": "^1.3.46",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
+1 -1
View File
@@ -233,7 +233,7 @@ const testCases: TestsCase[] = [
name: 'run-ai-docs.nvidia.com',
contentBaseURL: 'https://run-ai-docs.nvidia.com',
tests: [
{ name: 'Home', url: '/' },
// { name: 'Home', url: '/' } Temporarily skipped: this page is unstable in CI during Argos screenshots.
{ name: 'OG Image', url: '/~gitbook/ogimage/h17zQIFwy3MaafVNmItO', mode: 'image' },
],
},
@@ -4,8 +4,7 @@ import { tcls } from '@/lib/tailwind';
import { RecordColumnValue } from './RecordColumnValue';
import type { TableRecordKV, TableViewProps } from './Table';
import { getColumnWidth } from './ViewGrid';
import styles from './table.module.css';
import { getColumnWidth } from './layout';
import { getColumnVerticalAlignment } from './utils';
export function RecordRow(
@@ -18,7 +17,15 @@ export function RecordRow(
const { view, autoSizedColumns, fixedColumns, block, context } = props;
return (
<div className={styles.row} role="row">
<div
className={tcls(
'flex',
'border-tint-subtle',
'transition-colors',
'hover:bg-tint-hover'
)}
role="row"
>
{view.columns.map((column) => {
const columnWidth = getColumnWidth({
column,
@@ -33,7 +40,10 @@ export function RecordRow(
<div
key={column}
role="cell"
className={tcls(styles.cell)}
className={tcls(
'relative flex flex-1 border-r px-3 py-2 align-middle text-sm last:border-r-0',
'border-tint-subtle'
)}
style={{
width: columnWidth,
minWidth: columnWidth || '100px',
@@ -0,0 +1,151 @@
'use client';
import { useScrollListener } from '@/components/hooks/useScrollListener';
import { tcls } from '@/lib/tailwind';
import { type ReactNode, useCallback, useEffect, useLayoutEffect, useRef } from 'react';
interface StickyViewGridProps {
className?: string;
header: ReactNode;
tableClassName?: string;
children: ReactNode;
}
export function StickyViewGrid({
className,
header,
tableClassName,
children,
}: StickyViewGridProps) {
const rootRef = useRef<HTMLDivElement>(null);
const stickyHeaderRef = useRef<HTMLDivElement>(null);
const bodyScrollRef = useRef<HTMLDivElement>(null);
const bodyTableRef = useRef<HTMLDivElement>(null);
const onStickyHeaderWheel = useCallback((event: WheelEvent) => {
const bodyScrollElement = bodyScrollRef.current;
if (!bodyScrollElement) {
return;
}
const horizontalDelta = event.deltaX || (event.shiftKey && event.deltaY ? event.deltaY : 0);
if (horizontalDelta === 0) {
return;
}
bodyScrollElement.scrollLeft += horizontalDelta;
event.preventDefault();
}, []);
useEffect(() => {
const stickyHeaderElement = stickyHeaderRef.current;
if (!stickyHeaderElement) {
return;
}
stickyHeaderElement.addEventListener('wheel', onStickyHeaderWheel, { passive: false });
return () => {
stickyHeaderElement.removeEventListener('wheel', onStickyHeaderWheel);
};
}, [onStickyHeaderWheel]);
const syncStickyLayout = useCallback(() => {
const rootElement = rootRef.current;
const bodyScrollElement = bodyScrollRef.current;
const bodyTableElement = bodyTableRef.current;
if (!rootElement || !bodyScrollElement || !bodyTableElement) {
return;
}
rootElement.style.setProperty(
'--table-sticky-scroll-left',
`${-bodyScrollElement.scrollLeft}px`
);
rootElement.style.setProperty(
'--table-sticky-table-width',
`${bodyTableElement.scrollWidth}px`
);
rootElement.dataset.scrollable = `${
bodyScrollElement.scrollWidth > bodyScrollElement.clientWidth + 1
}`;
}, []);
useLayoutEffect(() => {
syncStickyLayout();
}, [syncStickyLayout]);
useScrollListener(syncStickyLayout, bodyScrollRef);
useEffect(() => {
const bodyScrollElement = bodyScrollRef.current;
const bodyTableElement = bodyTableRef.current;
if (!bodyScrollElement && !bodyTableElement) {
return;
}
const resizeObserver = new ResizeObserver(syncStickyLayout);
if (bodyScrollElement) {
resizeObserver.observe(bodyScrollElement);
}
if (bodyTableElement) {
resizeObserver.observe(bodyTableElement);
}
return () => {
resizeObserver.disconnect();
};
}, [syncStickyLayout]);
return (
<div className={className}>
<div
ref={rootRef}
className={tcls(
'group/table relative flex w-full min-w-0 max-w-full flex-col rounded-lg border-tint-subtle',
'data-[scrollable=true]:border'
)}
data-scrollable="false"
>
<div
ref={stickyHeaderRef}
className={tcls(
'-mx-px sticky z-10 w-full min-w-0 max-w-full overflow-hidden rounded-t-[inherit] px-px',
'[top:var(--toc-top-offset,var(--outline-top-offset,0px))]'
)}
>
<div
className={tcls(
'flex',
'flex-col',
tableClassName ?? 'w-fit',
'[transform:translateX(var(--table-sticky-scroll-left,0px))]'
)}
style={{ width: 'var(--table-sticky-table-width)' }}
>
{header}
</div>
</div>
<div
ref={bodyScrollRef}
className={tcls(
'w-full min-w-0 overflow-x-auto overflow-y-hidden overscroll-x-none border-tint-subtle',
'group-data-[scrollable=true]/table:mx-px',
'group-data-[scrollable=true]/table:border-0',
'group-data-[scrollable=true]/table:rounded-none'
)}
>
<div
ref={bodyTableRef}
className={tcls('flex', 'flex-col', tableClassName ?? 'w-fit')}
>
{children}
</div>
</div>
</div>
</div>
);
}
@@ -1,10 +1,14 @@
import type { DocumentBlockTable, DocumentTableRecord } from '@gitbook/api';
import assertNever from 'assert-never';
import { tcls } from '@/lib/tailwind';
import type { BlockProps } from '../Block';
import { isBlockOffscreen } from '../utils';
import { StickyViewGrid } from './StickyViewGrid';
import { ViewCards } from './ViewCards';
import { ViewGrid } from './ViewGrid';
import { ViewGrid, ViewGridHeader } from './ViewGrid';
import { getViewGridLayout, hasVisibleHeader } from './layout';
export type TableRecordKV = [string, DocumentTableRecord];
@@ -15,7 +19,7 @@ export interface TableViewProps<View> extends BlockProps<DocumentBlockTable> {
}
export function Table(props: BlockProps<DocumentBlockTable>) {
const { block, ancestorBlocks, document } = props;
const { block, ancestorBlocks, document, context, style } = props;
const isOffscreen = isBlockOffscreen({ block, ancestorBlocks, document });
const records: TableRecordKV[] = Object.entries(block.data.records).sort((a, b) => {
@@ -32,15 +36,68 @@ export function Table(props: BlockProps<DocumentBlockTable>) {
{...props}
/>
);
case 'grid':
case 'grid': {
const gridProps = {
...props,
view: block.data.view,
isOffscreen,
records,
};
const { tableWidth } = getViewGridLayout({
block,
view: block.data.view,
mode: context.mode,
});
const tableContainerClassName =
tableWidth === 'w-full' ? 'min-w-full w-fit' : tableWidth;
const withHeader = hasVisibleHeader(block, block.data.view);
const withStickyHeader =
withHeader && context.mode !== 'print' && block.data.view.stickyHeader === true;
if (withStickyHeader) {
return (
<StickyViewGrid
className={tcls(style, 'relative mx-auto grid w-full min-w-0')}
tableClassName={tableContainerClassName}
header={
<ViewGridHeader
{...gridProps}
className={tcls(
'mb-0 rounded-b-none border-t border-r border-l',
'group-data-[scrollable=false]/table:mb-1',
'group-data-[scrollable=false]/table:rounded-b-lg',
'group-data-[scrollable=true]/table:border-t-0',
'group-data-[scrollable=true]/table:border-r-0',
'group-data-[scrollable=true]/table:border-l-0'
)}
/>
}
>
<ViewGrid {...gridProps} />
</StickyViewGrid>
);
}
return (
<ViewGrid
view={block.data.view}
isOffscreen={isOffscreen}
records={records}
{...props}
/>
<div className={tcls(style, 'relative mx-auto grid w-full min-w-0')}>
<div
className={tcls(
'w-full min-w-0 overflow-x-auto overflow-y-hidden overscroll-x-none border-tint-subtle '
)}
>
<div className={tcls('flex', 'flex-col', tableContainerClassName)}>
{withHeader ? (
<ViewGridHeader
{...gridProps}
className={tableContainerClassName}
/>
) : null}
<ViewGrid {...gridProps} tableClassName={tableContainerClassName} />
</div>
</div>
</div>
);
}
default:
assertNever(block.data.view);
}
@@ -4,118 +4,94 @@ import { tcls } from '@/lib/tailwind';
import { RecordRow } from './RecordRow';
import type { TableViewProps } from './Table';
import styles from './table.module.css';
import { getColumnWidth, getViewGridLayout } from './layout';
import { getColumnAlignment } from './utils';
/* Columns are sized in 3 ways:
1. Set to auto-size by default, these columns share the available width
2. Explicitly set by the user by dragging column separator (we then turn off auto-size)
3. Auto-size is turned off without setting a width, we then default to a fixed width of 100px
*/
export function ViewGrid(props: TableViewProps<DocumentTableViewGrid>) {
const { block, view, records, style, context } = props;
interface ViewGridHeaderProps extends TableViewProps<DocumentTableViewGrid> {
className?: string;
}
/* Calculate how many columns are auto-sized vs fixed width */
const columnWidths = context.mode === 'print' ? undefined : view.columnWidths;
const autoSizedColumns = view.columns.filter((column) => !columnWidths?.[column]);
const fixedColumns = view.columns.filter((column) => columnWidths?.[column]);
interface ViewGridProps extends TableViewProps<DocumentTableViewGrid> {
tableClassName?: string;
}
const tableWidth = autoSizedColumns.length > 0 ? 'w-full' : 'w-fit';
/* Only show the header when configured and not empty */
const withHeader =
!view.hideHeader &&
view.columns.some(
(columnId) => (block.data.definition[columnId]?.title.trim().length ?? 0) > 0
);
export function ViewGridHeader(props: ViewGridHeaderProps) {
const { block, view, context, className } = props;
const { tableWidth, columnWidths, autoSizedColumns, fixedColumns } = getViewGridLayout({
block,
view,
mode: context.mode,
});
return (
<div className={tcls(style, styles.tableWrapper)}>
{/* Table */}
<div role="table" className={tcls('flex', 'flex-col')}>
{/* Header */}
{withHeader && (
<div
role="rowgroup"
className={tcls(
tableWidth,
styles.rowGroup,
'straight-corners:rounded-none',
'circular-corners:rounded-xl'
)}
>
<div role="row" className={tcls('flex', 'w-full')}>
{view.columns.map((column) => {
const definition = block.data.definition[column]!;
return (
<div
key={column}
role="columnheader"
className={tcls(
styles.columnHeader,
getColumnAlignment(definition)
)}
style={{
width: getColumnWidth({
column,
columnWidths,
autoSizedColumns,
fixedColumns,
}),
minWidth: columnWidths?.[column] || '100px',
}}
title={definition.title}
>
{definition.title}
</div>
);
})}
<div
role="rowgroup"
className={tcls(
tableWidth,
'mb-1 flex flex-col rounded-lg border border-tint-subtle bg-tint',
className
)}
>
<div role="row" className={tcls('flex', 'w-full')}>
{view.columns.map((column) => {
const definition = block.data.definition[column];
if (!definition) {
return null;
}
return (
<div
key={column}
role="columnheader"
className={tcls(
'px-3 py-2 font-medium text-sm text-tint-strong',
getColumnAlignment(definition)
)}
style={{
width: getColumnWidth({
column,
columnWidths,
autoSizedColumns,
fixedColumns,
}),
minWidth: columnWidths?.[column] || '100px',
}}
title={definition.title}
>
{definition.title}
</div>
</div>
)}
<div
role="rowgroup"
className={tcls('flex', 'flex-col', tableWidth, '[&>*+*]:border-t')}
>
{records.map((record) => (
<RecordRow
key={record[0]}
record={record}
autoSizedColumns={autoSizedColumns}
fixedColumns={fixedColumns}
{...props}
/>
))}
</div>
);
})}
</div>
</div>
);
}
export const getColumnWidth = ({
column,
columnWidths,
autoSizedColumns,
fixedColumns,
}: {
column: string;
columnWidths: Record<string, number> | undefined;
autoSizedColumns: string[];
fixedColumns: string[];
}) => {
const columnWidth = columnWidths?.[column];
export function ViewGrid(props: ViewGridProps) {
const { block, view, records, context, tableClassName } = props;
const { tableWidth, autoSizedColumns, fixedColumns } = getViewGridLayout({
block,
view,
mode: context.mode,
});
/* Column was explicitly set by user or user turned off auto-sizing (in that case, columnWidth should've also been set to 100px) */
if (columnWidth) return `${columnWidth}px`;
const body = (
<div role="rowgroup" className={tcls('flex', 'flex-col', tableWidth, '[&>*+*]:border-t')}>
{records.map((record) => (
<RecordRow
key={record[0]}
record={record}
autoSizedColumns={autoSizedColumns}
fixedColumns={fixedColumns}
{...props}
/>
))}
</div>
);
/* Fallback minimum width for columns, so the columns don't become unreadable from being too narrow and instead table will become scrollable. */
const minAutoColumnWidth = '100px';
const totalFixedWidth = fixedColumns.reduce((sum, col) => {
return sum + (columnWidths?.[col] || 0);
}, 0);
/* Column should use auto-sizing, which means it grows to fill available space */
const availableWidth = `calc((100% - ${totalFixedWidth}px) / ${autoSizedColumns.length})`;
return `clamp(${minAutoColumnWidth}, ${availableWidth}, 100%)`;
};
return (
<div role="table" className={tcls('flex', 'flex-col', tableClassName ?? 'w-fit')}>
{body}
</div>
);
}
@@ -0,0 +1,68 @@
import type { DocumentBlockTable, DocumentTableViewGrid } from '@gitbook/api';
import type { BlockProps } from '../Block';
export function hasVisibleHeader(block: DocumentBlockTable, view: DocumentTableViewGrid): boolean {
return (
!view.hideHeader &&
view.columns.some(
(columnId) => (block.data.definition[columnId]?.title.trim().length ?? 0) > 0
)
);
}
/* Columns are sized in 3 ways:
1. Set to auto-size by default, these columns share the available width
2. Explicitly set by the user by dragging column separator (we then turn off auto-size)
3. Auto-size is turned off without setting a width, we then default to a fixed width of 100px
*/
export function getViewGridLayout({
block,
view,
mode,
}: {
block: DocumentBlockTable;
view: DocumentTableViewGrid;
mode: BlockProps<DocumentBlockTable>['context']['mode'];
}) {
const columnWidths = mode === 'print' ? undefined : view.columnWidths;
const autoSizedColumns = view.columns.filter((column) => !columnWidths?.[column]);
const fixedColumns = view.columns.filter((column) => columnWidths?.[column]);
const tableWidth = autoSizedColumns.length > 0 ? 'w-full' : 'w-fit';
return {
columnWidths,
autoSizedColumns,
fixedColumns,
tableWidth,
withHeader: hasVisibleHeader(block, view),
};
}
export const getColumnWidth = ({
column,
columnWidths,
autoSizedColumns,
fixedColumns,
}: {
column: string;
columnWidths: Record<string, number> | undefined;
autoSizedColumns: string[];
fixedColumns: string[];
}) => {
const columnWidth = columnWidths?.[column];
/* Column was explicitly set by user or user turned off auto-sizing (in that case, columnWidth should've also been set to 100px) */
if (columnWidth) return `${columnWidth}px`;
/* Fallback minimum width for columns, so the columns don't become unreadable from being too narrow and instead table will become scrollable. */
const minAutoColumnWidth = '100px';
const totalFixedWidth = fixedColumns.reduce((sum, col) => {
return sum + (columnWidths?.[col] || 0);
}, 0);
/* Column should use auto-sizing, which means it grows to fill available space */
const availableWidth = `calc((100% - ${totalFixedWidth}px) / ${autoSizedColumns.length})`;
return `clamp(${minAutoColumnWidth}, ${availableWidth}, 100%)`;
};
@@ -1,45 +0,0 @@
@reference "../../RootLayout/globals.css";
/* Detect whether a scrollbar exists on the table */
@keyframes detect-scroll {
from,
to {
--can-scroll: ;
}
}
/* Apply styles to the Table if scrollbar exists */
.tableWrapper {
animation: detect-scroll linear;
animation-timeline: scroll(self x);
--border-radius-if-can-scroll: var(--can-scroll) 0.375rem;
--border-radius-if-cant-scroll: 0;
border-radius: var(--border-radius-if-can-scroll, var(--border-radius-if-cant-scroll));
--border-width-if-can-scroll: var(--can-scroll) 1px;
--border-width-if-cant-scroll: 0;
border-width: var(--border-width-if-can-scroll, var(--border-width-if-cant-scroll));
@apply relative grid w-full overflow-x-auto overflow-y-hidden mx-auto border-tint-subtle;
}
.columnHeader {
@apply text-sm font-medium py-2 px-3 text-tint-strong;
}
.row {
@apply flex border-tint-subtle hover:bg-tint-hover transition-colors;
}
.rowGroup {
@apply flex flex-col border rounded-lg bg-tint border-tint-subtle mb-1;
}
.cell {
@apply flex-1 flex align-middle border-tint-subtle py-2 px-3 text-sm relative;
}
.cell:not(:last-child) {
@apply border-r;
}