blob: 0e858fe2e0ae89c4155fdad88357d1030d11081f [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 React, {
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 Col = ReactElementWithChildren<'col', null>;
type ColGroup = ReactElementWithChildren<'colgroup', Col>;
export type Table = ReactElementWithChildren<'table', (Thead | Tbody | ColGroup)[]>;
export type TableRenderer = () => Table;
export type GetTableSize = () => Partial<StickyState> | undefined;
export type SetStickyState = (size?: 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,
},
});
/**
* 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 colgroup: ColGroup | undefined;
React.Children.forEach(table.props.children, node => {
if (node.type === 'thead') {
thead = node;
} else if (node.type === 'tbody') {
tbody = node;
} else if (node.type === 'colgroup') {
colgroup = node;
}
});
if (!thead || !tbody) {
throw new Error('<table> in <StickyWrap> must contain both thead and tbody.');
}
const columnCount = useMemo(() => {
const headerRows = React.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 scrollHeaderRef = useRef<HTMLDivElement>(null); // fixed header
const scrollBodyRef = useRef<HTMLDivElement>(null); // main body
const { bodyHeight, columnWidths } = sticky;
const needSizer =
!columnWidths ||
sticky.width !== maxWidth ||
sticky.height !== maxHeight ||
sticky.setStickyState !== setStickyState;
const scrollBarSize = getScrollBarSize();
// update scrollable area and header column sizes when mounted
useLayoutEffect(() => {
if (theadRef.current) {
const bodyThead = theadRef.current;
const theadHeight = bodyThead.clientHeight;
if (!theadHeight) {
return;
}
const fullTableHeight = (bodyThead.parentNode as HTMLTableElement).clientHeight;
const ths = bodyThead.childNodes[0].childNodes as NodeListOf<HTMLTableHeaderCellElement>;
const widths = Array.from(ths).map(th => th.clientWidth);
const [hasVerticalScroll, hasHorizontalScroll] = needScrollBar({
width: maxWidth,
height: maxHeight - theadHeight,
innerHeight: fullTableHeight,
innerWidth: widths.reduce(sum),
scrollBarSize,
});
// real container height, include table header 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,
columnWidths: widths,
});
}
}, [maxWidth, maxHeight, setStickyState, scrollBarSize]);
let sizerTable: ReactElement | undefined;
let headerTable: ReactElement | undefined;
let bodyTable: ReactElement | undefined;
if (needSizer) {
const theadWithRef = React.cloneElement(thead, { ref: theadRef });
sizerTable = (
<div
key="sizer"
style={{
height: maxHeight,
overflow: 'auto',
visibility: 'hidden',
}}
>
{React.cloneElement(table, {}, colgroup, theadWithRef, tbody)}
</div>
);
}
// reuse previously column widths, will be updated by `useLayoutEffect` above
const colWidths = columnWidths?.slice(0, columnCount);
if (colWidths && bodyHeight) {
const tableStyle: CSSProperties = { tableLayout: 'fixed' };
const bodyCols = colWidths.map((w, i) => (
// eslint-disable-next-line react/no-array-index-key
<col key={i} width={w} />
));
const bodyColgroup = <colgroup>{bodyCols}</colgroup>;
// header columns do not have vertical scroll bars,
// so we add scroll bar size to the last column
const headerColgroup =
sticky.hasVerticalScroll && scrollBarSize ? (
<colgroup>
{colWidths.map((x, i) => (
// eslint-disable-next-line react/no-array-index-key
<col key={i} width={x + (i === colWidths.length - 1 ? scrollBarSize : 0)} />
))}
</colgroup>
) : (
bodyColgroup
);
headerTable = (
<div
key="header"
ref={scrollHeaderRef}
style={{
overflow: 'hidden',
}}
>
{React.cloneElement(table, mergeStyleProp(table, tableStyle), headerColgroup, thead)}
{headerTable}
</div>
);
const onScroll: UIEventHandler<HTMLDivElement> = e => {
if (scrollHeaderRef.current) {
scrollHeaderRef.current.scrollLeft = e.currentTarget.scrollLeft;
}
};
bodyTable = (
<div
key="body"
ref={scrollBodyRef}
style={{
height: bodyHeight,
overflow: 'auto',
}}
onScroll={sticky.hasHorizontalScroll ? onScroll : undefined}
>
{React.cloneElement(table, mergeStyleProp(table, tableStyle), bodyColgroup, tbody)}
</div>
);
}
return (
<div
style={{
width: maxWidth,
height: sticky.realHeight || maxHeight,
overflow: 'hidden',
}}
>
{headerTable}
{bodyTable}
{sizerTable}
</div>
);
}
function useInstance<D extends object>(instance: TableInstance<D>) {
const {
dispatch,
state: { sticky },
data,
page,
rows,
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 } = 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]);
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_) => {
const action = action_ as ReducerAction<ReducerActions, { size: StickyState }>;
if (action.type === ReducerActions.init) {
return {
...newState,
sticky: newState.sticky || {},
};
}
if (action.type === ReducerActions.setStickyState) {
const { size } = action;
if (!size) {
return { ...newState };
}
return {
...newState,
sticky: {
...newState.sticky,
...action.size,
},
};
}
return newState;
});
}
useSticky.pluginName = 'useSticky';