blob: 46e556ef82f875aba179e839b39d331d378d5999 [file] [log] [blame]
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
Children,
cloneElement,
useRef,
useMemo,
useLayoutEffect,
useCallback,
ReactNode,
ReactElement,
ComponentPropsWithRef,
CSSProperties,
UIEventHandler,
} from 'react';
import { TableInstance, Hooks } from 'react-table';
import getScrollBarSize from '../utils/getScrollBarSize';
import needScrollBar from '../utils/needScrollBar';
import useMountedMemo from '../utils/useMountedMemo';
type ReactElementWithChildren<
T extends keyof JSX.IntrinsicElements,
C extends ReactNode = ReactNode,
> = ReactElement<ComponentPropsWithRef<T> & { children: C }, T>;
type Th = ReactElementWithChildren<'th'>;
type Td = ReactElementWithChildren<'td'>;
type TrWithTh = ReactElementWithChildren<'tr', Th[]>;
type TrWithTd = ReactElementWithChildren<'tr', Td[]>;
type Thead = ReactElementWithChildren<'thead', TrWithTh>;
type Tbody = ReactElementWithChildren<'tbody', TrWithTd>;
type Tfoot = ReactElementWithChildren<'tfoot', TrWithTd>;
type Col = ReactElementWithChildren<'col', null>;
type ColGroup = ReactElementWithChildren<'colgroup', Col>;
export type Table = ReactElementWithChildren<
'table',
(Thead | Tbody | Tfoot | ColGroup)[]
>;
export type TableRenderer = () => Table;
export type GetTableSize = () => Partial<StickyState> | undefined;
export type SetStickyState = (size?: Partial<StickyState>) => void;
export enum ReducerActions {
Init = 'init', // this is from global reducer
SetStickyState = 'setStickyState',
}
export type ReducerAction<
T extends string,
P extends Record<string, unknown>,
> = P & { type: T };
export type ColumnWidths = number[];
export interface StickyState {
width?: number; // maximum full table width
height?: number; // maximum full table height
realHeight?: number; // actual table viewport height (header + scrollable area)
bodyHeight?: number; // scrollable area height
tableHeight?: number; // the full table height
columnWidths?: ColumnWidths;
hasHorizontalScroll?: boolean;
hasVerticalScroll?: boolean;
rendering?: boolean;
setStickyState?: SetStickyState;
}
export interface UseStickyTableOptions {
getTableSize?: GetTableSize;
}
export interface UseStickyInstanceProps {
// manipulate DOMs in <table> to make the header sticky
wrapStickyTable: (renderer: TableRenderer) => ReactNode;
// update or recompute the sticky table size
setStickyState: SetStickyState;
}
export type UseStickyState = {
sticky: StickyState;
};
const sum = (a: number, b: number) => a + b;
const mergeStyleProp = (
node: ReactElement<{ style?: CSSProperties }>,
style: CSSProperties,
) => ({
style: {
...node.props.style,
...style,
},
});
const fixedTableLayout: CSSProperties = { tableLayout: 'fixed' };
/**
* An HOC for generating sticky header and fixed-height scrollable area
*/
function StickyWrap({
sticky = {},
width: maxWidth,
height: maxHeight,
children: table,
setStickyState,
}: {
width: number;
height: number;
setStickyState: SetStickyState;
children: Table;
sticky?: StickyState; // current sticky element sizes
}) {
if (!table || table.type !== 'table') {
throw new Error('<StickyWrap> must have only one <table> element as child');
}
let thead: Thead | undefined;
let tbody: Tbody | undefined;
let tfoot: Tfoot | undefined;
Children.forEach(table.props.children, node => {
if (!node) {
return;
}
if (node.type === 'thead') {
thead = node;
} else if (node.type === 'tbody') {
tbody = node;
} else if (node.type === 'tfoot') {
tfoot = node;
}
});
if (!thead || !tbody) {
throw new Error(
'<table> in <StickyWrap> must contain both thead and tbody.',
);
}
const columnCount = useMemo(() => {
const headerRows = Children.toArray(
thead?.props.children,
).pop() as TrWithTh;
return headerRows.props.children.length;
}, [thead]);
const theadRef = useRef<HTMLTableSectionElement>(null); // original thead for layout computation
const tfootRef = useRef<HTMLTableSectionElement>(null); // original tfoot for layout computation
const scrollHeaderRef = useRef<HTMLDivElement>(null); // fixed header
const scrollFooterRef = useRef<HTMLDivElement>(null); // fixed footer
const scrollBodyRef = useRef<HTMLDivElement>(null); // main body
const scrollBarSize = getScrollBarSize();
const { bodyHeight, columnWidths } = sticky;
const needSizer =
!columnWidths ||
sticky.width !== maxWidth ||
sticky.height !== maxHeight ||
sticky.setStickyState !== setStickyState;
// update scrollable area and header column sizes when mounted
useLayoutEffect(() => {
if (!theadRef.current) {
return;
}
const bodyThead = theadRef.current;
const theadHeight = bodyThead.clientHeight;
const tfootHeight = tfootRef.current ? tfootRef.current.clientHeight : 0;
if (!theadHeight) {
return;
}
const fullTableHeight = (bodyThead.parentNode as HTMLTableElement)
.clientHeight;
// instead of always using the first tr, we use the last one to support
// multi-level headers assuming the last one is the more detailed one
const ths = bodyThead.childNodes?.[bodyThead.childNodes?.length - 1 || 0]
.childNodes as NodeListOf<HTMLTableHeaderCellElement>;
const widths = Array.from(ths).map(
th => th.getBoundingClientRect()?.width || th.clientWidth,
);
const [hasVerticalScroll, hasHorizontalScroll] = needScrollBar({
width: maxWidth,
height: maxHeight - theadHeight - tfootHeight,
innerHeight: fullTableHeight,
innerWidth: widths.reduce(sum),
scrollBarSize,
});
// real container height, include table header, footer and space for
// horizontal scroll bar
const realHeight = Math.min(
maxHeight,
hasHorizontalScroll ? fullTableHeight + scrollBarSize : fullTableHeight,
);
setStickyState({
hasVerticalScroll,
hasHorizontalScroll,
setStickyState,
width: maxWidth,
height: maxHeight,
realHeight,
tableHeight: fullTableHeight,
bodyHeight: realHeight - theadHeight - tfootHeight,
columnWidths: widths,
});
}, [maxWidth, maxHeight, setStickyState, scrollBarSize]);
let sizerTable: ReactElement | undefined;
let headerTable: ReactElement | undefined;
let footerTable: ReactElement | undefined;
let bodyTable: ReactElement | undefined;
if (needSizer) {
const theadWithRef = cloneElement(thead, { ref: theadRef });
const tfootWithRef = tfoot && cloneElement(tfoot, { ref: tfootRef });
sizerTable = (
<div
key="sizer"
style={{
height: maxHeight,
overflow: 'auto',
visibility: 'hidden',
scrollbarGutter: 'stable',
}}
role="presentation"
>
{cloneElement(
table,
{ role: 'presentation' },
theadWithRef,
tbody,
tfootWithRef,
)}
</div>
);
}
// reuse previously column widths, will be updated by `useLayoutEffect` above
const colWidths = columnWidths?.slice(0, columnCount);
if (colWidths && bodyHeight) {
const colgroup = (
<colgroup>
{colWidths.map((w, i) => (
// eslint-disable-next-line react/no-array-index-key
<col key={i} width={w} />
))}
</colgroup>
);
headerTable = (
<div
key="header"
ref={scrollHeaderRef}
style={{
overflow: 'hidden',
scrollbarGutter: 'stable',
}}
role="presentation"
>
{cloneElement(
cloneElement(table, { role: 'presentation' }),
mergeStyleProp(table, fixedTableLayout),
colgroup,
thead,
)}
{headerTable}
</div>
);
footerTable = tfoot && (
<div
key="footer"
ref={scrollFooterRef}
style={{
overflow: 'hidden',
scrollbarGutter: 'stable',
}}
role="presentation"
>
{cloneElement(
cloneElement(table, { role: 'presentation' }),
mergeStyleProp(table, fixedTableLayout),
colgroup,
tfoot,
)}
{footerTable}
</div>
);
const onScroll: UIEventHandler<HTMLDivElement> = e => {
if (scrollHeaderRef.current) {
scrollHeaderRef.current.scrollLeft = e.currentTarget.scrollLeft;
}
if (scrollFooterRef.current) {
scrollFooterRef.current.scrollLeft = e.currentTarget.scrollLeft;
}
};
bodyTable = (
<div
key="body"
ref={scrollBodyRef}
style={{
height: bodyHeight,
overflow: 'auto',
scrollbarGutter: 'stable',
}}
onScroll={sticky.hasHorizontalScroll ? onScroll : undefined}
role="presentation"
>
{cloneElement(
cloneElement(table, { role: 'presentation' }),
mergeStyleProp(table, fixedTableLayout),
colgroup,
tbody,
)}
</div>
);
}
return (
<div
style={{
width: maxWidth,
height: sticky.realHeight || maxHeight,
overflow: 'hidden',
}}
role="table"
>
{headerTable}
{bodyTable}
{footerTable}
{sizerTable}
</div>
);
}
function useInstance<D extends object>(instance: TableInstance<D>) {
const {
dispatch,
state: { sticky },
data,
page,
rows,
allColumns,
getTableSize = () => undefined,
} = instance;
const setStickyState = useCallback(
(size?: Partial<StickyState>) => {
dispatch({
type: ReducerActions.SetStickyState,
size,
});
},
// turning pages would also trigger a resize
// eslint-disable-next-line react-hooks/exhaustive-deps
[dispatch, getTableSize, page, rows],
);
const useStickyWrap = (renderer: TableRenderer) => {
const { width, height }: { width?: number; height?: number } =
useMountedMemo(getTableSize, [getTableSize]) || sticky;
// only change of data should trigger re-render
// eslint-disable-next-line react-hooks/exhaustive-deps
const table = useMemo(renderer, [page, rows, allColumns]);
useLayoutEffect(() => {
if (!width || !height) {
setStickyState();
}
}, [width, height]);
if (!width || !height) {
return null;
}
if (data.length === 0) {
return table;
}
return (
<StickyWrap
width={width}
height={height}
sticky={sticky}
setStickyState={setStickyState}
>
{table}
</StickyWrap>
);
};
Object.assign(instance, {
setStickyState,
wrapStickyTable: useStickyWrap,
});
}
export default function useSticky<D extends object>(hooks: Hooks<D>) {
hooks.useInstance.push(useInstance);
hooks.stateReducers.push((newState, action_, prevState) => {
const action = action_ as ReducerAction<
ReducerActions,
{ size: StickyState }
>;
if (action.type === ReducerActions.Init) {
return {
...newState,
sticky: {
...prevState?.sticky,
},
};
}
if (action.type === ReducerActions.SetStickyState) {
const { size } = action;
if (!size) {
return { ...newState };
}
return {
...newState,
sticky: {
...prevState?.sticky,
...newState?.sticky,
...action.size,
},
};
}
return newState;
});
}
useSticky.pluginName = 'useSticky';