Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
.DS_Store
.cache
.idea
.vscode
.package-lock.json
.parcel-cache
build-storybook.log
Expand Down
61 changes: 61 additions & 0 deletions packages/@react-aria/utils/src/getOffsetType.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/*
* Copyright 2020 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/

const cache = new WeakMap<Element, ReturnType<typeof getOffsetType>>();

// Original licensing for the following methods can be found in the
// NOTICE file in the root directory of this source tree.
// See https://github.com/bvaughn/react-window/blob/master/lib/utils/getRTLOffsetType.ts

// According to the spec, scrollLeft should be negative for RTL aligned elements.
// Chrome <= 85 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 with 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 getOffsetType(element: Element, recalculate: boolean = false): 'negative' | 'positive-descending' | 'positive-ascending' {
let offsetType = cache.get(element);

if (!offsetType || recalculate) {
let {direction, flexDirection} = getComputedStyle(element);

let axis = flexDirection.startsWith('row') ? 'scrollLeft' : 'scrollTop';

let container = document.createElement('div');
container.style.width = '50px';
container.style.height = '50px';
container.style.display = 'flex';
container.style.overflow = 'scroll';
container.style.direction = direction;
container.style.flexDirection = flexDirection;

let child = document.createElement('div');
child.style.width = '100px';
child.style.height = '100px';
child.style.flexShrink = '0';

container.appendChild(child);
document.body.appendChild(container);

if (container[axis] > 0) {
offsetType = 'positive-descending';
} else {
container[axis] = 1;
offsetType = container[axis] > 0 ? 'positive-ascending' : 'negative';
}

cache.set(element, offsetType);
document.body.removeChild(container);
}

return offsetType;
}
69 changes: 69 additions & 0 deletions packages/@react-aria/utils/src/getScrollOffset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/*
* Copyright 2020 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/

import {getOffsetType} from './getOffsetType';

function getScrollOffset(element: Element, axis: 'left' | 'top', allowOverscroll = true): number {
let size = element[axis === 'left' ? 'scrollWidth' : 'scrollHeight'];
let rect = element[axis === 'left' ? 'clientWidth' : 'clientHeight'];
let offset = element[axis === 'left' ? 'scrollLeft' : 'scrollTop'];

switch (getOffsetType(element)) {
case 'negative':
offset = Math.abs(allowOverscroll ? offset : Math.min(0, offset));
break;
case 'positive-ascending':
offset = allowOverscroll ? offset : Math.max(0, offset);
break;
case 'positive-descending':
offset = size - rect - offset;
offset = allowOverscroll ? offset : Math.max(0, offset);
break;
}

return allowOverscroll ? offset : Math.min(size - rect, offset);
}

function setScrollOffset(element: Element, axis: 'left' | 'top', offset: number): void {
let scrollWidth = element[axis === 'left' ? 'scrollWidth' : 'scrollHeight'];
let clientWidth = element[axis === 'left' ? 'clientWidth' : 'clientHeight'];

switch (getOffsetType(element)) {
case 'negative':
offset = Math.abs(offset) * -1;
break;
case 'positive-ascending':
offset = Math.abs(offset);
break;
case 'positive-descending':
offset = scrollWidth - clientWidth - Math.abs(offset);
break;
}

element.scrollLeft = offset;
}

export function getScrollLeft(element: Element, allowOverscroll = true): number {
return getScrollOffset(element, 'left', allowOverscroll);
}

export function setScrollLeft(element: Element, scrollLeft: number): void {
return setScrollOffset(element, 'left', scrollLeft);
}

export function getScrollTop(element: Element, allowOverscroll = true): number {
return getScrollOffset(element, 'top', allowOverscroll);
}

export function setScrollTop(element: Element, scrollTop: number): void {
return setScrollOffset(element, 'top', scrollTop);
}
6 changes: 6 additions & 0 deletions packages/@react-aria/utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,5 +51,11 @@ export {CLEAR_FOCUS_EVENT, FOCUS_EVENT} from './constants';
export {isCtrlKeyPressed, willOpenKeyboard} from './keyboard';
export {useEnterAnimation, useExitAnimation} from './animation';
export {isFocusable, isTabbable} from './isFocusable';
export {getOffsetType} from './getOffsetType';
export {getScrollLeft, getScrollTop, setScrollLeft, setScrollTop} from './getScrollOffset';
export {useScrollObserver} from './useScrollObserver';
export {useScrollView} from './useScrollView';

export type {ScrollObserverProps} from './useScrollObserver';
export type {ScrollViewProps, ScrollViewAria} from './useScrollView';
export type {LoadMoreSentinelProps} from './useLoadMoreSentinel';
8 changes: 8 additions & 0 deletions packages/@react-aria/utils/src/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,11 @@ export const isAndroid: () => boolean = cached(function () {
export const isFirefox: () => boolean = cached(function () {
return testUserAgent(/Firefox/i);
});

export const isReactAct: () => boolean = cached(function () {
// IS_REACT_ACT_ENVIRONMENT is used by React 18. Previous versions checked for the `jest` global.
// https://github.com/reactwg/react-18/discussions/102
return typeof global.IS_REACT_ACT_ENVIRONMENT === 'boolean'
? global.IS_REACT_ACT_ENVIRONMENT
: typeof jest !== 'undefined';
});
2 changes: 1 addition & 1 deletion packages/@react-aria/utils/src/useResizeObserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export function useResizeObserver<T extends Element>(options: useResizeObserverO
// Only call onResize from inside the effect, otherwise we'll void our assumption that
// useEffectEvents are safe to pass in.
const {ref, box, onResize} = options;
let onResizeEvent = useEffectEvent(onResize);
let onResizeEvent = useEffectEvent(() => onResize());

useEffect(() => {
let element = ref?.current;
Expand Down
186 changes: 186 additions & 0 deletions packages/@react-aria/utils/src/useScrollObserver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
/*
* Copyright 2020 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/

import {flushSync} from 'react-dom';
import {getScrollLeft, getScrollTop} from './getScrollOffset';
import {isReactAct} from './platform';
import {Rect, Size} from '@react-stately/utils';
import {RefObject, useCallback, useRef, useState} from 'react';
import {useEffectEvent} from './useEffectEvent';
import {useEvent} from './useEvent';
import {useLayoutEffect} from './useLayoutEffect';
import {useResizeObserver} from './useResizeObserver';

export interface ScrollObserverProps {
/** Handler that is called when the scroll port changes. */
onScrollPortChange?: (rect: Rect) => void,
/** Handler that is called when the visible rect changes. */
onVisibleRectChange?: (rect: Rect) => void,
/** Handler that is called when the content size changes. */
onContentSizeChange?: (size: Size) => void,
/** Handler that is called when the scroll snap target changes. */
onScrollSnapChange?: (e: Event) => void,
/** Handler that is called when a scroll snap change is pending. */
onScrollSnapChanging?: (e: Event) => void,
/** Handler that is called when the element is scrolled. */
onScroll?: (e: Event) => void,
/** Handler that is called when scrolling starts. */
onScrollStart?: () => void,
/** Handler that is called when scrolling ends. */
onScrollEnd?: () => void,
/**
* Whether the visible rect can overscroll the content size.
* @default false
*/
allowOverscroll?: boolean,
/**
* The box model to use for the size observer.
* @default 'content-box'
*/
box?: ResizeObserverBoxOptions
}

export function useScrollObserver(props: ScrollObserverProps, ref: RefObject<HTMLElement | null>): { updateSize: () => void } {
let {box = 'content-box', allowOverscroll = false, onScrollEnd, onScrollStart, onScroll, onVisibleRectChange, onScrollPortChange, onContentSizeChange} = props;

let isUpdating = useRef(false);
let [update, setUpdate] = useState({});

let state = useRef({
y: 0,
x: 0,
width: 0,
height: 0,
scrollWidth: 0,
scrollHeight: 0,
scrollEndTime: 0,
scrollPaddingTop: 0,
scrollPaddingBottom: 0,
scrollPaddingLeft: 0,
scrollPaddingRight: 0,
scrollTimeout: undefined as ReturnType<typeof setTimeout> | undefined,
isScrolling: false
}).current;

let handleSizeChange = useCallback(() => {
onContentSizeChange?.(new Size(state.scrollWidth, state.scrollHeight));
}, [state, onContentSizeChange]);

let handleRectChange = useCallback(() => {
onVisibleRectChange?.(new Rect(state.x, state.y, state.width, state.height));

let x = state.x + state.scrollPaddingLeft;
let y = state.y + state.scrollPaddingTop;
let width = state.width - state.scrollPaddingLeft - state.scrollPaddingRight;
let height = state.height - state.scrollPaddingTop - state.scrollPaddingBottom;

onScrollPortChange?.(new Rect(x, y, width, height));
}, [state, onVisibleRectChange, onScrollPortChange]);

let handleScrollEnd = useCallback(() => flushSync(() => {
state.isScrolling = false;
clearTimeout(state.scrollTimeout);
state.scrollTimeout = undefined;
onScrollEnd?.();
}), [state, onScrollEnd]);

let handleScroll = useCallback((e: Event) => flushSync(() => {
if (!e.target || e.target !== e.currentTarget) { return; }

if (!state.isScrolling) {
state.isScrolling = true;
onScrollStart?.();
}

// Pass ref.current to allow for mock proxy in tests.
state.y = getScrollTop(ref.current!, allowOverscroll);
state.x = getScrollLeft(ref.current!, allowOverscroll);

onScroll?.(e);
handleRectChange();

// So we don't constantly call clearTimeout and setTimeout,
// keep track of the current timeout time and only reschedule
// the timer when it is getting close.
if (state.scrollEndTime <= e.timeStamp + 50) {
state.scrollEndTime = e.timeStamp + 300;
clearTimeout(state.scrollTimeout);
state.scrollTimeout = setTimeout(handleScrollEnd, 300);
}
}), [ref, state, allowOverscroll, handleRectChange, handleScrollEnd, onScroll, onScrollStart]);

let updateSize = useCallback((flush = flushSync) => {
if (!ref.current || isUpdating.current) { return; }

isUpdating.current = true;

let target = ref.current;
let style = getComputedStyle(target);

state.scrollPaddingTop = parseFloat(style.scrollPaddingTop) || 0;
state.scrollPaddingBottom = parseFloat(style.scrollPaddingBottom) || 0;
state.scrollPaddingLeft = parseFloat(style.scrollPaddingLeft) || 0;
state.scrollPaddingRight = parseFloat(style.scrollPaddingRight) || 0;

if (target.scrollWidth !== state.scrollWidth || target.scrollHeight !== state.scrollHeight) {
state.scrollWidth = target.scrollWidth;
state.scrollHeight = target.scrollHeight;
flush(handleSizeChange);
}

// If the clientWidth or clientHeight changed, scrollbars appeared or disappeared as
// a result of the layout update. In this case, re-layout again to account for the
// adjusted space. In very specific cases this might result in the scrollbars disappearing
// again, resulting in extra padding. We stop after a maximum of two layout passes to avoid
// an infinite loop. This matches how browsers behave with native CSS grid layout.
if (target.clientWidth !== state.width || target.clientHeight !== state.height) {
state.width = target.clientWidth;
state.height = target.clientHeight;
flush(handleRectChange);

if (target.clientWidth !== state.width || target.clientHeight !== state.height) {
state.width = target.clientWidth;
state.height = target.clientHeight;
flush(handleRectChange);
}
}

isUpdating.current = false;
}, [ref, state, handleSizeChange, handleRectChange]);

// Attach events directly to ref so props won't need to be sent upward.
useEvent(ref, 'scroll', handleScroll);
useEvent(ref, 'scrollsnapchange', props.onScrollSnapChange);
useEvent(ref, 'scrollsnapchanging', props.onScrollSnapChanging);
useResizeObserver({ref, box, onResize: updateSize});

// React doesn't allow flushSync inside effects, so queue a microtask.
// We also need to wait until all refs are set (e.g. when passing a ref down from a parent).
// In `act` environments, update immediately so we don't need to mock timers in tests.
// We currently need to do this in a seperate render, but within the same act.
// https://github.com/adobe/react-spectrum/pull/7938#discussion_r2078228393
let queueUpdate = useCallback(() => {
if (isUpdating.current) { return; }

return isReactAct() ? setUpdate({}) : queueMicrotask(updateSize);
}, [updateSize]);

// Will only run in tests, needs to be in separate effect so it is properly run in the next render in strict mode.
// eslint-disable-next-line react-hooks/exhaustive-deps
useLayoutEffect(() => updateSize(fn => fn()), [update]);

// Fire scrollend on unmount to run cleanups
let onScrollEndEvent = useEffectEvent(onScrollEnd);
useLayoutEffect(() => () => queueMicrotask(onScrollEndEvent), []);

return {updateSize: queueUpdate};
}
Loading