diff --git a/.prettierrc b/.prettierrc
index 895b8bdc7..77290ab30 100644
--- a/.prettierrc
+++ b/.prettierrc
@@ -2,5 +2,6 @@
"singleQuote": true,
"trailingComma": "all",
"proseWrap": "never",
- "printWidth": 100
+ "printWidth": 100,
+ "arrowParens": "avoid"
}
diff --git a/assets/index.less b/assets/index.less
index 510ec4f1a..34f4b5f91 100644
--- a/assets/index.less
+++ b/assets/index.less
@@ -287,4 +287,31 @@
background: #fff;
}
}
+ &-sticky {
+ &-header {
+ position: sticky;
+ z-index: 10;
+ }
+ &-scroll {
+ position: fixed;
+ bottom: 0;
+ display: flex;
+ align-items: center;
+ border-top: 1px solid #f3f3f3;
+ opacity: 0.6;
+ transition: transform 0.1s ease-in 0s;
+ &:hover {
+ transform: scaleY(1.2);
+ transform-origin: center bottom;
+ }
+ &-bar {
+ height: 8px;
+ border-radius: 4px;
+ background-color: #bbb;
+ &:hover {
+ background-color: #999;
+ }
+ }
+ }
+ }
}
diff --git a/examples/stickyHeader.tsx b/examples/stickyHeader.tsx
new file mode 100644
index 000000000..5e93c7a56
--- /dev/null
+++ b/examples/stickyHeader.tsx
@@ -0,0 +1,114 @@
+/* eslint-disable no-console,func-names,react/no-multi-comp */
+import React from 'react';
+import Table from '../src';
+import '../assets/index.less';
+import { ColumnType } from '../src/interface';
+
+interface RecordType {
+ a?: string;
+ b?: string;
+ c?: string;
+}
+
+const columns: ColumnType<{ a: string; b: string; c: string }>[] = [
+ { title: 'title1', dataIndex: 'a', key: 'a', width: 100 },
+ { title: 'title2', dataIndex: 'b', key: 'b', width: 100, align: 'right' },
+ { title: 'title3', dataIndex: 'c', key: 'c', width: 200 },
+ {
+ title: 'Operations',
+ dataIndex: '',
+ key: 'd',
+ render(_, record) {
+ return (
+ {
+ e.preventDefault();
+ console.log('Operate on:', record);
+ }}
+ href="#"
+ >
+ Operations
+
+ );
+ },
+ },
+];
+
+const data = [
+ { a: '123', key: '1' },
+ { a: 'cdd', b: 'edd', key: '2' },
+ { a: '1333', c: 'eee', d: 2, key: '3' },
+ { a: '1333', c: 'eee', d: 2, key: '4' },
+ { a: '1333', c: 'eee', d: 2, key: '5' },
+ { a: '1333', c: 'eee', d: 2, key: '6' },
+ { a: '1333', c: 'eee', d: 2, key: '7' },
+ { a: '1333', c: 'eee', d: 2, key: '8' },
+ { a: '1333', c: 'eee', d: 2, key: '9' },
+ { a: '1333', c: 'eee', d: 2, key: '10' },
+ { a: '1333', c: 'eee', d: 2, key: '11' },
+ { a: '1333', c: 'eee', d: 2, key: '12' },
+ { a: '1333', c: 'eee', d: 2, key: '13' },
+ { a: '1333', c: 'eee', d: 2, key: '14' },
+ { a: '1333', c: 'eee', d: 2, key: '15' },
+ { a: '1333', c: 'eee', d: 2, key: '16' },
+ { a: '1333', c: 'eee', d: 2, key: '17' },
+ { a: '1333', c: 'eee', d: 2, key: '18' },
+ { a: '1333', c: 'eee', d: 2, key: '19' },
+ { a: '1333', c: 'eee', d: 2, key: '20' },
+];
+
+const Demo = () => (
+
+
Sticky
+
+ columns={columns}
+ data={data}
+ tableLayout="auto"
+ sticky
+ scroll={{
+ x: 10000,
+ }}
+ style={{
+ marginBottom: 100,
+ }}
+ />
+
+ Show offset Header
+
+ columns={columns}
+ data={data}
+ tableLayout="auto"
+ sticky={{
+ offsetHeader: 100,
+ }}
+ scroll={{
+ x: 10000,
+ }}
+ style={{
+ marginBottom: 100,
+ }}
+ />
+
+ Show offset scroll
+
+ columns={columns}
+ data={data}
+ tableLayout="auto"
+ sticky={{
+ offsetScroll: 100,
+ }}
+ scroll={{
+ x: 10000,
+ }}
+ style={{
+ marginBottom: 100,
+ }}
+ />
+
+);
+
+export default Demo;
diff --git a/package.json b/package.json
index ec5681d6a..fc7c06e95 100644
--- a/package.json
+++ b/package.json
@@ -55,7 +55,7 @@
"classnames": "^2.2.5",
"raf": "^3.4.1",
"rc-resize-observer": "^0.2.0",
- "rc-util": "^5.0.0",
+ "rc-util": "^5.0.4",
"shallowequal": "^1.1.0"
},
"devDependencies": {
diff --git a/src/Header/FixedHeader.tsx b/src/Header/FixedHeader.tsx
index 93533f859..f118870d6 100644
--- a/src/Header/FixedHeader.tsx
+++ b/src/Header/FixedHeader.tsx
@@ -24,6 +24,7 @@ export interface FixedHeaderProps extends HeaderProps {
colWidths: number[];
columCount: number;
direction: 'ltr' | 'rtl';
+ fixHeader: boolean;
}
function FixedHeader({
@@ -33,6 +34,7 @@ function FixedHeader({
columCount,
stickyOffsets,
direction,
+ fixHeader,
...props
}: FixedHeaderProps) {
const { prefixCls, scrollbarSize } = React.useContext(TableContext);
@@ -47,8 +49,8 @@ function FixedHeader({
};
const columnsWithScrollbar = useMemo>(
- () => (scrollbarSize ? [...columns, ScrollBarColumn] : columns),
- [scrollbarSize, columns],
+ () => (scrollbarSize && fixHeader ? [...columns, ScrollBarColumn] : columns),
+ [scrollbarSize, columns, fixHeader],
);
const flattenColumnsWithScrollbar = useMemo[]>(
diff --git a/src/Table.tsx b/src/Table.tsx
index d9c005dac..f6f851da5 100644
--- a/src/Table.tsx
+++ b/src/Table.tsx
@@ -52,6 +52,7 @@ import {
CustomizeComponent,
ColumnType,
CustomizeScrollBody,
+ TableSticky,
} from './interface';
import TableContext from './context/TableContext';
import BodyContext from './context/BodyContext';
@@ -67,6 +68,8 @@ import Panel from './Panel';
import Footer, { FooterComponents } from './Footer';
import { findAllChildrenKeys, renderExpandIcon } from './utils/expandUtil';
import { getCellFixedInfo } from './utils/fixUtil';
+import StickyScrollBar from './stickyScrollBar';
+import useSticky from './hooks/useSticky';
// Used for conditions cache
const EMPTY_DATA = [];
@@ -155,6 +158,8 @@ export interface TableProps extends LegacyExpandableProps<
internalRefs?: {
body: React.MutableRefObject;
};
+
+ sticky?: boolean | TableSticky;
}
function Table(props: TableProps) {
@@ -186,6 +191,8 @@ function Table(props: TableProps(props: TableProps fixed);
+ // Sticky
+ const stickyRef = React.useRef<{ setScrollLeft: (left: number) => void }>();
+ const { isSticky, offsetHeader, offsetScroll, stickyClassName } = useSticky(sticky, prefixCls);
+
let scrollXStyle: React.CSSProperties;
let scrollYStyle: React.CSSProperties;
let scrollTableStyle: React.CSSProperties;
@@ -412,11 +423,16 @@ function Table(props: TableProps void)) {
/* eslint-disable no-param-reassign */
- if (target && target.scrollLeft !== scrollLeft) {
- target.scrollLeft = scrollLeft;
+ if (target) {
+ if (typeof target === 'function') {
+ target(scrollLeft);
+ } else if (target.scrollLeft !== scrollLeft) {
+ target.scrollLeft = scrollLeft;
+ }
}
+
/* eslint-enable */
}
@@ -432,6 +448,7 @@ function Table(props: TableProps(props: TableProps(props: TableProps(props: TableProps(props: TableProps
+
+ {isSticky && (
+
+ )}
);
}
@@ -592,10 +619,13 @@ function Table(props: TableProps
diff --git a/src/hooks/useSticky.ts b/src/hooks/useSticky.ts
new file mode 100644
index 000000000..595decc74
--- /dev/null
+++ b/src/hooks/useSticky.ts
@@ -0,0 +1,22 @@
+import * as React from 'react';
+import { TableSticky } from '../interface';
+
+export default function useSticky(
+ sticky: boolean | TableSticky,
+ prefixCls: string,
+): {
+ isSticky: boolean;
+ offsetHeader: number;
+ offsetScroll: number;
+ stickyClassName: string;
+} {
+ return React.useMemo(() => {
+ const isSticky = !!sticky;
+ return {
+ isSticky,
+ stickyClassName: isSticky ? `${prefixCls}-sticky-header` : '',
+ offsetHeader: typeof sticky === 'object' ? sticky.offsetHeader || 0 : 0,
+ offsetScroll: typeof sticky === 'object' ? sticky.offsetScroll || 0 : 0,
+ };
+ }, [sticky, prefixCls]);
+}
diff --git a/src/interface.ts b/src/interface.ts
index dfeaeb8b8..019f746e2 100644
--- a/src/interface.ts
+++ b/src/interface.ts
@@ -91,7 +91,8 @@ export interface ColumnType extends ColumnSharedType {
export type ColumnsType = (
| ColumnGroupType
- | ColumnType)[];
+ | ColumnType
+)[];
export type GetRowKey = (record: RecordType, index?: number) => Key;
@@ -206,3 +207,9 @@ export type TriggerEventHandler = (
record: RecordType,
event: React.MouseEvent,
) => void;
+
+// =================== Sticky ===================
+export interface TableSticky {
+ offsetHeader?: number;
+ offsetScroll?: number;
+}
diff --git a/src/stickyScrollBar.tsx b/src/stickyScrollBar.tsx
new file mode 100644
index 000000000..da33ea66e
--- /dev/null
+++ b/src/stickyScrollBar.tsx
@@ -0,0 +1,168 @@
+import * as React from 'react';
+import addEventListener from 'rc-util/lib/Dom/addEventListener';
+import getScrollBarSize from 'rc-util/lib/getScrollBarSize';
+import { getOffset } from 'rc-util/lib/Dom/css';
+import TableContext from './context/TableContext';
+import { useFrameState } from './hooks/useFrame';
+
+interface StickyScrollBarProps {
+ scrollBodyRef: React.RefObject;
+ onScroll: (params: { scrollLeft?: number }) => void;
+ offsetScroll: number;
+}
+
+const StickyScrollBar: React.ForwardRefRenderFunction = (
+ { scrollBodyRef, onScroll, offsetScroll },
+ ref,
+) => {
+ const { prefixCls } = React.useContext(TableContext);
+ const bodyScrollWidth = scrollBodyRef.current?.scrollWidth || 0;
+ const bodyWidth = scrollBodyRef.current?.offsetWidth || 0;
+ const scrollBarWidth = bodyScrollWidth && bodyWidth * (bodyWidth / bodyScrollWidth);
+
+ const scrollBarRef = React.useRef();
+ const [frameState, setFrameState] = useFrameState<{
+ scrollLeft: number;
+ isHiddenScrollBar: boolean;
+ }>({
+ scrollLeft: 0,
+ isHiddenScrollBar: false,
+ });
+ const refState = React.useRef<{
+ isScollBarDragable: boolean;
+ delta: number;
+ x: number;
+ }>({
+ isScollBarDragable: false,
+ delta: 0,
+ x: 0,
+ });
+
+ const onMouseUp: React.MouseEventHandler = event => {
+ refState.current.isScollBarDragable = false;
+ event.preventDefault();
+ };
+
+ const onMouseDown: React.MouseEventHandler = event => {
+ event.persist();
+ refState.current.isScollBarDragable = true;
+ refState.current.delta = event.pageX - frameState.scrollLeft;
+ refState.current.x = 0;
+ event.preventDefault();
+ };
+
+ const onMouseMove: React.MouseEventHandler = event => {
+ event.preventDefault();
+ // https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/buttons
+ const { buttons } = event || (window?.event as any);
+ if (!refState.current.isScollBarDragable || buttons === 0) {
+ return;
+ }
+ let left: number =
+ refState.current.x + event.pageX - refState.current.x - refState.current.delta;
+
+ if (left <= 0) {
+ left = 0;
+ }
+
+ if (left + scrollBarWidth >= bodyWidth) {
+ left = bodyWidth - scrollBarWidth;
+ }
+
+ onScroll({
+ scrollLeft: (left / bodyWidth) * (bodyScrollWidth + 2),
+ });
+
+ refState.current.x = event.pageX;
+ };
+
+ const onContainerScroll = () => {
+ const tableOffsetTop = getOffset(scrollBodyRef.current).top;
+ const tableBottomOffset = tableOffsetTop + scrollBodyRef.current.offsetHeight;
+ const currentClientOffset = document.documentElement.scrollTop + window.innerHeight;
+
+ if (
+ tableBottomOffset - getScrollBarSize() <= currentClientOffset ||
+ tableOffsetTop >= currentClientOffset - offsetScroll
+ ) {
+ setFrameState(state => ({
+ ...state,
+ isHiddenScrollBar: true,
+ }));
+ } else {
+ setFrameState(state => ({
+ ...state,
+ isHiddenScrollBar: false,
+ }));
+ }
+ };
+
+ const setScrollLeft = (left: number) => {
+ setFrameState(state => {
+ return {
+ ...state,
+ scrollLeft: (left / bodyScrollWidth) * bodyWidth || 0,
+ };
+ });
+ };
+
+ React.useImperativeHandle(ref, () => ({
+ setScrollLeft,
+ }));
+
+ React.useEffect(() => {
+ const onMouseUpListener = addEventListener(document.body, 'mouseup', onMouseUp, false);
+ const onMouseMoveListener = addEventListener(document.body, 'mousemove', onMouseMove, false);
+ onContainerScroll();
+ return () => {
+ onMouseUpListener.remove();
+ onMouseMoveListener.remove();
+ };
+ }, [scrollBarWidth]);
+
+ React.useEffect(() => {
+ const onScrollListener = addEventListener(window, 'scroll', onContainerScroll, false);
+
+ return () => {
+ onScrollListener.remove();
+ };
+ }, []);
+
+ React.useEffect(() => {
+ if (!frameState.isHiddenScrollBar) {
+ setFrameState(state => ({
+ ...state,
+ scrollLeft:
+ (scrollBodyRef.current.scrollLeft / scrollBodyRef.current?.scrollWidth) *
+ scrollBodyRef.current?.offsetWidth,
+ }));
+ }
+ }, [frameState.isHiddenScrollBar]);
+
+ if (bodyScrollWidth <= bodyWidth || !scrollBarWidth || frameState.isHiddenScrollBar) {
+ return null;
+ }
+
+ return (
+
+ );
+};
+
+export default React.forwardRef(StickyScrollBar);
diff --git a/tests/Sticky.spec.js b/tests/Sticky.spec.js
new file mode 100644
index 000000000..5ab5ac038
--- /dev/null
+++ b/tests/Sticky.spec.js
@@ -0,0 +1,172 @@
+import React from 'react';
+import { mount } from 'enzyme';
+import { spyElementPrototypes } from 'rc-util/lib/test/domHook';
+import Table from '../src';
+
+describe('Table.Sticky', () => {
+ it('Sticky Header', () => {
+ jest.useFakeTimers();
+ const col1 = { dataIndex: 'light', width: 100 };
+ const col2 = { dataIndex: 'bamboo', width: 200 };
+
+ const TableDemo = props => {
+ return (
+
+ );
+ };
+ const wrapper = mount();
+
+ expect(wrapper.find('.rc-table-header').prop('style')).toEqual({
+ overflow: 'hidden',
+ top: 0,
+ });
+
+ expect(wrapper.find('.rc-table-header').prop('className')).toBe(
+ 'rc-table-header rc-table-sticky-header',
+ );
+
+ wrapper.setProps({
+ sticky: {
+ offsetHeader: 10,
+ },
+ });
+
+ expect(wrapper.find('.rc-table-header').prop('style')).toEqual({
+ overflow: 'hidden',
+ top: 10,
+ });
+
+ jest.useRealTimers();
+ });
+
+ it('Sticky scroll', () => {
+ jest.useFakeTimers();
+ window.pageYOffset = 900;
+ document.documentElement.scrollTop = 200;
+ let scrollLeft = 100;
+ const domSpy = spyElementPrototypes(HTMLDivElement, {
+ scrollLeft: {
+ get: () => scrollLeft,
+ set: left => {
+ scrollLeft = left;
+ },
+ },
+ scrollTop: {
+ get: () => 100,
+ },
+ scrollWidth: {
+ get: () => 200,
+ },
+ offsetWidth: {
+ get: () => 100,
+ },
+ offsetHeight: {
+ get: () => 100,
+ },
+ });
+
+ const col1 = { dataIndex: 'light', width: 1000 };
+ const col2 = { dataIndex: 'bamboo', width: 2000 };
+ const wrapper = mount(
+ ,
+ );
+
+ jest.runAllTimers();
+
+ expect(wrapper.find('.rc-table-sticky-scroll').get(0)).not.toBeUndefined();
+
+ const mockFn = jest.fn();
+
+ wrapper
+ .find('.rc-table-sticky-scroll-bar')
+ .simulate('mousedown', { persist: mockFn, preventDefault: mockFn, pageX: 0 });
+
+ expect(mockFn).toHaveBeenCalledTimes(2);
+
+ const mousemoveEvent = new Event('mousemove');
+
+ mousemoveEvent.buttons = 1;
+ mousemoveEvent.pageX = 50;
+ mousemoveEvent.preventDefault = mockFn;
+
+ document.body.dispatchEvent(mousemoveEvent);
+
+ jest.runAllTimers();
+ expect(mockFn).toHaveBeenCalledTimes(4);
+ expect(wrapper.find('.rc-table-sticky-scroll-bar').prop('style')).toEqual({
+ width: '50px',
+ transform: 'translate3d(0px, 0, 0)',
+ });
+
+ mousemoveEvent.pageX = -50;
+ mousemoveEvent.preventDefault = mockFn;
+ document.body.dispatchEvent(mousemoveEvent);
+
+ jest.runAllTimers();
+ wrapper.update();
+
+ expect(mockFn).toHaveBeenCalledTimes(6);
+ expect(wrapper.find('.rc-table-sticky-scroll-bar').prop('style')).toEqual({
+ width: '50px',
+ transform: 'translate3d(0px, 0, 0)',
+ });
+
+ const mouseupEvent = new Event('mouseup');
+
+ mouseupEvent.preventDefault = mockFn;
+
+ document.body.dispatchEvent(mouseupEvent);
+
+ expect(mockFn).toHaveBeenCalledTimes(8);
+
+ wrapper.unmount();
+
+ window.pageYOffset = 0;
+ mockFn.mockRestore();
+ domSpy.mockRestore();
+ jest.useRealTimers();
+ });
+});