Skip to content

Commit 36c806e

Browse files
committed
Merge branch 'main' into dedupe-packages-and-fix-types
2 parents a68ec41 + b5cbf5b commit 36c806e

File tree

68 files changed

+1583
-465
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

68 files changed

+1583
-465
lines changed

eslint.config.mjs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,8 @@ export default [{
6060
"packages/dev/storybook-builder-parcel/*",
6161
"packages/dev/storybook-react-parcel/*",
6262
"packages/dev/s2-docs/pages/**",
63-
"packages/dev/mcp/*/dist"
63+
"packages/dev/mcp/*/dist",
64+
"packages/dev/codemods/src/s1-to-s2/__testfixtures__/cli/**"
6465
],
6566
}, ...compat.extends("eslint:recommended"), {
6667
plugins: {
@@ -534,4 +535,4 @@ export default [{
534535
...globals.browser
535536
}
536537
}
537-
}];
538+
}];

packages/@react-aria/combobox/src/useComboBox.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,9 @@ export function useComboBox<T, M extends SelectionMode = 'single'>(props: AriaCo
156156
break;
157157
}
158158
}
159-
state.commit();
159+
if (e.key === 'Enter' || state.isOpen) {
160+
state.commit();
161+
}
160162
break;
161163
case 'Escape':
162164
if (

packages/@react-aria/combobox/test/useComboBox.test.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,23 @@ describe('useComboBox', function () {
119119
expect(preventDefault).toHaveBeenCalledTimes(1);
120120
});
121121

122+
it('should only call commit on Tab when the menu is open', function () {
123+
let commitSpy = jest.fn();
124+
let {result: state} = renderHook((props) => useComboBoxState(props), {initialProps: props});
125+
let closedState = {...state.current, isOpen: false, commit: commitSpy};
126+
let {result: closedResult} = renderHook((props) => useComboBox(props, closedState), {initialProps: props});
127+
act(() => {
128+
closedResult.current.inputProps.onKeyDown(event({key: 'Tab'}));
129+
});
130+
expect(commitSpy).toHaveBeenCalledTimes(0);
131+
let openState = {...state.current, isOpen: true, commit: commitSpy};
132+
let {result: openResult} = renderHook((props) => useComboBox(props, openState), {initialProps: props});
133+
act(() => {
134+
openResult.current.inputProps.onKeyDown(event({key: 'Tab'}));
135+
});
136+
expect(commitSpy).toHaveBeenCalledTimes(1);
137+
});
138+
122139
it('calls open and toggle with the expected parameters when arrow down/up/trigger button is pressed', function () {
123140
let {result: state} = renderHook((props) => useComboBoxState(props), {initialProps: props});
124141
state.current.open = openSpy;

packages/@react-aria/dnd/stories/VirtualizedListBox.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ React.forwardRef(function (props: any, ref) {
174174
<Context.Provider value={{state, dropState}}>
175175
<Virtualizer
176176
{...mergeProps(collectionProps, listBoxProps)}
177+
onScroll={undefined}
177178
ref={domRef}
178179
className={classNames(dndStyles, 'droppable-collection', 'is-virtualized', {'is-drop-target': isDropTarget})}
179180
scrollDirection="vertical"

packages/@react-aria/utils/src/scrollIntoView.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
*/
1212

1313
import {getScrollParents} from './getScrollParents';
14-
import {isChrome, isIOS} from './platform';
14+
import {isIOS} from './platform';
1515

1616
interface ScrollIntoViewOpts {
1717
/** The position to align items along the block axis in. */
@@ -133,9 +133,7 @@ export function scrollIntoViewport(targetElement: Element | null, opts: ScrollIn
133133
if (targetElement && targetElement.isConnected) {
134134
let root = document.scrollingElement || document.documentElement;
135135
let isScrollPrevented = window.getComputedStyle(root).overflow === 'hidden';
136-
// If scrolling is not currently prevented then we aren't in a overlay nor is a overlay open, just use element.scrollIntoView to bring the element into view
137-
// Also ignore in chrome because of this bug: https://issues.chromium.org/issues/40074749
138-
if (!isScrollPrevented && !isChrome()) {
136+
if (!isScrollPrevented) {
139137
let {left: originalLeft, top: originalTop} = targetElement.getBoundingClientRect();
140138

141139
// use scrollIntoView({block: 'nearest'}) instead of .focus to check if the element is fully in view or not since .focus()

packages/@react-aria/virtualizer/src/ScrollView.tsx

Lines changed: 90 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@
1212

1313
// @ts-ignore
1414
import {flushSync} from 'react-dom';
15-
import {getEventTarget, useEffectEvent, useEvent, useLayoutEffect, useObjectRef, useResizeObserver} from '@react-aria/utils';
15+
import {getEventTarget, nodeContains, useEffectEvent, useLayoutEffect, useObjectRef, useResizeObserver} from '@react-aria/utils';
1616
import {getScrollLeft} from './utils';
17+
import {Point, Rect, Size} from '@react-stately/virtualizer';
1718
import React, {
1819
CSSProperties,
1920
ForwardedRef,
@@ -25,17 +26,17 @@ import React, {
2526
useRef,
2627
useState
2728
} from 'react';
28-
import {Rect, Size} from '@react-stately/virtualizer';
2929
import {useLocale} from '@react-aria/i18n';
3030

31-
interface ScrollViewProps extends HTMLAttributes<HTMLElement> {
31+
interface ScrollViewProps extends Omit<HTMLAttributes<HTMLElement>, 'onScroll'> {
3232
contentSize: Size,
3333
onVisibleRectChange: (rect: Rect) => void,
3434
children?: ReactNode,
3535
innerStyle?: CSSProperties,
3636
onScrollStart?: () => void,
3737
onScrollEnd?: () => void,
38-
scrollDirection?: 'horizontal' | 'vertical' | 'both'
38+
scrollDirection?: 'horizontal' | 'vertical' | 'both',
39+
onScroll?: (e: Event) => void
3940
}
4041

4142
function ScrollView(props: ScrollViewProps, ref: ForwardedRef<HTMLDivElement | null>) {
@@ -70,39 +71,76 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject<HTMLElement
7071
onScrollStart,
7172
onScrollEnd,
7273
scrollDirection = 'both',
74+
onScroll: onScrollProp,
7375
...otherProps
7476
} = props;
7577

7678
let state = useRef({
77-
scrollTop: 0,
78-
scrollLeft: 0,
79+
// Internal scroll position of the scroll view.
80+
scrollPosition: new Point(),
81+
// Size of the scroll view.
82+
size: new Size(),
83+
// Offset of the scroll view relative to the window viewport.
84+
viewportOffset: new Point(),
85+
// Size of the window viewport.
86+
viewportSize: new Size(),
7987
scrollEndTime: 0,
8088
scrollTimeout: null as ReturnType<typeof setTimeout> | null,
81-
width: 0,
82-
height: 0,
8389
isScrolling: false
8490
}).current;
8591
let {direction} = useLocale();
8692

93+
let updateVisibleRect = useCallback(() => {
94+
// Intersect the window viewport with the scroll view itself to find the actual visible rectangle.
95+
// This allows virtualized components to have unbounded height but still virtualize when scrolled with the page.
96+
// While there may be other scrollable elements between the <body> and the scroll view, we do not take
97+
// their sizes into account for performance reasons. Their scroll positions are accounted for in viewportOffset
98+
// though (due to getBoundingClientRect). This may result in more rows than absolutely necessary being rendered,
99+
// but no more than the entire height of the viewport which is good enough for virtualization use cases.
100+
let visibleRect = new Rect(
101+
state.viewportOffset.x + state.scrollPosition.x,
102+
state.viewportOffset.y + state.scrollPosition.y,
103+
Math.max(0, Math.min(state.size.width - state.viewportOffset.x, state.viewportSize.width)),
104+
Math.max(0, Math.min(state.size.height - state.viewportOffset.y, state.viewportSize.height))
105+
);
106+
onVisibleRectChange(visibleRect);
107+
}, [state, onVisibleRectChange]);
108+
87109
let [isScrolling, setScrolling] = useState(false);
88110

89-
let onScroll = useCallback((e) => {
90-
if (getEventTarget(e) !== e.currentTarget) {
111+
let onScroll = useCallback((e: Event) => {
112+
let target = getEventTarget(e) as Element;
113+
if (!nodeContains(target, ref.current!)) {
91114
return;
92115
}
93116

94-
if (props.onScroll) {
95-
props.onScroll(e);
117+
if (onScrollProp && target === ref.current) {
118+
onScrollProp(e);
96119
}
97120

98-
flushSync(() => {
99-
let scrollTop = e.currentTarget.scrollTop;
100-
let scrollLeft = getScrollLeft(e.currentTarget, direction);
121+
if (target !== ref.current) {
122+
// An ancestor element or the window was scrolled. Update the position of the scroll view relative to the viewport.
123+
let boundingRect = ref.current!.getBoundingClientRect();
124+
let x = boundingRect.x < 0 ? -boundingRect.x : 0;
125+
let y = boundingRect.y < 0 ? -boundingRect.y : 0;
126+
if (x === state.viewportOffset.x && y === state.viewportOffset.y) {
127+
return;
128+
}
101129

130+
state.viewportOffset = new Point(x, y);
131+
} else {
132+
// The scroll view itself was scrolled. Update the local scroll position.
102133
// Prevent rubber band scrolling from shaking when scrolling out of bounds
103-
state.scrollTop = Math.max(0, Math.min(scrollTop, contentSize.height - state.height));
104-
state.scrollLeft = Math.max(0, Math.min(scrollLeft, contentSize.width - state.width));
105-
onVisibleRectChange(new Rect(state.scrollLeft, state.scrollTop, state.width, state.height));
134+
let scrollTop = target.scrollTop;
135+
let scrollLeft = getScrollLeft(target, direction);
136+
state.scrollPosition = new Point(
137+
Math.max(0, Math.min(scrollLeft, contentSize.width - state.size.width)),
138+
Math.max(0, Math.min(scrollTop, contentSize.height - state.size.height))
139+
);
140+
}
141+
142+
flushSync(() => {
143+
updateVisibleRect();
106144

107145
if (!state.isScrolling) {
108146
state.isScrolling = true;
@@ -138,10 +176,13 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject<HTMLElement
138176
}, 300);
139177
}
140178
});
141-
}, [props, direction, state, contentSize, onVisibleRectChange, onScrollStart, onScrollEnd]);
179+
}, [onScrollProp, ref, direction, state, contentSize, updateVisibleRect, onScrollStart, onScrollEnd]);
142180

143-
// Attach event directly to ref so RAC Virtualizer doesn't need to send props upward.
144-
useEvent(ref, 'scroll', onScroll);
181+
// Attach a document-level capturing scroll listener so we can account for scrollable ancestors.
182+
useEffect(() => {
183+
document.addEventListener('scroll', onScroll, true);
184+
return () => document.removeEventListener('scroll', onScroll, true);
185+
}, [onScroll]);
145186

146187
useEffect(() => {
147188
return () => {
@@ -175,11 +216,18 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject<HTMLElement
175216
let w = isTestEnv && !isClientWidthMocked ? Infinity : clientWidth;
176217
let h = isTestEnv && !isClientHeightMocked ? Infinity : clientHeight;
177218

178-
if (state.width !== w || state.height !== h) {
179-
state.width = w;
180-
state.height = h;
219+
// Update the window viewport size.
220+
let viewportWidth = window.innerWidth;
221+
let viewportHeight = window.innerHeight;
222+
let viewportSizeChanged = state.viewportSize.width !== viewportWidth || state.viewportSize.height !== viewportHeight;
223+
if (viewportSizeChanged) {
224+
state.viewportSize = new Size(viewportWidth, viewportHeight);
225+
}
226+
227+
if (state.size.width !== w || state.size.height !== h || viewportSizeChanged) {
228+
state.size = new Size(w, h);
181229
flush(() => {
182-
onVisibleRectChange(new Rect(state.scrollLeft, state.scrollTop, w, h));
230+
updateVisibleRect();
183231
});
184232

185233
// If the clientWidth or clientHeight changed, scrollbars appeared or disappeared as
@@ -188,18 +236,30 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject<HTMLElement
188236
// again, resulting in extra padding. We stop after a maximum of two layout passes to avoid
189237
// an infinite loop. This matches how browsers behavior with native CSS grid layout.
190238
if (!isTestEnv && clientWidth !== dom.clientWidth || clientHeight !== dom.clientHeight) {
191-
state.width = dom.clientWidth;
192-
state.height = dom.clientHeight;
239+
state.size = new Size(dom.clientWidth, dom.clientHeight);
193240
flush(() => {
194-
onVisibleRectChange(new Rect(state.scrollLeft, state.scrollTop, state.width, state.height));
241+
updateVisibleRect();
195242
});
196243
}
197244
}
198245

199246
isUpdatingSize.current = false;
200-
}, [ref, state, onVisibleRectChange]);
247+
}, [ref, state, updateVisibleRect]);
201248
let updateSizeEvent = useEffectEvent(updateSize);
202249

250+
// Track the size of the entire window viewport, which is used to bound the size of the virtualizer's visible rectangle.
251+
useLayoutEffect(() => {
252+
// Initialize viewportRect before updating size for the first time.
253+
state.viewportSize = new Size(window.innerWidth, window.innerHeight);
254+
255+
let onWindowResize = () => {
256+
updateSizeEvent(flushSync);
257+
};
258+
259+
window.addEventListener('resize', onWindowResize);
260+
return () => window.removeEventListener('resize', onWindowResize);
261+
}, [state]);
262+
203263
// Update visible rect when the content size changes, in case scrollbars need to appear or disappear.
204264
let lastContentSize = useRef<Size | null>(null);
205265
let [update, setUpdate] = useState({});
@@ -250,7 +310,7 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject<HTMLElement
250310
if (scrollDirection === 'horizontal') {
251311
style.overflowX = 'auto';
252312
style.overflowY = 'hidden';
253-
} else if (scrollDirection === 'vertical' || contentSize.width === state.width) {
313+
} else if (scrollDirection === 'vertical' || contentSize.width === state.size.width) {
254314
// Set overflow-x: hidden if content size is equal to the width of the scroll view.
255315
// This prevents horizontal scrollbars from flickering during resizing due to resize observer
256316
// firing slower than the frame rate, which may cause an infinite re-render loop.

packages/@react-aria/virtualizer/src/Virtualizer.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ type RenderWrapper<T extends object, V> = (
2424
renderChildren: (views: ReusableView<T, V>[]) => ReactElement[]
2525
) => ReactElement | null;
2626

27-
interface VirtualizerProps<T extends object, V, O> extends Omit<HTMLAttributes<HTMLElement>, 'children'> {
27+
interface VirtualizerProps<T extends object, V, O> extends Omit<HTMLAttributes<HTMLElement>, 'children' | 'onScroll'> {
2828
children: (type: string, content: T) => V,
2929
renderWrapper?: RenderWrapper<T, V>,
3030
layout: Layout<T, O>,
@@ -33,7 +33,8 @@ interface VirtualizerProps<T extends object, V, O> extends Omit<HTMLAttributes<H
3333
scrollDirection?: 'horizontal' | 'vertical' | 'both',
3434
isLoading?: boolean,
3535
onLoadMore?: () => void,
36-
layoutOptions?: O
36+
layoutOptions?: O,
37+
onScroll?: (e: Event) => void
3738
}
3839

3940
// forwardRef doesn't support generic parameters, so cast the result to the correct type

packages/@react-spectrum/card/test/CardView.test.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,9 +136,13 @@ function DynamicCardView(props) {
136136
}
137137

138138
describe('CardView', function () {
139-
let user;
139+
let user, innerHeight, innerWidth;
140140
beforeAll(function () {
141141
user = userEvent.setup({delay: null, pointerMap});
142+
innerHeight = window.innerHeight;
143+
innerWidth = window.innerWidth;
144+
Object.defineProperty(window, 'innerHeight', {value: 1000, configurable: true, writable: true});
145+
Object.defineProperty(window, 'innerWidth', {value: 1000, configurable: true, writable: true});
142146
jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => mockWidth);
143147
jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => mockHeight);
144148
jest.useFakeTimers();
@@ -154,6 +158,8 @@ describe('CardView', function () {
154158
});
155159

156160
afterAll(function () {
161+
window.innerHeight = innerHeight;
162+
window.innerWidth = innerWidth;
157163
jest.restoreAllMocks();
158164
});
159165

packages/@react-spectrum/list/src/ListView.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,7 @@ export const ListView = React.forwardRef(function ListView<T extends object>(pro
223223
{...filterDOMProps(otherProps)}
224224
{...gridProps}
225225
{...styleProps}
226+
onScroll={undefined}
226227
isLoading={isLoading}
227228
onLoadMore={onLoadMore}
228229
ref={domRef}

packages/@react-spectrum/list/test/ListView.test.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,13 @@ describe('ListView', function () {
6262
manyItems.push({id: i, label: 'Foo ' + i});
6363
}
6464

65+
let innerHeight, innerWidth;
6566
beforeAll(function () {
6667
user = userEvent.setup({delay: null, pointerMap});
68+
innerHeight = window.innerHeight;
69+
innerWidth = window.innerWidth;
70+
Object.defineProperty(window, 'innerHeight', {value: 1000, configurable: true, writable: true});
71+
Object.defineProperty(window, 'innerWidth', {value: 1000, configurable: true, writable: true});
6772
offsetWidth = jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 1000);
6873
offsetHeight = jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 1000);
6974
scrollHeight = jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 40);
@@ -78,6 +83,8 @@ describe('ListView', function () {
7883
});
7984

8085
afterAll(function () {
86+
window.innerHeight = innerHeight;
87+
window.innerWidth = innerWidth;
8188
offsetWidth.mockReset();
8289
offsetHeight.mockReset();
8390
scrollHeight.mockReset();

0 commit comments

Comments
 (0)