diff --git a/src/createGridComponent.js b/src/createGridComponent.js index ddb25f8..0943bbd 100644 --- a/src/createGridComponent.js +++ b/src/createGridComponent.js @@ -3,7 +3,7 @@ import memoizeOne from 'memoize-one'; import { createElement, PureComponent } from 'react'; import { cancelTimeout, requestTimeout } from './timer'; -import { getScrollbarSize } from './domHelpers'; +import { getScrollbarSize, isRTLOffsetNegative } from './domHelpers'; import type { TimeoutID } from './timer'; @@ -86,7 +86,6 @@ type State = {| isScrolling: boolean, horizontalScrollDirection: ScrollDirection, scrollLeft: number, - scrollLeftRTLNonStandard: boolean | null, scrollTop: number, scrollUpdateWasRequested: boolean, verticalScrollDirection: ScrollDirection, @@ -195,7 +194,6 @@ export default function createGridComponent({ typeof this.props.initialScrollLeft === 'number' ? this.props.initialScrollLeft : 0, - scrollLeftRTLNonStandard: null, scrollTop: typeof this.props.initialScrollTop === 'number' ? this.props.initialScrollTop @@ -326,21 +324,39 @@ export default function createGridComponent({ componentDidMount() { const { initialScrollLeft, initialScrollTop } = this.props; - if (typeof initialScrollLeft === 'number' && this._outerRef != null) { - ((this._outerRef: any): HTMLDivElement).scrollLeft = initialScrollLeft; - } - if (typeof initialScrollTop === 'number' && this._outerRef != null) { - ((this._outerRef: any): HTMLDivElement).scrollTop = initialScrollTop; + + if (this._outerRef != null) { + const outerRef = ((this._outerRef: any): HTMLElement); + if (typeof initialScrollLeft === 'number') { + outerRef.scrollLeft = initialScrollLeft; + } + if (typeof initialScrollTop === 'number') { + outerRef.scrollTop = initialScrollTop; + } } this._callPropsCallbacks(); } componentDidUpdate() { + const { direction } = this.props; const { scrollLeft, scrollTop, scrollUpdateWasRequested } = this.state; - if (scrollUpdateWasRequested && this._outerRef !== null) { - ((this._outerRef: any): HTMLDivElement).scrollLeft = scrollLeft; - ((this._outerRef: any): HTMLDivElement).scrollTop = scrollTop; + + if (scrollUpdateWasRequested && this._outerRef != null) { + const outerRef = ((this._outerRef: any): HTMLElement); + if (direction === 'rtl') { + const isNegative = isRTLOffsetNegative(); + if (isNegative) { + outerRef.scrollLeft = -scrollLeft; + } else { + const { clientWidth, scrollWidth } = outerRef; + outerRef.scrollLeft = scrollWidth - clientWidth - scrollLeft; + } + } else { + outerRef.scrollLeft = Math.max(0, scrollLeft); + } + + outerRef.scrollTop = Math.max(0, scrollTop); } this._callPropsCallbacks(); @@ -707,22 +723,17 @@ export default function createGridComponent({ const { direction } = this.props; - let scrollLeftRTLNonStandard = prevState.scrollLeftRTLNonStandard; - - // TRICKY According to the spec, scrollLeft should be negative for RTL aligned elements. - // Chrome does not seem to adhere; its scrollLeft 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') { - // TRICKY It's important that we only set this value once; iOS elastic bounce can calse false positives. - if (scrollLeftRTLNonStandard === null && scrollLeft !== 0) { - scrollLeftRTLNonStandard = scrollLeft > 0; - } + const isNegative = isRTLOffsetNegative(); - if (scrollLeftRTLNonStandard) { - calculatedScrollLeft = scrollWidth - clientWidth - scrollLeft; - } else { + // TRICKY According to the spec, scrollLeft should be negative for RTL aligned elements. + // Chrome does not seem to adhere; its scrollLeft values are positive (measured relative to the left). + // See https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollLeft + if (isNegative) { calculatedScrollLeft = -scrollLeft; + } else { + calculatedScrollLeft = scrollWidth - clientWidth - scrollLeft; } } @@ -741,7 +752,6 @@ export default function createGridComponent({ horizontalScrollDirection: prevState.scrollLeft < scrollLeft ? 'forward' : 'backward', scrollLeft: calculatedScrollLeft, - scrollLeftRTLNonStandard, scrollTop: calculatedScrollTop, verticalScrollDirection: prevState.scrollTop < scrollTop ? 'forward' : 'backward', diff --git a/src/createListComponent.js b/src/createListComponent.js index 43b1b5f..c94078a 100644 --- a/src/createListComponent.js +++ b/src/createListComponent.js @@ -3,6 +3,7 @@ import memoizeOne from 'memoize-one'; import { createElement, PureComponent } from 'react'; import { cancelTimeout, requestTimeout } from './timer'; +import { isRTLOffsetNegative } from './domHelpers'; import type { TimeoutID } from './timer'; @@ -68,7 +69,6 @@ type State = {| isScrolling: boolean, scrollDirection: ScrollDirection, scrollOffset: number, - scrollOffsetRTLNonStandard: boolean | null, scrollUpdateWasRequested: boolean, |}; @@ -161,7 +161,6 @@ export default function createListComponent({ typeof this.props.initialScrollOffset === 'number' ? this.props.initialScrollOffset : 0, - scrollOffsetRTLNonStandard: null, scrollUpdateWasRequested: false, }; @@ -217,14 +216,13 @@ export default function createListComponent({ componentDidMount() { const { direction, initialScrollOffset, layout } = this.props; - if (typeof initialScrollOffset === 'number' && this._outerRef !== null) { + if (typeof initialScrollOffset === 'number' && this._outerRef != null) { + const outerRef = ((this._outerRef: any): HTMLElement); // TODO Deprecate direction "horizontal" if (direction === 'horizontal' || layout === 'horizontal') { - ((this - ._outerRef: any): HTMLDivElement).scrollLeft = initialScrollOffset; + outerRef.scrollLeft = initialScrollOffset; } else { - ((this - ._outerRef: any): HTMLDivElement).scrollTop = initialScrollOffset; + outerRef.scrollTop = initialScrollOffset; } } @@ -235,12 +233,23 @@ export default function createListComponent({ const { direction, layout } = this.props; const { scrollOffset, scrollUpdateWasRequested } = this.state; - if (scrollUpdateWasRequested && this._outerRef !== null) { + if (scrollUpdateWasRequested && this._outerRef != null) { + const outerRef = ((this._outerRef: any): HTMLElement); // TODO Deprecate direction "horizontal" if (direction === 'horizontal' || layout === 'horizontal') { - ((this._outerRef: any): HTMLDivElement).scrollLeft = scrollOffset; + if (direction === 'rtl') { + const isNegative = isRTLOffsetNegative(); + if (isNegative) { + outerRef.scrollLeft = -scrollOffset; + } else { + const { clientWidth, scrollWidth } = outerRef; + outerRef.scrollLeft = scrollWidth - clientWidth - scrollOffset; + } + } else { + outerRef.scrollLeft = scrollOffset; + } } else { - ((this._outerRef: any): HTMLDivElement).scrollTop = scrollOffset; + outerRef.scrollTop = scrollOffset; } } @@ -498,22 +507,17 @@ export default function createListComponent({ const { direction } = this.props; - let scrollOffsetRTLNonStandard = prevState.scrollOffsetRTLNonStandard; - - // TRICKY According to the spec, scrollLeft should be negative for RTL aligned elements. - // Chrome does not seem to adhere; its scrollLeft 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') { - // TRICKY It's important that we only set this value once; iOS elastic bounce can calse false positives. - if (scrollOffsetRTLNonStandard === null && scrollLeft !== 0) { - scrollOffsetRTLNonStandard = scrollLeft > 0; - } + const isNegative = isRTLOffsetNegative(); - if (scrollOffsetRTLNonStandard) { - scrollOffset = scrollWidth - clientWidth - scrollLeft; + // TRICKY According to the spec, scrollLeft should be negative for RTL aligned elements. + // Chrome does not seem to adhere; its scrollLeft values are positive (measured relative to the left). + // See https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollLeft + if (isNegative) { + scrollOffset = -scrollLeft; } else { - scrollOffset = -scrollOffset; + scrollOffset = scrollWidth - clientWidth - scrollLeft; } } @@ -528,7 +532,6 @@ export default function createListComponent({ scrollDirection: prevState.scrollOffset < scrollLeft ? 'forward' : 'backward', scrollOffset, - scrollOffsetRTLNonStandard, scrollUpdateWasRequested: false, }; }, this._resetIsScrollingDebounced); diff --git a/src/domHelpers.js b/src/domHelpers.js index 830aba6..53a06cd 100644 --- a/src/domHelpers.js +++ b/src/domHelpers.js @@ -20,3 +20,40 @@ export function getScrollbarSize(recalculate?: boolean = false): number { return size; } + +let cachedRTLResult: boolean | null = null; + +// TRICKY According to the spec, scrollLeft should be negative for RTL aligned elements. +// Chrome does not seem to adhere; its scrollLeft values are positive (measured relative to the left). +// Safari's elastic bounce makes detecting this even more complicated wrt potential false positives. +// The safest way to check this is to intentionally set a negative offset, +// and then verify that the subsequent "scroll" event matches the negative offset. +// If it does not match, then we can assume a non-standard RTL scroll implementation. +export function isRTLOffsetNegative(recalculate?: boolean = false): boolean { + if (cachedRTLResult === null || recalculate) { + const outerDiv = document.createElement('div'); + const outerStyle = outerDiv.style; + outerStyle.width = '50px'; + outerStyle.height = '50px'; + outerStyle.overflow = 'scroll'; + outerStyle.direction = 'rtl'; + + const innerDiv = document.createElement('div'); + const innerStyle = innerDiv.style; + innerStyle.width = '100px'; + innerStyle.height = '100px'; + + outerDiv.appendChild(innerDiv); + + ((document.body: any): HTMLBodyElement).appendChild(outerDiv); + + outerDiv.scrollLeft = -10; + cachedRTLResult = outerDiv.scrollLeft === -10; + + ((document.body: any): HTMLBodyElement).removeChild(outerDiv); + + return cachedRTLResult; + } + + return cachedRTLResult; +} diff --git a/website/src/routes/examples/RTLLayout.js b/website/src/routes/examples/RTLLayout.js index 26d533f..942d0c5 100644 --- a/website/src/routes/examples/RTLLayout.js +++ b/website/src/routes/examples/RTLLayout.js @@ -1,4 +1,4 @@ -import React, { PureComponent } from 'react'; +import React, { PureComponent, useRef } from 'react'; import { FixedSizeGrid, FixedSizeList } from 'react-window'; import CodeBlock from '../../components/CodeBlock'; import ProfiledExample from '../../components/ProfiledExample'; @@ -47,6 +47,22 @@ class Item extends PureComponent { } export default function() { + const gridRef = useRef(); + const listRef = useRef(); + + const scrollGridTo500 = () => gridRef.current.scrollTo({ scrollLeft: 500 }); + const scrollGridTo1500 = () => gridRef.current.scrollTo({ scrollLeft: 1500 }); + const scrollGridToItem8Auto = () => + gridRef.current.scrollToItem({ columnIndex: 8 }); + const scrollGridToItem12Center = () => + gridRef.current.scrollToItem({ columnIndex: 12, align: 'center' }); + + const scrollListTo500 = () => listRef.current.scrollTo(500); + const scrollListTo1500 = () => listRef.current.scrollTo(1500); + const scrollListToItem8Auto = () => listRef.current.scrollToItem(8); + const scrollListToItem12Center = () => + listRef.current.scrollToItem(12, 'center'); + return (