From 4acc58147978059725372af0531ef77321d28238 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AA=97=E4=BD=A0=E6=98=AF=E5=B0=8F=E7=8C=AB=E5=92=AA?= Date: Fri, 31 Jul 2020 17:53:39 +0800 Subject: [PATCH] feat: sticky header and scroll (#505) * feat: sticky header and scroll * improve style * refactor, complete test suite * add mouseup test * test if left <= 0 * remove useless px * remove useless code * improve ref value if undefined * fix NaN * rename isShow -> show * Update FixedHeader.tsx * fix example refactor * imporve sticky prop * fix: trackpad scroll sync sticky scroll * fix test fail * imporve sticky * add test * remove useless code * imporve useSticky Co-authored-by: 07akioni <07akioni2@gmail.com> --- .prettierrc | 3 +- assets/index.less | 27 ++++++ examples/stickyHeader.tsx | 114 ++++++++++++++++++++++++ package.json | 2 +- src/Header/FixedHeader.tsx | 6 +- src/Table.tsx | 42 +++++++-- src/hooks/useSticky.ts | 22 +++++ src/interface.ts | 9 +- src/stickyScrollBar.tsx | 168 ++++++++++++++++++++++++++++++++++++ tests/Sticky.spec.js | 172 +++++++++++++++++++++++++++++++++++++ 10 files changed, 554 insertions(+), 11 deletions(-) create mode 100644 examples/stickyHeader.tsx create mode 100644 src/hooks/useSticky.ts create mode 100644 src/stickyScrollBar.tsx create mode 100644 tests/Sticky.spec.js 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(); + }); +});