diff --git a/CHANGELOG.md b/CHANGELOG.md index a85b2652..05b52c13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,16 @@ Changelog ------------ +### 1.6.2 +* 🐛 Bugfix for RTL when scrolling back towards the beginning (right) of the list. + +### 1.6.1 +* 🐛 Bugfix to account for differences between Chrome and non-Chrome browsers with regard to RTL and "scroll" events. + +### 1.6.0 +* 🎉 RTL support added for lists and grids. Special thanks to [davidgarsan](https://github.com/davidgarsan) for his support. - [#156](https://github.com/bvaughn/react-window/pull/156) +* 🐛 Grid `scrollToItem` methods take scrollbar size into account when aligning items - [#153](https://github.com/bvaughn/react-window/issues/153) + ### 1.5.2 * 🐛 Edge case bug fix for `VariableSizeList` and `VariableSizeGrid` when the number of items decreases while a scroll is in progress. - ([iamsolankiamit](https://github.com/iamsolankiamit) - [#138](https://github.com/bvaughn/react-window/pull/138)) diff --git a/package.json b/package.json index b6aa7818..12dbb1cb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-window", - "version": "1.5.2", + "version": "1.6.2", "description": "React components for efficiently rendering large, scrollable lists and tabular data", "author": @@ -61,7 +61,7 @@ }, "dependencies": { "@babel/runtime": "^7.0.0", - "memoize-one": "^3.1.1" + "memoize-one": ">=3.1.1 <6" }, "peerDependencies": { "react": "^15.0.0 || ^16.0.0", @@ -99,9 +99,9 @@ "react-dom": "^16.7.0", "react-scripts": "^1.1.1", "react-test-renderer": "^16.7.0", - "rollup": "^0.65.0", - "rollup-plugin-babel": "^4.0.2", - "rollup-plugin-commonjs": "^8.2.1", - "rollup-plugin-node-resolve": "^3.0.2" + "rollup": "^1.4.1", + "rollup-plugin-babel": "^4.3.2", + "rollup-plugin-commonjs": "^9.2.1", + "rollup-plugin-node-resolve": "^4.0.1" } } diff --git a/rollup.config.js b/rollup.config.js index 5d167d14..c05d722a 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -1,6 +1,7 @@ import babel from 'rollup-plugin-babel'; import commonjs from 'rollup-plugin-commonjs'; import nodeResolve from 'rollup-plugin-node-resolve'; + import pkg from './package.json'; const input = './src/index.js'; diff --git a/src/FixedSizeGrid.js b/src/FixedSizeGrid.js index f446acb8..6224f71d 100644 --- a/src/FixedSizeGrid.js +++ b/src/FixedSizeGrid.js @@ -27,7 +27,9 @@ const FixedSizeGrid = createGridComponent({ { columnCount, columnWidth, width }: Props, columnIndex: number, align: ScrollToAlign, - scrollLeft: number + scrollLeft: number, + instanceProps: typeof undefined, + scrollbarSize: number ): number => { const maxOffset = Math.max( 0, @@ -40,6 +42,7 @@ const FixedSizeGrid = createGridComponent({ 0, columnIndex * ((columnWidth: any): number) - width + + scrollbarSize + ((columnWidth: any): number) ); @@ -66,7 +69,9 @@ const FixedSizeGrid = createGridComponent({ { rowHeight, height, rowCount }: Props, rowIndex: number, align: ScrollToAlign, - scrollTop: number + scrollTop: number, + instanceProps: typeof undefined, + scrollbarSize: number ): number => { const maxOffset = Math.max( 0, @@ -79,6 +84,7 @@ const FixedSizeGrid = createGridComponent({ 0, rowIndex * ((rowHeight: any): number) - height + + scrollbarSize + ((rowHeight: any): number) ); diff --git a/src/FixedSizeList.js b/src/FixedSizeList.js index 7491d91d..498a8c9b 100644 --- a/src/FixedSizeList.js +++ b/src/FixedSizeList.js @@ -15,12 +15,14 @@ const FixedSizeList = createListComponent({ ((itemSize: any): number) * itemCount, getOffsetForIndexAndAlignment: ( - { direction, height, itemCount, itemSize, width }: Props, + { direction, height, itemCount, itemSize, layout, width }: Props, index: number, align: ScrollToAlign, scrollOffset: number ): number => { - const size = (((direction === 'horizontal' ? width : height): any): number); + // TODO Deprecate direction "horizontal" + const isHorizontal = direction === 'horizontal' || layout === 'horizontal'; + const size = (((isHorizontal ? width : height): any): number); const maxOffset = Math.max( 0, Math.min( @@ -62,12 +64,14 @@ const FixedSizeList = createListComponent({ ), getStopIndexForStartIndex: ( - { direction, height, itemCount, itemSize, width }: Props, + { direction, height, itemCount, itemSize, layout, width }: Props, startIndex: number, scrollOffset: number ): number => { + // TODO Deprecate direction "horizontal" + const isHorizontal = direction === 'horizontal' || layout === 'horizontal'; const offset = startIndex * ((itemSize: any): number); - const size = (((direction === 'horizontal' ? width : height): any): number); + const size = (((isHorizontal ? width : height): any): number); return Math.max( 0, Math.min( diff --git a/src/VariableSizeGrid.js b/src/VariableSizeGrid.js index be5a3c9d..061b3064 100644 --- a/src/VariableSizeGrid.js +++ b/src/VariableSizeGrid.js @@ -233,7 +233,8 @@ const getOffsetForIndexAndAlignment = ( index: number, align: ScrollToAlign, scrollOffset: number, - instanceProps: InstanceProps + instanceProps: InstanceProps, + scrollbarSize: number ): number => { const size = itemType === 'column' ? props.width : props.height; const itemMetadata = getItemMetadata(itemType, props, index, instanceProps); @@ -249,7 +250,10 @@ const getOffsetForIndexAndAlignment = ( 0, Math.min(estimatedTotalSize - size, itemMetadata.offset) ); - const minOffset = Math.max(0, itemMetadata.offset - size + itemMetadata.size); + const minOffset = Math.max( + 0, + itemMetadata.offset - size + scrollbarSize + itemMetadata.size + ); switch (align) { case 'start': @@ -324,7 +328,8 @@ const VariableSizeGrid = createGridComponent({ index: number, align: ScrollToAlign, scrollOffset: number, - instanceProps: InstanceProps + instanceProps: InstanceProps, + scrollbarSize: number ): number => getOffsetForIndexAndAlignment( 'column', @@ -332,7 +337,8 @@ const VariableSizeGrid = createGridComponent({ index, align, scrollOffset, - instanceProps + instanceProps, + scrollbarSize ), getOffsetForRowAndAlignment: ( @@ -340,7 +346,8 @@ const VariableSizeGrid = createGridComponent({ index: number, align: ScrollToAlign, scrollOffset: number, - instanceProps: InstanceProps + instanceProps: InstanceProps, + scrollbarSize: number ): number => getOffsetForIndexAndAlignment( 'row', @@ -348,7 +355,8 @@ const VariableSizeGrid = createGridComponent({ index, align, scrollOffset, - instanceProps + instanceProps, + scrollbarSize ), getRowOffset: ( diff --git a/src/VariableSizeList.js b/src/VariableSizeList.js index 75188c2c..806beac1 100644 --- a/src/VariableSizeList.js +++ b/src/VariableSizeList.js @@ -185,9 +185,11 @@ const VariableSizeList = createListComponent({ scrollOffset: number, instanceProps: InstanceProps ): number => { - const { direction, height, width } = props; + const { direction, height, layout, width } = props; - const size = (((direction === 'horizontal' ? width : height): any): number); + // TODO Deprecate direction "horizontal" + const isHorizontal = direction === 'horizontal' || layout === 'horizontal'; + const size = (((isHorizontal ? width : height): any): number); const itemMetadata = getItemMetadata(props, index, instanceProps); // Get estimated total size after ItemMetadata is computed, @@ -234,9 +236,11 @@ const VariableSizeList = createListComponent({ scrollOffset: number, instanceProps: InstanceProps ): number => { - const { direction, height, itemCount, width } = props; + const { direction, height, itemCount, layout, width } = props; - const size = (((direction === 'horizontal' ? width : height): any): number); + // TODO Deprecate direction "horizontal" + const isHorizontal = direction === 'horizontal' || layout === 'horizontal'; + const size = (((isHorizontal ? width : height): any): number); const itemMetadata = getItemMetadata(props, startIndex, instanceProps); const maxOffset = scrollOffset + size; diff --git a/src/__tests__/FixedSizeGrid.js b/src/__tests__/FixedSizeGrid.js index 14e0de1f..dbc4303d 100644 --- a/src/__tests__/FixedSizeGrid.js +++ b/src/__tests__/FixedSizeGrid.js @@ -3,6 +3,7 @@ import ReactDOM from 'react-dom'; import ReactTestRenderer from 'react-test-renderer'; import ReactTestUtils from 'react-dom/test-utils'; import { FixedSizeGrid } from '..'; +import * as domHelpers from '../domHelpers'; const findScrollContainer = rendered => rendered.root.children[0].children[0]; @@ -13,7 +14,7 @@ const simulateScroll = (instance, { scrollLeft, scrollTop }) => { }; describe('FixedSizeGrid', () => { - let itemRenderer, defaultProps, onItemsRendered; + let defaultProps, getScrollbarSize, itemRenderer, onItemsRendered; // Use PureComponent to test memoization. // Pass through to itemRenderer mock for easier test assertions. @@ -26,6 +27,9 @@ describe('FixedSizeGrid', () => { beforeEach(() => { jest.useFakeTimers(); + // Mock the DOM helper util for testing purposes. + getScrollbarSize = domHelpers.getScrollbarSize = jest.fn(() => 0); + onItemsRendered = jest.fn(); itemRenderer = jest.fn(({ style, ...rest }) => ( @@ -172,6 +176,41 @@ describe('FixedSizeGrid', () => { }); }); + describe('direction', () => { + it('should set the appropriate CSS direction style', () => { + const renderer = ReactTestRenderer.create( + + ); + expect(renderer.toJSON().props.style.direction).toBe('ltr'); + renderer.update(); + expect(renderer.toJSON().props.style.direction).toBe('rtl'); + }); + + it('should position items correctly', () => { + const renderer = ReactTestRenderer.create( + + ); + + let params = itemRenderer.mock.calls[0][0]; + expect(params.columnIndex).toBe(0); + expect(params.rowIndex).toBe(0); + let style = params.style; + expect(style.left).toBe(0); + expect(style.right).toBeUndefined(); + + itemRenderer.mockClear(); + + renderer.update(); + + params = itemRenderer.mock.calls[0][0]; + expect(params.columnIndex).toBe(0); + expect(params.rowIndex).toBe(0); + style = params.style; + expect(style.left).toBeUndefined(); + expect(style.right).toBe(0); + }); + }); + describe('overscanColumnsCount and overscanRowsCount', () => { it('should require a minimum of 1 overscan to support tabbing', () => { ReactTestRenderer.create( @@ -248,7 +287,8 @@ describe('FixedSizeGrid', () => { describe('overscanCount', () => { it('should warn about deprecated overscanCount prop', () => { spyOn(console, 'warn'); - ReactTestRenderer.create( + + const renderer = ReactTestRenderer.create( ); expect(console.warn).toHaveBeenCalledTimes(1); @@ -256,10 +296,16 @@ describe('FixedSizeGrid', () => { 'The overscanCount prop has been deprecated. ' + 'Please use the overscanColumnsCount and overscanRowsCount props instead.' ); + + renderer.update(); + + // But it should only warn once. + expect(console.warn).toHaveBeenCalledTimes(1); }); it('should use overscanColumnsCount if both it and overscanCount are provided', () => { spyOn(console, 'warn'); + ReactTestRenderer.create( { it('should use overscanRowsCount if both it and overscanCount are provided', () => { spyOn(console, 'warn'); + ReactTestRenderer.create( { it('should support deprecated overscanCount', () => { spyOn(console, 'warn'); + ReactTestRenderer.create( { instance.scrollToItem({ columnIndex: 15, rowIndex: 20 }); expect(itemRenderer.mock.calls[0][0].isScrolling).toBe(false); }); + + it('should account for scrollbar size', () => { + const onScroll = jest.fn(); + const rendered = ReactTestRenderer.create( + + ); + + onScroll.mockClear(); + rendered + .getInstance() + .scrollToItem({ columnIndex: 15, rowIndex: 10, align: 'end' }); + + // With hidden scrollbars (size === 0) we would expect... + expect(onScroll).toHaveBeenCalledWith({ + horizontalScrollDirection: 'forward', + scrollLeft: 1300, + scrollTop: 125, + scrollUpdateWasRequested: true, + verticalScrollDirection: 'forward', + }); + + getScrollbarSize.mockImplementation(() => 20); + + onScroll.mockClear(); + rendered + .getInstance() + .scrollToItem({ columnIndex: 15, rowIndex: 10, align: 'end' }); + + // With scrollbars of size 20 we would expect those values ot increase by 20px + expect(onScroll).toHaveBeenCalledWith({ + horizontalScrollDirection: 'forward', + scrollLeft: 1320, + scrollTop: 145, + scrollUpdateWasRequested: true, + verticalScrollDirection: 'forward', + }); + }); + + it('should not account for scrollbar size when no scrollbar is visible for a particular direction', () => { + getScrollbarSize.mockImplementation(() => 20); + + const onScroll = jest.fn(); + const rendered = ReactTestRenderer.create( + + ); + + onScroll.mockClear(); + rendered + .getInstance() + .scrollToItem({ columnIndex: 0, rowIndex: 10, align: 'end' }); + + // Since there aren't enough columns to require horizontal scrolling, + // the additional 20px for the scrollbar should not be taken into consideration. + expect(onScroll).toHaveBeenCalledWith({ + horizontalScrollDirection: 'backward', + scrollLeft: 0, + scrollTop: 125, + scrollUpdateWasRequested: true, + verticalScrollDirection: 'forward', + }); + + rendered.update( + + ); + + onScroll.mockClear(); + rendered + .getInstance() + .scrollToItem({ columnIndex: 15, rowIndex: 0, align: 'end' }); + + // Since there aren't enough rows to require vertical scrolling, + // the additional 20px for the scrollbar should not be taken into consideration. + expect(onScroll).toHaveBeenCalledWith({ + horizontalScrollDirection: 'forward', + scrollLeft: 1300, + scrollTop: 0, + scrollUpdateWasRequested: true, + verticalScrollDirection: 'backward', + }); + }); }); // onItemsRendered is pretty well covered by other snapshot tests @@ -721,19 +872,29 @@ describe('FixedSizeGrid', () => { it('should warn if legacy innerTagName or outerTagName props are used', () => { spyOn(console, 'warn'); - ReactDOM.render( + const renderer = ReactTestRenderer.create( , - document.createElement('div') + /> ); expect(console.warn).toHaveBeenCalledTimes(1); expect(console.warn).toHaveBeenLastCalledWith( 'The innerTagName and outerTagName props have been deprecated. ' + 'Please use the innerElementType and outerElementType props instead.' ); + + renderer.update( + + ); + + // But it should only warn once. + expect(console.warn).toHaveBeenCalledTimes(1); }); }); @@ -814,10 +975,22 @@ describe('FixedSizeGrid', () => { ); }); + it('should fail if an invalid direction is provided', () => { + expect(() => + ReactTestRenderer.create( + + ) + ).toThrow( + 'An invalid "direction" prop has been specified. ' + + 'Value should be either "ltr" or "rtl". ' + + '"null" was specified.' + ); + }); + it('should fail if a string height is provided', () => { expect(() => ReactTestRenderer.create( - + ) ).toThrow( 'An invalid "height" prop has been specified. ' + @@ -829,11 +1002,7 @@ describe('FixedSizeGrid', () => { it('should fail if a string width is provided', () => { expect(() => ReactTestRenderer.create( - + ) ).toThrow( 'An invalid "width" prop has been specified. ' + diff --git a/src/__tests__/FixedSizeList.js b/src/__tests__/FixedSizeList.js index d71b9f39..f591c3d0 100644 --- a/src/__tests__/FixedSizeList.js +++ b/src/__tests__/FixedSizeList.js @@ -58,13 +58,32 @@ describe('FixedSizeList', () => { it('should render a list of columns', () => { ReactTestRenderer.create( - + ); expect(itemRenderer).toHaveBeenCalledTimes(5); expect(onItemsRendered.mock.calls).toMatchSnapshot(); }); + it('should re-render items if layout changes', () => { + const rendered = ReactTestRenderer.create( + + ); + expect(itemRenderer).toHaveBeenCalled(); + itemRenderer.mockClear(); + + // Re-rendering should not affect pure sCU children: + rendered.update(); + expect(itemRenderer).not.toHaveBeenCalled(); + + // Re-rendering with new layout should re-render children: + rendered.update(); + expect(itemRenderer).toHaveBeenCalled(); + }); + + // TODO Deprecate direction "horizontal" it('should re-render items if direction changes', () => { + spyOn(console, 'warn'); // Ingore legacy prop warning + const rendered = ReactTestRenderer.create( ); @@ -75,7 +94,7 @@ describe('FixedSizeList', () => { rendered.update(); expect(itemRenderer).not.toHaveBeenCalled(); - // Re-rendering with new direction should re-render children: + // Re-rendering with new layout should re-render children: rendered.update(); expect(itemRenderer).toHaveBeenCalled(); }); @@ -97,7 +116,7 @@ describe('FixedSizeList', () => { ReactDOM.render( , document.createElement('div') @@ -202,6 +221,39 @@ describe('FixedSizeList', () => { }); }); + describe('direction', () => { + it('should set the appropriate CSS direction style', () => { + const renderer = ReactTestRenderer.create( + + ); + expect(renderer.toJSON().props.style.direction).toBe('ltr'); + renderer.update(); + expect(renderer.toJSON().props.style.direction).toBe('rtl'); + }); + + it('should position items correctly', () => { + const renderer = ReactTestRenderer.create( + + ); + + let params = itemRenderer.mock.calls[0][0]; + expect(params.index).toBe(0); + let style = params.style; + expect(style.left).toBe(0); + expect(style.right).toBeUndefined(); + + itemRenderer.mockClear(); + + renderer.update(); + + params = itemRenderer.mock.calls[0][0]; + expect(params.index).toBe(0); + style = params.style; + expect(style.left).toBeUndefined(); + expect(style.right).toBe(0); + }); + }); + describe('overscanCount', () => { it('should require a minimum of 1 overscan to support tabbing', () => { ReactTestRenderer.create( @@ -583,19 +635,68 @@ describe('FixedSizeList', () => { it('should warn if legacy innerTagName or outerTagName props are used', () => { spyOn(console, 'warn'); - ReactDOM.render( + + const renderer = ReactTestRenderer.create( , - document.createElement('div') + /> ); expect(console.warn).toHaveBeenCalledTimes(1); expect(console.warn).toHaveBeenLastCalledWith( 'The innerTagName and outerTagName props have been deprecated. ' + 'Please use the innerElementType and outerElementType props instead.' ); + + renderer.update( + + ); + + // But it should only warn once. + expect(console.warn).toHaveBeenCalledTimes(1); + }); + + it('should warn if legacy direction "horizontal" value is used', () => { + spyOn(console, 'warn'); + + const renderer = ReactTestRenderer.create( + + ); + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenLastCalledWith( + 'The direction prop should be either "ltr" (default) or "rtl". ' + + 'Please use the layout prop to specify "vertical" (default) or "horizontal" orientation.' + ); + + renderer.update( + + ); + + // But it should only warn once. + expect(console.warn).toHaveBeenCalledTimes(1); + }); + + it('should warn if legacy direction "vertical" value is used', () => { + spyOn(console, 'warn'); + + const renderer = ReactTestRenderer.create( + + ); + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenLastCalledWith( + 'The direction prop should be either "ltr" (default) or "rtl". ' + + 'Please use the layout prop to specify "vertical" (default) or "horizontal" orientation.' + ); + + renderer.update(); + + // But it should only warn once. + expect(console.warn).toHaveBeenCalledTimes(1); }); }); @@ -664,6 +765,18 @@ describe('FixedSizeList', () => { ); }); + it('should fail if an invalid layout is provided', () => { + expect(() => + ReactTestRenderer.create( + + ) + ).toThrow( + 'An invalid "layout" prop has been specified. ' + + 'Value should be either "horizontal" or "vertical". ' + + '"null" was specified.' + ); + }); + it('should fail if an invalid direction is provided', () => { expect(() => ReactTestRenderer.create( @@ -671,7 +784,7 @@ describe('FixedSizeList', () => { ) ).toThrow( 'An invalid "direction" prop has been specified. ' + - 'Value should be either "horizontal" or "vertical". ' + + 'Value should be either "ltr" or "rtl". ' + '"null" was specified.' ); }); @@ -679,7 +792,7 @@ describe('FixedSizeList', () => { it('should fail if a string height is provided for a vertical list', () => { expect(() => ReactTestRenderer.create( - + ) ).toThrow( 'An invalid "height" prop has been specified. ' + @@ -691,11 +804,7 @@ describe('FixedSizeList', () => { it('should fail if a string width is provided for a horizontal list', () => { expect(() => ReactTestRenderer.create( - + ) ).toThrow( 'An invalid "width" prop has been specified. ' + diff --git a/src/__tests__/VariableSizeGrid.js b/src/__tests__/VariableSizeGrid.js index fb3d76a8..de665b13 100644 --- a/src/__tests__/VariableSizeGrid.js +++ b/src/__tests__/VariableSizeGrid.js @@ -3,6 +3,7 @@ import { render } from 'react-dom'; import { Simulate } from 'react-dom/test-utils'; import ReactTestRenderer from 'react-test-renderer'; import { VariableSizeGrid } from '..'; +import * as domHelpers from '../domHelpers'; const simulateScroll = (instance, { scrollLeft, scrollTop }) => { instance._outerRef.scrollLeft = scrollLeft; @@ -13,7 +14,12 @@ const simulateScroll = (instance, { scrollLeft, scrollTop }) => { const findScrollContainer = rendered => rendered.root.children[0].children[0]; describe('VariableSizeGrid', () => { - let columnWidth, defaultProps, itemRenderer, onItemsRendered, rowHeight; + let columnWidth, + defaultProps, + getScrollbarSize, + itemRenderer, + onItemsRendered, + rowHeight; // Use PureComponent to test memoization. // Pass through to itemRenderer mock for easier test assertions. @@ -34,6 +40,9 @@ describe('VariableSizeGrid', () => { beforeEach(() => { jest.useFakeTimers(); + // Mock the DOM helper util for testing purposes. + getScrollbarSize = domHelpers.getScrollbarSize = jest.fn(() => 0); + itemRenderer = jest.fn(({ style, ...rest }) => (
{JSON.stringify(rest, null, 2)}
)); @@ -255,6 +264,90 @@ describe('VariableSizeGrid', () => { .scrollToItem({ columnIndex: 9, rowIndex: 19, align: 'center' }); expect(onItemsRendered.mock.calls).toMatchSnapshot(); }); + + it('should account for scrollbar size', () => { + const onScroll = jest.fn(); + const rendered = ReactTestRenderer.create( + + ); + + onScroll.mockClear(); + rendered + .getInstance() + .scrollToItem({ columnIndex: 15, rowIndex: 10, align: 'end' }); + + // With hidden scrollbars (size === 0) we would expect... + expect(onScroll).toHaveBeenCalledWith({ + horizontalScrollDirection: 'forward', + scrollLeft: 720, + scrollTop: 230, + scrollUpdateWasRequested: true, + verticalScrollDirection: 'forward', + }); + + getScrollbarSize.mockImplementation(() => 20); + + onScroll.mockClear(); + rendered + .getInstance() + .scrollToItem({ columnIndex: 15, rowIndex: 10, align: 'end' }); + + // With scrollbars of size 20 we would expect those values ot increase by 20px + expect(onScroll).toHaveBeenCalledWith({ + horizontalScrollDirection: 'forward', + scrollLeft: 740, + scrollTop: 250, + scrollUpdateWasRequested: true, + verticalScrollDirection: 'forward', + }); + }); + + it('should not account for scrollbar size when no scrollbar is visible for a particular direction', () => { + getScrollbarSize.mockImplementation(() => 20); + + const onScroll = jest.fn(); + const rendered = ReactTestRenderer.create( + + ); + + onScroll.mockClear(); + rendered + .getInstance() + .scrollToItem({ columnIndex: 0, rowIndex: 10, align: 'end' }); + + // Since there aren't enough columns to require horizontal scrolling, + // the additional 20px for the scrollbar should not be taken into consideration. + expect(onScroll).toHaveBeenCalledWith({ + horizontalScrollDirection: 'backward', + scrollLeft: 0, + scrollTop: 230, + scrollUpdateWasRequested: true, + verticalScrollDirection: 'forward', + }); + + rendered.update( + + ); + + onScroll.mockClear(); + rendered + .getInstance() + .scrollToItem({ columnIndex: 15, rowIndex: 0, align: 'end' }); + + // Since there aren't enough rows to require vertical scrolling, + // the additional 20px for the scrollbar should not be taken into consideration. + expect(onScroll).toHaveBeenCalledWith({ + horizontalScrollDirection: 'forward', + scrollLeft: 720, + scrollTop: 0, + scrollUpdateWasRequested: true, + verticalScrollDirection: 'backward', + }); + }); }); describe('resetAfterIndex method', () => { diff --git a/src/createGridComponent.js b/src/createGridComponent.js index 4e8fbb82..fd3893e8 100644 --- a/src/createGridComponent.js +++ b/src/createGridComponent.js @@ -3,9 +3,11 @@ import memoizeOne from 'memoize-one'; import { createElement, PureComponent } from 'react'; import { cancelTimeout, requestTimeout } from './timer'; +import { getScrollbarSize } from './domHelpers'; import type { TimeoutID } from './timer'; +type Direction = 'ltr' | 'rtl'; export type ScrollToAlign = 'auto' | 'center' | 'start' | 'end'; type itemSize = number | ((index: number) => number); @@ -49,6 +51,7 @@ export type Props = {| className?: string, columnCount: number, columnWidth: itemSize, + direction: Direction, height: number, initialScrollLeft?: number, initialScrollTop?: number, @@ -77,6 +80,7 @@ export type Props = {| |}; type State = {| + instance: any, isScrolling: boolean, horizontalScrollDirection: ScrollDirection, scrollLeft: number, @@ -101,7 +105,8 @@ type GetOffsetForItemAndAlignment = ( index: number, align: ScrollToAlign, scrollOffset: number, - instanceProps: any + instanceProps: any, + scrollbarSize: number ) => number; type GetStartIndexForOffset = ( props: Props, @@ -122,6 +127,15 @@ const IS_SCROLLING_DEBOUNCE_INTERVAL = 150; const defaultItemKey = ({ columnIndex, data, rowIndex }) => `${rowIndex}:${columnIndex}`; +// In DEV mode, this Set helps us only log a warning once per component instace. +// This avoids spamming the console every time a render happens. +let devWarningsOverscanCount = null; +let devWarningsTagName = null; +if (process.env.NODE_ENV !== 'production') { + devWarningsOverscanCount = new WeakSet(); + devWarningsTagName = new WeakSet(); +} + export default function createGridComponent({ getColumnOffset, getColumnStartIndexForOffset, @@ -161,11 +175,13 @@ export default function createGridComponent({ _outerRef: ?HTMLDivElement; static defaultProps = { + direction: 'ltr', itemData: undefined, useIsScrolling: false, }; state: State = { + instance: this, isScrolling: false, horizontalScrollDirection: 'forward', scrollLeft: @@ -191,7 +207,7 @@ export default function createGridComponent({ nextProps: Props, prevState: State ): $Shape | null { - validateSharedProps(nextProps); + validateSharedProps(nextProps, prevState); validateProps(nextProps); return null; } @@ -232,7 +248,26 @@ export default function createGridComponent({ columnIndex: number, rowIndex: number, }): void { + const { height, width } = this.props; const { scrollLeft, scrollTop } = this.state; + const scrollbarSize = getScrollbarSize(); + + const estimatedTotalHeight = getEstimatedTotalHeight( + this.props, + this._instanceProps + ); + const estimatedTotalWidth = getEstimatedTotalWidth( + this.props, + this._instanceProps + ); + + // The scrollbar size should be considered when scrolling an item into view, + // to ensure it's fully visible. + // But we only need to account for its size when it's actually visible. + const horizontalScrollbarSize = + estimatedTotalWidth > width ? scrollbarSize : 0; + const verticalScrollbarSize = + estimatedTotalHeight > height ? scrollbarSize : 0; this.scrollTo({ scrollLeft: getOffsetForColumnAndAlignment( @@ -240,14 +275,16 @@ export default function createGridComponent({ columnIndex, align, scrollLeft, - this._instanceProps + this._instanceProps, + verticalScrollbarSize ), scrollTop: getOffsetForRowAndAlignment( this.props, rowIndex, align, scrollTop, - this._instanceProps + this._instanceProps, + horizontalScrollbarSize ), }); } @@ -285,6 +322,7 @@ export default function createGridComponent({ children, className, columnCount, + direction, height, innerRef, innerElementType, @@ -356,6 +394,7 @@ export default function createGridComponent({ overflow: 'auto', WebkitOverflowScrolling: 'touch', willChange: 'transform', + direction, ...style, }, }, @@ -482,20 +521,27 @@ export default function createGridComponent({ // So that List can clear cached styles and force item re-render if necessary. _getItemStyle: (rowIndex: number, columnIndex: number) => Object; _getItemStyle = (rowIndex: number, columnIndex: number): Object => { - const key = `${rowIndex}:${columnIndex}`; + const { columnWidth, direction, rowHeight } = this.props; const itemStyleCache = this._getItemStyleCache( - shouldResetStyleCacheOnItemSizeChange && this.props.columnWidth, - shouldResetStyleCacheOnItemSizeChange && this.props.rowHeight + shouldResetStyleCacheOnItemSizeChange && columnWidth, + shouldResetStyleCacheOnItemSizeChange && direction, + shouldResetStyleCacheOnItemSizeChange && rowHeight ); + const key = `${rowIndex}:${columnIndex}`; + let style; if (itemStyleCache.hasOwnProperty(key)) { style = itemStyleCache[key]; } else { itemStyleCache[key] = style = { position: 'absolute', - left: getColumnOffset(this.props, columnIndex, this._instanceProps), + [direction === 'rtl' ? 'right' : 'left']: getColumnOffset( + this.props, + columnIndex, + this._instanceProps + ), top: getRowOffset(this.props, rowIndex, this._instanceProps), height: getRowHeight(this.props, rowIndex, this._instanceProps), width: getColumnWidth(this.props, columnIndex, this._instanceProps), @@ -505,8 +551,8 @@ export default function createGridComponent({ return style; }; - _getItemStyleCache: (_: any, __: any) => ItemStyleCache; - _getItemStyleCache = memoizeOne((_: any, __: any) => ({})); + _getItemStyleCache: (_: any, __: any, ___: any) => ItemStyleCache; + _getItemStyleCache = memoizeOne((_: any, __: any, ___: any) => ({})); _getHorizontalRangeToRender(): [number, number, number, number] { const { @@ -603,7 +649,12 @@ export default function createGridComponent({ } _onScroll = (event: ScrollEvent): void => { - const { scrollLeft, scrollTop } = event.currentTarget; + const { + clientWidth, + scrollLeft, + scrollTop, + scrollWidth, + } = event.currentTarget; this.setState(prevState => { if ( prevState.scrollLeft === scrollLeft && @@ -615,11 +666,25 @@ export default function createGridComponent({ return null; } + const { direction } = this.props; + + // HACK According to the spec, scrollLeft should be negative for RTL aligned elements. + // Chrome does not seem to adhere; its scrolLeft values are positive (measured relative to the left). + // See https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollLeft + let calculatedScrollLeft = scrollLeft; + if (direction === 'rtl') { + if (scrollLeft <= 0) { + calculatedScrollLeft = -scrollLeft; + } else { + calculatedScrollLeft = scrollWidth - clientWidth - scrollLeft; + } + } + return { isScrolling: true, horizontalScrollDirection: prevState.scrollLeft < scrollLeft ? 'forward' : 'backward', - scrollLeft, + scrollLeft: calculatedScrollLeft, scrollTop, verticalScrollDirection: prevState.scrollTop < scrollTop ? 'forward' : 'backward', @@ -667,27 +732,37 @@ export default function createGridComponent({ }; } -const validateSharedProps = ({ - children, - height, - innerTagName, - outerTagName, - overscanCount, - width, -}: Props): void => { +const validateSharedProps = ( + { + children, + direction, + height, + innerTagName, + outerTagName, + overscanCount, + width, + }: Props, + { instance }: State +): void => { if (process.env.NODE_ENV !== 'production') { if (typeof overscanCount === 'number') { - console.warn( - 'The overscanCount prop has been deprecated. ' + - 'Please use the overscanColumnsCount and overscanRowsCount props instead.' - ); + if (!((devWarningsOverscanCount: any): WeakSet).has(instance)) { + ((devWarningsOverscanCount: any): WeakSet).add(instance); + console.warn( + 'The overscanCount prop has been deprecated. ' + + 'Please use the overscanColumnsCount and overscanRowsCount props instead.' + ); + } } if (innerTagName != null || outerTagName != null) { - console.warn( - 'The innerTagName and outerTagName props have been deprecated. ' + - 'Please use the innerElementType and outerElementType props instead.' - ); + if (!((devWarningsTagName: any): WeakSet).has(instance)) { + ((devWarningsTagName: any): WeakSet).add(instance); + console.warn( + 'The innerTagName and outerTagName props have been deprecated. ' + + 'Please use the innerElementType and outerElementType props instead.' + ); + } } if (children == null) { @@ -698,6 +773,19 @@ const validateSharedProps = ({ ); } + switch (direction) { + case 'ltr': + case 'rtl': + // Valid values + break; + default: + throw Error( + 'An invalid "direction" prop has been specified. ' + + 'Value should be either "ltr" or "rtl". ' + + `"${direction}" was specified.` + ); + } + if (typeof width !== 'number') { throw Error( 'An invalid "width" prop has been specified. ' + diff --git a/src/createListComponent.js b/src/createListComponent.js index daa34625..5756d44e 100644 --- a/src/createListComponent.js +++ b/src/createListComponent.js @@ -9,7 +9,9 @@ import type { TimeoutID } from './timer'; export type ScrollToAlign = 'auto' | 'center' | 'start' | 'end'; type itemSize = number | ((index: number) => number); -type Direction = 'horizontal' | 'vertical'; +// TODO Deprecate directions "horizontal" and "vertical" +type Direction = 'ltr' | 'rtl' | 'horizontal' | 'vertical'; +type Layout = 'horizontal' | 'vertical'; type RenderComponentProps = {| data: T, @@ -49,6 +51,7 @@ export type Props = {| itemData: T, itemKey?: (index: number, data: T) => any, itemSize: itemSize, + layout: Layout, onItemsRendered?: onItemsRenderedCallback, onScroll?: onScrollCallback, outerRef?: any, @@ -61,6 +64,7 @@ export type Props = {| |}; type State = {| + instance: any, isScrolling: boolean, scrollDirection: ScrollDirection, scrollOffset: number, @@ -103,6 +107,15 @@ const IS_SCROLLING_DEBOUNCE_INTERVAL = 150; const defaultItemKey = (index: number, data: any) => index; +// In DEV mode, this Set helps us only log a warning once per component instace. +// This avoids spamming the console every time a render happens. +let devWarningsDirection = null; +let devWarningsTagName = null; +if (process.env.NODE_ENV !== 'production') { + devWarningsDirection = new WeakSet(); + devWarningsTagName = new WeakSet(); +} + export default function createListComponent({ getItemOffset, getEstimatedTotalSize, @@ -130,13 +143,15 @@ export default function createListComponent({ _resetIsScrollingTimeoutId: TimeoutID | null = null; static defaultProps = { - direction: 'vertical', + direction: 'ltr', itemData: undefined, + layout: 'vertical', overscanCount: 2, useIsScrolling: false, }; state: State = { + instance: this, isScrolling: false, scrollDirection: 'forward', scrollOffset: @@ -154,11 +169,11 @@ export default function createListComponent({ } static getDerivedStateFromProps( - props: Props, - state: State + nextProps: Props, + prevState: State ): $Shape | null { - validateSharedProps(props); - validateProps(props); + validateSharedProps(nextProps, prevState); + validateProps(nextProps); return null; } @@ -188,10 +203,11 @@ export default function createListComponent({ } componentDidMount() { - const { initialScrollOffset, direction } = this.props; + const { direction, initialScrollOffset, layout } = this.props; if (typeof initialScrollOffset === 'number' && this._outerRef !== null) { - if (direction === 'horizontal') { + // TODO Deprecate direction "horizontal" + if (direction === 'horizontal' || layout === 'horizontal') { ((this ._outerRef: any): HTMLDivElement).scrollLeft = initialScrollOffset; } else { @@ -204,11 +220,12 @@ export default function createListComponent({ } componentDidUpdate() { - const { direction } = this.props; + const { direction, layout } = this.props; const { scrollOffset, scrollUpdateWasRequested } = this.state; if (scrollUpdateWasRequested && this._outerRef !== null) { - if (direction === 'horizontal') { + // TODO Deprecate direction "horizontal" + if (direction === 'horizontal' || layout === 'horizontal') { ((this._outerRef: any): HTMLDivElement).scrollLeft = scrollOffset; } else { ((this._outerRef: any): HTMLDivElement).scrollTop = scrollOffset; @@ -236,6 +253,7 @@ export default function createListComponent({ itemCount, itemData, itemKey = defaultItemKey, + layout, outerElementType, outerTagName, style, @@ -244,10 +262,13 @@ export default function createListComponent({ } = this.props; const { isScrolling } = this.state; - const onScroll = - direction === 'vertical' - ? this._onScrollVertical - : this._onScrollHorizontal; + // TODO Deprecate direction "horizontal" + const isHorizontal = + direction === 'horizontal' || layout === 'horizontal'; + + const onScroll = isHorizontal + ? this._onScrollHorizontal + : this._onScrollVertical; const [startIndex, stopIndex] = this._getRangeToRender(); @@ -286,6 +307,7 @@ export default function createListComponent({ overflow: 'auto', WebkitOverflowScrolling: 'touch', willChange: 'transform', + direction, ...style, }, }, @@ -293,9 +315,9 @@ export default function createListComponent({ children: items, ref: innerRef, style: { - height: direction === 'horizontal' ? '100%' : estimatedTotalSize, + height: isHorizontal ? '100%' : estimatedTotalSize, pointerEvents: isScrolling ? 'none' : '', - width: direction === 'horizontal' ? estimatedTotalSize : '100%', + width: isHorizontal ? estimatedTotalSize : '100%', }, }) ); @@ -379,10 +401,11 @@ export default function createListComponent({ // So that List can clear cached styles and force item re-render if necessary. _getItemStyle: (index: number) => Object; _getItemStyle = (index: number): Object => { - const { direction, itemSize } = this.props; + const { direction, itemSize, layout } = this.props; const itemStyleCache = this._getItemStyleCache( shouldResetStyleCacheOnItemSizeChange && itemSize, + shouldResetStyleCacheOnItemSizeChange && layout, shouldResetStyleCacheOnItemSizeChange && direction ); @@ -393,20 +416,24 @@ export default function createListComponent({ const offset = getItemOffset(this.props, index, this._instanceProps); const size = getItemSize(this.props, index, this._instanceProps); + // TODO Deprecate direction "horizontal" + const isHorizontal = + direction === 'horizontal' || layout === 'horizontal'; + itemStyleCache[index] = style = { position: 'absolute', - left: direction === 'horizontal' ? offset : 0, - top: direction === 'vertical' ? offset : 0, - height: direction === 'vertical' ? size : '100%', - width: direction === 'horizontal' ? size : '100%', + [direction === 'rtl' ? 'right' : 'left']: isHorizontal ? offset : 0, + top: !isHorizontal ? offset : 0, + height: !isHorizontal ? size : '100%', + width: isHorizontal ? size : '100%', }; } return style; }; - _getItemStyleCache: (_: any, __: any) => ItemStyleCache; - _getItemStyleCache = memoizeOne((_: any, __: any) => ({})); + _getItemStyleCache: (_: any, __: any, ___: any) => ItemStyleCache; + _getItemStyleCache = memoizeOne((_: any, __: any, ___: any) => ({})); _getRangeToRender(): [number, number, number, number] { const { itemCount, overscanCount } = this.props; @@ -448,7 +475,7 @@ export default function createListComponent({ } _onScrollHorizontal = (event: ScrollEvent): void => { - const { scrollLeft } = event.currentTarget; + const { clientWidth, scrollLeft, scrollWidth } = event.currentTarget; this.setState(prevState => { if (prevState.scrollOffset === scrollLeft) { // Scroll position may have been updated by cDM/cDU, @@ -457,11 +484,25 @@ export default function createListComponent({ return null; } + const { direction } = this.props; + + // HACK According to the spec, scrollLeft should be negative for RTL aligned elements. + // Chrome does not seem to adhere; its scrolLeft values are positive (measured relative to the left). + // See https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollLeft + let scrollOffset = scrollLeft; + if (direction === 'rtl') { + if (scrollLeft <= 0) { + scrollOffset = -scrollOffset; + } else { + scrollOffset = scrollWidth - clientWidth - scrollLeft; + } + } + return { isScrolling: true, scrollDirection: prevState.scrollOffset < scrollLeft ? 'forward' : 'backward', - scrollOffset: scrollLeft, + scrollOffset, scrollUpdateWasRequested: false, }; }, this._resetIsScrollingDebounced); @@ -532,28 +573,66 @@ export default function createListComponent({ // I assume people already do this (render function returning a class component), // So my doing it would just unnecessarily double the wrappers. -const validateSharedProps = ({ - children, - direction, - height, - innerTagName, - outerTagName, - width, -}: Props): void => { +const validateSharedProps = ( + { + children, + direction, + height, + layout, + innerTagName, + outerTagName, + width, + }: Props, + { instance }: State +): void => { if (process.env.NODE_ENV !== 'production') { if (innerTagName != null || outerTagName != null) { - console.warn( - 'The innerTagName and outerTagName props have been deprecated. ' + - 'Please use the innerElementType and outerElementType props instead.' - ); + if (!((devWarningsTagName: any): WeakSet).has(instance)) { + ((devWarningsTagName: any): WeakSet).add(instance); + console.warn( + 'The innerTagName and outerTagName props have been deprecated. ' + + 'Please use the innerElementType and outerElementType props instead.' + ); + } } - if (direction !== 'horizontal' && direction !== 'vertical') { - throw Error( - 'An invalid "direction" prop has been specified. ' + - 'Value should be either "horizontal" or "vertical". ' + - `"${direction}" was specified.` - ); + // TODO Deprecate direction "horizontal" + const isHorizontal = direction === 'horizontal' || layout === 'horizontal'; + + switch (direction) { + case 'horizontal': + case 'vertical': + if (!((devWarningsDirection: any): WeakSet).has(instance)) { + ((devWarningsDirection: any): WeakSet).add(instance); + console.warn( + 'The direction prop should be either "ltr" (default) or "rtl". ' + + 'Please use the layout prop to specify "vertical" (default) or "horizontal" orientation.' + ); + } + break; + case 'ltr': + case 'rtl': + // Valid values + break; + default: + throw Error( + 'An invalid "direction" prop has been specified. ' + + 'Value should be either "ltr" or "rtl". ' + + `"${direction}" was specified.` + ); + } + + switch (layout) { + case 'horizontal': + case 'vertical': + // Valid values + break; + default: + throw Error( + 'An invalid "layout" prop has been specified. ' + + 'Value should be either "horizontal" or "vertical". ' + + `"${layout}" was specified.` + ); } if (children == null) { @@ -564,13 +643,13 @@ const validateSharedProps = ({ ); } - if (direction === 'horizontal' && typeof width !== 'number') { + if (isHorizontal && typeof width !== 'number') { throw Error( 'An invalid "width" prop has been specified. ' + 'Horizontal lists must specify a number for width. ' + `"${width === null ? 'null' : typeof width}" was specified.` ); - } else if (direction === 'vertical' && typeof height !== 'number') { + } else if (!isHorizontal && typeof height !== 'number') { throw Error( 'An invalid "height" prop has been specified. ' + 'Vertical lists must specify a number for height. ' + diff --git a/src/domHelpers.js b/src/domHelpers.js new file mode 100644 index 00000000..830aba68 --- /dev/null +++ b/src/domHelpers.js @@ -0,0 +1,22 @@ +// @flow + +let size: number = -1; + +// This utility copied from "dom-helpers" package. +export function getScrollbarSize(recalculate?: boolean = false): number { + if (size === -1 || recalculate) { + const div = document.createElement('div'); + const style = div.style; + style.width = '50px'; + style.height = '50px'; + style.overflow = 'scroll'; + + ((document.body: any): HTMLBodyElement).appendChild(div); + + size = div.offsetWidth - div.clientWidth; + + ((document.body: any): HTMLBodyElement).removeChild(div); + } + + return size; +} diff --git a/website/sandboxes/fixed-size-list-horizontal/index.js b/website/sandboxes/fixed-size-list-horizontal/index.js index bbf45a4e..4c5ae27e 100644 --- a/website/sandboxes/fixed-size-list-horizontal/index.js +++ b/website/sandboxes/fixed-size-list-horizontal/index.js @@ -13,10 +13,10 @@ const Column = ({ index, style }) => ( const Example = () => ( {Column} diff --git a/website/sandboxes/memoized-list-items/package.json b/website/sandboxes/memoized-list-items/package.json index 1d2a045c..6110232c 100644 --- a/website/sandboxes/memoized-list-items/package.json +++ b/website/sandboxes/memoized-list-items/package.json @@ -2,7 +2,7 @@ "description": "Demo of react-window with advanced memoization techniques", "main": "src/index.js", "dependencies": { - "memoize-one": "^4", + "memoize-one": "^5", "react": "^16.6", "react-dom": "^16.6", "react-window": "^1" diff --git a/website/sandboxes/variable-size-list-horizontal/index.js b/website/sandboxes/variable-size-list-horizontal/index.js index 89f1d54f..cc69d7d1 100644 --- a/website/sandboxes/variable-size-list-horizontal/index.js +++ b/website/sandboxes/variable-size-list-horizontal/index.js @@ -21,10 +21,10 @@ const Column = ({ index, style }) => ( const Example = () => ( {Column} diff --git a/website/src/App.js b/website/src/App.js index d24571cc..3a7ff7fb 100644 --- a/website/src/App.js +++ b/website/src/App.js @@ -14,6 +14,7 @@ import FixedSizeGridApi from './routes/api/FixedSizeGrid'; import FixedSizeListApi from './routes/api/FixedSizeList'; import FixedSizeGridExample from './routes/examples/FixedSizeGrid'; import FixedSizeListExample from './routes/examples/FixedSizeList'; +import RTLLayoutExample from './routes/examples/RTLLayout'; import ListWithScrollingIndicatorExample from './routes/examples/ListWithScrollingIndicator'; import ScrollToItemExample from './routes/examples/ScrollToItem'; import MemoizedListItemsExample from './routes/examples/MemoizedListItemsExample'; @@ -95,6 +96,11 @@ const EXAMPLE_ROUTES = [ title: 'Memoized List items', component: MemoizedListItemsExample, }, + { + path: '/examples/list/fixed-size-rtl', + title: 'RTL layout', + component: RTLLayoutExample, + }, ]; const COMPONENTS_ROUTES = [ diff --git a/website/src/code/FixedSizeGridRtl.js b/website/src/code/FixedSizeGridRtl.js new file mode 100644 index 00000000..febca6bc --- /dev/null +++ b/website/src/code/FixedSizeGridRtl.js @@ -0,0 +1,21 @@ +import { FixedSizeGrid as Grid } from 'react-window'; + +const Cell = ({ columnIndex, rowIndex, style }) => ( +
+ بند {rowIndex},{columnIndex} +
+); + +const Example = () => ( + + {Cell} + +); \ No newline at end of file diff --git a/website/src/code/FixedSizeListHorizontal.js b/website/src/code/FixedSizeListHorizontal.js index 72cdefd1..654196df 100644 --- a/website/src/code/FixedSizeListHorizontal.js +++ b/website/src/code/FixedSizeListHorizontal.js @@ -6,10 +6,10 @@ const Column = ({ index, style }) => ( const Example = () => ( {Column} diff --git a/website/src/code/FixedSizeListHorizontalRtl.js b/website/src/code/FixedSizeListHorizontalRtl.js new file mode 100644 index 00000000..b43071e0 --- /dev/null +++ b/website/src/code/FixedSizeListHorizontalRtl.js @@ -0,0 +1,18 @@ +import { FixedSizeList as List } from 'react-window'; + +const Column = ({ index, style }) => ( +
عمود {index}
+); + +const Example = () => ( + + {Column} + +); diff --git a/website/src/code/VariableSizeListHorizontal.js b/website/src/code/VariableSizeListHorizontal.js index 8548dc79..654d7e17 100644 --- a/website/src/code/VariableSizeListHorizontal.js +++ b/website/src/code/VariableSizeListHorizontal.js @@ -14,10 +14,10 @@ const Column = ({ index, style }) => ( const Example = () => ( {Column} diff --git a/website/src/routes/api/FixedSizeGrid.js b/website/src/routes/api/FixedSizeGrid.js index fab2bb2c..c84555d5 100644 --- a/website/src/routes/api/FixedSizeGrid.js +++ b/website/src/routes/api/FixedSizeGrid.js @@ -74,6 +74,31 @@ const PROPS = [ name: 'columnWidth', type: 'number', }, + { + defaultValue: '"ltr"', + description: ( + +

Determines the direction of text and horizontal scrolling.

+
    +
  • ltr (default)
  • +
  • rtl
  • +
+

+ This property also automatically sets the{' '} + + CSS direction style + {' '} + for the grid component. +

+
+ ), + name: 'direction', + type: 'string', + }, { description: (

diff --git a/website/src/routes/api/FixedSizeList.js b/website/src/routes/api/FixedSizeList.js index dea1aed3..70be84ca 100644 --- a/website/src/routes/api/FixedSizeList.js +++ b/website/src/routes/api/FixedSizeList.js @@ -58,17 +58,24 @@ const PROPS = [ type: 'string', }, { - defaultValue: '"vertical"', + defaultValue: '"ltr"', description: ( -

Primary scroll direction of the list. Acceptable values are:

+

Determines the direction of text and horizontal scrolling.

    -
  • vertical (default) - Up/down scrolling.
  • -
  • horizontal - Left/right scrolling.
  • +
  • ltr (default)
  • +
  • rtl

- Note that lists may scroll in both directions (depending on CSS) but - content will only be windowed in the primary direction. + This property also automatically sets the{' '} + + CSS direction style + {' '} + for the list component.

), @@ -207,6 +214,24 @@ const PROPS = [ name: 'itemSize', type: 'number', }, + { + defaultValue: '"vertical"', + description: ( + +

Layout/orientation of the list. Acceptable values are:

+
    +
  • vertical (default) - Up/down scrolling.
  • +
  • horizontal - Left/right scrolling.
  • +
+

+ Note that lists may scroll in both directions (depending on CSS) but + content will only be windowed in the layout direction specified. +

+
+ ), + name: 'layout', + type: 'string', + }, { description: ( diff --git a/website/src/routes/examples/FixedSizeList.js b/website/src/routes/examples/FixedSizeList.js index e3cf3403..f0ed7ecd 100644 --- a/website/src/routes/examples/FixedSizeList.js +++ b/website/src/routes/examples/FixedSizeList.js @@ -54,11 +54,11 @@ export default function() { > {Item} diff --git a/website/src/routes/examples/RTLLayout.js b/website/src/routes/examples/RTLLayout.js new file mode 100644 index 00000000..26d533f0 --- /dev/null +++ b/website/src/routes/examples/RTLLayout.js @@ -0,0 +1,99 @@ +import React, { PureComponent } from 'react'; +import { FixedSizeGrid, FixedSizeList } from 'react-window'; +import CodeBlock from '../../components/CodeBlock'; +import ProfiledExample from '../../components/ProfiledExample'; + +import CODE_GRID from '../../code/FixedSizeGridRtl.js'; +import CODE_LIST from '../../code/FixedSizeListHorizontalRtl.js'; + +import styles from './shared.module.css'; + +class Cell extends PureComponent { + render() { + const { columnIndex, rowIndex, style } = this.props; + + return ( +
+ بند {rowIndex}, {columnIndex} +
+ ); + } +} + +class Item extends PureComponent { + render() { + const { index, style } = this.props; + + return ( +
+ عمود {index} +
+ ); + } +} + +export default function() { + return ( +
+

RTL List

+
+ + + {Item} + + +
+ +
+
+

RTL Grid

+
+ + + {Cell} + + +
+ +
+
+
+ ); +} diff --git a/website/src/routes/examples/VariableSizeList.js b/website/src/routes/examples/VariableSizeList.js index 51aff135..c5e4b9ef 100644 --- a/website/src/routes/examples/VariableSizeList.js +++ b/website/src/routes/examples/VariableSizeList.js @@ -58,11 +58,11 @@ export default function() { > columnSizes[index]} + layout="horizontal" width={300} > {Item} diff --git a/website/yarn.lock b/website/yarn.lock index 2435987d..17735bfe 100644 --- a/website/yarn.lock +++ b/website/yarn.lock @@ -6161,9 +6161,10 @@ mem@^1.1.0: dependencies: mimic-fn "^1.0.0" -memoize-one@^3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-3.1.1.tgz#ef609811e3bc28970eac2884eece64d167830d17" +memoize-one@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.0.0.tgz#d55007dffefb8de7546659a1722a5d42e128286e" + integrity sha512-7g0+ejkOaI9w5x6LvQwmj68kUj6rxROywPSCqmclG/HBacmFnZqhVscQ8kovkn9FBCNJmOz6SY42+jnvZzDWdw== memory-fs@^0.4.0, memory-fs@~0.4.1: version "0.4.1" diff --git a/yarn.lock b/yarn.lock index f81bc3cb..b55bb3a9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -586,10 +586,12 @@ "@types/estree@0.0.39": version "0.0.39" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f" + integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw== -"@types/node@*": - version "10.1.2" - resolved "https://registry.yarnpkg.com/@types/node/-/node-10.1.2.tgz#1b928a0baa408fc8ae3ac012cc81375addc147c6" +"@types/node@^11.9.5": + version "11.10.4" + resolved "https://registry.yarnpkg.com/@types/node/-/node-11.10.4.tgz#3f5fc4f0f322805f009e00ab35a2ff3d6b778e42" + integrity sha512-wa09itaLE8L705aXd8F80jnFpxz3Y1/KRHfKsYL2bPc0XF+wEWu8sR9n5bmeu8Ba1N9z2GRNzm/YdHcghLkLKg== abab@^1.0.3, abab@^1.0.4: version "1.0.4" @@ -638,10 +640,15 @@ acorn@^4.0.3, acorn@^4.0.4: version "4.0.13" resolved "https://registry.yarnpkg.com/acorn/-/acorn-4.0.13.tgz#105495ae5361d697bd195c825192e1ad7f253787" -acorn@^5.0.0, acorn@^5.2.1, acorn@^5.3.0, acorn@^5.5.0: +acorn@^5.0.0, acorn@^5.3.0, acorn@^5.5.0: version "5.5.3" resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.5.3.tgz#f473dd47e0277a08e28e9bec5aeeb04751f0b8c9" +acorn@^6.1.1: + version "6.1.1" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.1.1.tgz#7d25ae05bb8ad1f9b699108e1094ecd7884adc1f" + integrity sha512-jPTiwtOxaHNaAPg/dmrJ/beuzLRnXtB0kQPQ8JpotKJgTB6rX6c8mlf315941pyjBSaPg8NHXS9fhP4u17DpGA== + address@1.0.3, address@^1.0.1: version "1.0.3" resolved "https://registry.yarnpkg.com/address/-/address-1.0.3.tgz#b5f50631f8d6cec8bd20c963963afb55e06cbce9" @@ -1888,9 +1895,10 @@ builtin-modules@^1.0.0, builtin-modules@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-1.1.1.tgz#270f076c5a72c02f5b65a47df94c5fe3a278892f" -builtin-modules@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-2.0.0.tgz#60b7ef5ae6546bd7deefa74b08b62a43a232648e" +builtin-modules@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.0.0.tgz#1e587d44b006620d90286cc7a9238bbc6129cab1" + integrity sha512-hMIeU4K2ilbXV6Uv93ZZ0Avg/M91RaKXucQ+4me2Do1txxBDyDZWCBa5bJSLqoNTRpXTLwEzIk1KmloenDDjhg== builtin-status-codes@^3.0.0: version "3.0.0" @@ -3293,14 +3301,15 @@ estraverse@^4.0.0, estraverse@^4.1.0, estraverse@^4.1.1, estraverse@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.2.0.tgz#0dee3fed31fcd469618ce7342099fc1afa0bdb13" -estree-walker@^0.3.0: - version "0.3.1" - resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-0.3.1.tgz#e6b1a51cf7292524e7237c312e5fe6660c1ce1aa" - -estree-walker@^0.5.0, estree-walker@^0.5.2: +estree-walker@^0.5.2: version "0.5.2" resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-0.5.2.tgz#d3850be7529c9580d815600b53126515e146dd39" +estree-walker@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-0.6.0.tgz#5d865327c44a618dde5699f763891ae31f257dae" + integrity sha512-peq1RfVAVzr3PU/jL31RaOjUKLoZJpObQWJJ+LgfcxDUifyLZ1RjPQZTl0pzj2uJ45b7A7XpyppXvxdEqzo4rw== + esutils@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.2.tgz#0abf4f1caa5bcb1f7a9d8acc6dea4faaa04bac9b" @@ -5543,11 +5552,12 @@ macaddress@^0.2.8: version "0.2.8" resolved "https://registry.yarnpkg.com/macaddress/-/macaddress-0.2.8.tgz#5904dc537c39ec6dbefeae902327135fa8511f12" -magic-string@^0.22.4: - version "0.22.5" - resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.22.5.tgz#8e9cf5afddf44385c1da5bc2a6a0dbd10b03657e" +magic-string@^0.25.1: + version "0.25.2" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.2.tgz#139c3a729515ec55e96e69e82a11fe890a293ad9" + integrity sha512-iLs9mPjh9IuTtRsqqhNGYcZXGei0Nh/A4xirrsqW7c+QhKVFL2vm7U09ru6cHRD22azaP/wMDgI+HCqbETMTtg== dependencies: - vlq "^0.2.2" + sourcemap-codec "^1.4.4" make-dir@^1.0.0: version "1.2.0" @@ -5596,9 +5606,10 @@ mem@^1.1.0: dependencies: mimic-fn "^1.0.0" -memoize-one@^3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-3.1.1.tgz#ef609811e3bc28970eac2884eece64d167830d17" +"memoize-one@>=3.1.1 <6": + version "5.0.0" + resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.0.0.tgz#d55007dffefb8de7546659a1722a5d42e128286e" + integrity sha512-7g0+ejkOaI9w5x6LvQwmj68kUj6rxROywPSCqmclG/HBacmFnZqhVscQ8kovkn9FBCNJmOz6SY42+jnvZzDWdw== memory-fs@^0.4.0, memory-fs@~0.4.1: version "0.4.1" @@ -5652,7 +5663,7 @@ micromatch@^2.1.5, micromatch@^2.3.11: parse-glob "^3.0.4" regex-cache "^0.4.2" -micromatch@^3.1.4, micromatch@^3.1.8: +micromatch@^3.1.10, micromatch@^3.1.4, micromatch@^3.1.8: version "3.1.10" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23" dependencies: @@ -6273,6 +6284,11 @@ path-parse@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.5.tgz#3c1adf871ea9cd6c9431b6ea2bd74a0ff055c4c1" +path-parse@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.6.tgz#d62dbb5679405d72c4737ec58600e9ddcf06d24c" + integrity sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw== + path-to-regexp@0.1.7: version "0.1.7" resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" @@ -7297,7 +7313,14 @@ resolve@1.6.0: dependencies: path-parse "^1.0.5" -resolve@^1.1.6, resolve@^1.3.2, resolve@^1.3.3, resolve@^1.4.0, resolve@^1.5.0, resolve@^1.6.0: +resolve@^1.10.0: + version "1.10.0" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.10.0.tgz#3bdaaeaf45cc07f375656dfd2e54ed0810b101ba" + integrity sha512-3sUr9aq5OfSg2S9pNtPA9hL1FVEAjvfOC4leW0SNf/mpnaakz2a9femSd6LqAww2RaFctwyf1lCqnTHuF1rxDg== + dependencies: + path-parse "^1.0.6" + +resolve@^1.3.2, resolve@^1.3.3, resolve@^1.5.0, resolve@^1.6.0: version "1.7.1" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.7.1.tgz#aadd656374fd298aee895bc026b8297418677fd3" dependencies: @@ -7346,37 +7369,32 @@ ripemd160@^2.0.0, ripemd160@^2.0.1: hash-base "^3.0.0" inherits "^2.0.1" -rollup-plugin-babel@^4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/rollup-plugin-babel/-/rollup-plugin-babel-4.0.2.tgz#c073eeb0cc246324e6f6feaedbb90093841a138c" +rollup-plugin-babel@^4.3.2: + version "4.3.2" + resolved "https://registry.yarnpkg.com/rollup-plugin-babel/-/rollup-plugin-babel-4.3.2.tgz#8c0e1bd7aa9826e90769cf76895007098ffd1413" + integrity sha512-KfnizE258L/4enADKX61ozfwGHoqYauvoofghFJBhFnpH9Sb9dNPpWg8QHOaAfVASUYV8w0mCx430i9z0LJoJg== dependencies: "@babel/helper-module-imports" "^7.0.0" rollup-pluginutils "^2.3.0" -rollup-plugin-commonjs@^8.2.1: - version "8.4.1" - resolved "https://registry.yarnpkg.com/rollup-plugin-commonjs/-/rollup-plugin-commonjs-8.4.1.tgz#5c9cea2b2c3de322f5fbccd147e07ed5e502d7a0" +rollup-plugin-commonjs@^9.2.1: + version "9.2.1" + resolved "https://registry.yarnpkg.com/rollup-plugin-commonjs/-/rollup-plugin-commonjs-9.2.1.tgz#bb151ca8fa23600c7a03e25f9f0a45b1ee922dac" + integrity sha512-X0A/Cp/t+zbONFinBhiTZrfuUaVwRIp4xsbKq/2ohA2CDULa/7ONSJTelqxon+Vds2R2t2qJTqJQucKUC8GKkw== dependencies: - acorn "^5.2.1" - estree-walker "^0.5.0" - magic-string "^0.22.4" - resolve "^1.4.0" - rollup-pluginutils "^2.0.1" + estree-walker "^0.5.2" + magic-string "^0.25.1" + resolve "^1.10.0" + rollup-pluginutils "^2.3.3" -rollup-plugin-node-resolve@^3.0.2: - version "3.3.0" - resolved "https://registry.yarnpkg.com/rollup-plugin-node-resolve/-/rollup-plugin-node-resolve-3.3.0.tgz#c26d110a36812cbefa7ce117cadcd3439aa1c713" +rollup-plugin-node-resolve@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/rollup-plugin-node-resolve/-/rollup-plugin-node-resolve-4.0.1.tgz#f95765d174e5daeef9ea6268566141f53aa9d422" + integrity sha512-fSS7YDuCe0gYqKsr5OvxMloeZYUSgN43Ypi1WeRZzQcWtHgFayV5tUSPYpxuaioIIWaBXl6NrVk0T2/sKwueLg== dependencies: - builtin-modules "^2.0.0" + builtin-modules "^3.0.0" is-module "^1.0.0" - resolve "^1.1.6" - -rollup-pluginutils@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/rollup-pluginutils/-/rollup-pluginutils-2.0.1.tgz#7ec95b3573f6543a46a6461bd9a7c544525d0fc0" - dependencies: - estree-walker "^0.3.0" - micromatch "^2.3.11" + resolve "^1.10.0" rollup-pluginutils@^2.3.0: version "2.3.1" @@ -7385,12 +7403,22 @@ rollup-pluginutils@^2.3.0: estree-walker "^0.5.2" micromatch "^2.3.11" -rollup@^0.65.0: - version "0.65.0" - resolved "https://registry.yarnpkg.com/rollup/-/rollup-0.65.0.tgz#280db1252169b68fc3043028346b337dde453fba" +rollup-pluginutils@^2.3.3: + version "2.4.1" + resolved "https://registry.yarnpkg.com/rollup-pluginutils/-/rollup-pluginutils-2.4.1.tgz#de43ab54965bbf47843599a7f3adceb723de38db" + integrity sha512-wesMQ9/172IJDIW/lYWm0vW0LiKe5Ekjws481R7z9WTRtmO59cqyM/2uUlxvf6yzm/fElFmHUobeQOYz46dZJw== + dependencies: + estree-walker "^0.6.0" + micromatch "^3.1.10" + +rollup@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-1.4.1.tgz#cc03ef6fb49dd72a878e3da0131c0a3696de14a7" + integrity sha512-YWf5zeR6SWtqZmCnuYs4a+ZJetj8NT4yfBMPXekWHW4L3144jM+J2AWagQVejB0FwCqjEUP9l8o4hg1rPDfQlg== dependencies: "@types/estree" "0.0.39" - "@types/node" "*" + "@types/node" "^11.9.5" + acorn "^6.1.1" run-async@^2.2.0: version "2.3.0" @@ -7717,6 +7745,11 @@ source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" +sourcemap-codec@^1.4.4: + version "1.4.4" + resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.4.tgz#c63ea927c029dd6bd9a2b7fa03b3fec02ad56e9f" + integrity sha512-CYAPYdBu34781kLHkaW3m6b/uUSyMOC2R61gcYMWooeuaGtjof86ZA/8T+qVPPt7np1085CR9hmMGrySwEc8Xg== + spdx-correct@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.0.0.tgz#05a5b4d7153a195bc92c3c425b69f3b2a9524c82" @@ -8423,10 +8456,6 @@ verror@1.10.0: core-util-is "1.0.2" extsprintf "^1.2.0" -vlq@^0.2.2: - version "0.2.3" - resolved "https://registry.yarnpkg.com/vlq/-/vlq-0.2.3.tgz#8f3e4328cf63b1540c0d67e1b2778386f8975b26" - vm-browserify@0.0.4: version "0.0.4" resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-0.0.4.tgz#5d7ea45bbef9e4a6ff65f95438e0a87c357d5a73"