Skip to content

Commit 1107831

Browse files
committed
Merge branch 'main' of github.com:adobe/react-spectrum into test_util_bug_fixes
2 parents 24805ce + 2edef89 commit 1107831

Some content is hidden

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

57 files changed

+6066
-5241
lines changed

packages/@internationalized/date/docs/CalendarDate.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ date.toString(); // '2022-02-03'
7272

7373
By default, `CalendarDate` uses the Gregorian calendar system, but many other calendar systems that are used around the world are supported, such as Hebrew, Indian, Islamic, Buddhist, Ethiopic, and more. A <TypeLink links={docs.links} type={docs.exports.Calendar} /> instance can be passed to the `CalendarDate` constructor to represent dates in that calendar system.
7474

75-
This example creates a date in the Buddhist calendar system, which is equivalent to April 4th, 2020 in the Gregorian calendar.
75+
This example creates a date in the Buddhist calendar system, which is equivalent to April 30th, 2020 in the Gregorian calendar.
7676

7777
```tsx
7878
import {BuddhistCalendar} from '@internationalized/date';
@@ -86,7 +86,7 @@ See the [Calendar](Calendar.html#implementations) docs for details about the sup
8686

8787
Many calendar systems have only one era, or a modern era and a pre-modern era (e.g. AD and BC in the Gregorian calendar). However, other calendar systems may have many eras. For example, the Japanese calendar has eras for the reign of each Emperor. `CalendarDate` represents eras using string identifiers, which can be passed as an additional parameter to the constructor before the year. When eras are present, years are numbered starting from 1 within the era.
8888

89-
This example creates a date in the Japanese calendar system, which is equivalent to April 4th, 2020 in the Gregorian calendar.
89+
This example creates a date in the Japanese calendar system, which is equivalent to April 30th, 2019 in the Gregorian calendar.
9090

9191
```tsx
9292
import {JapaneseCalendar} from '@internationalized/date';

packages/@react-aria/collections/src/Document.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -460,9 +460,13 @@ export class Document<T, C extends BaseCollection<T> = BaseCollection<T>> extend
460460
}
461461

462462
updateCollection(): void {
463-
// First, update the indices of dirty element children.
463+
// First, remove disconnected nodes and update the indices of dirty element children.
464464
for (let element of this.dirtyNodes) {
465-
element.updateChildIndices();
465+
if (element instanceof ElementNode && (!element.isConnected || element.isHidden)) {
466+
this.removeNode(element);
467+
} else {
468+
element.updateChildIndices();
469+
}
466470
}
467471

468472
// Next, update dirty collection nodes.
@@ -471,8 +475,6 @@ export class Document<T, C extends BaseCollection<T> = BaseCollection<T>> extend
471475
if (element.isConnected && !element.isHidden) {
472476
element.updateNode();
473477
this.addNode(element);
474-
} else {
475-
this.removeNode(element);
476478
}
477479

478480
element.isMutated = false;

packages/@react-aria/grid/src/useGrid.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,16 @@ export interface GridProps extends DOMProps, AriaLabelingProps {
5353
/** Handler that is called when a user performs an action on the row. */
5454
onRowAction?: (key: Key) => void,
5555
/** Handler that is called when a user performs an action on the cell. */
56-
onCellAction?: (key: Key) => void
56+
onCellAction?: (key: Key) => void,
57+
/**
58+
* Whether pressing the escape key should clear selection in the grid or not.
59+
*
60+
* Most experiences should not modify this option as it eliminates a keyboard user's ability to
61+
* easily clear selection. Only use if the escape key is being handled externally or should not
62+
* trigger selection clearing contextually.
63+
* @default 'clearSelection'
64+
*/
65+
escapeKeyBehavior?: 'clearSelection' | 'none'
5766
}
5867

5968
export interface GridAria {
@@ -77,7 +86,8 @@ export function useGrid<T>(props: GridProps, state: GridState<T, GridCollection<
7786
scrollRef,
7887
getRowText,
7988
onRowAction,
80-
onCellAction
89+
onCellAction,
90+
escapeKeyBehavior = 'clearSelection'
8191
} = props;
8292
let {selectionManager: manager} = state;
8393

@@ -106,7 +116,8 @@ export function useGrid<T>(props: GridProps, state: GridState<T, GridCollection<
106116
keyboardDelegate: delegate,
107117
isVirtualized,
108118
scrollRef,
109-
disallowTypeAhead
119+
disallowTypeAhead,
120+
escapeKeyBehavior
110121
});
111122

112123
let id = useId(props.id);

packages/@react-aria/gridlist/src/useGridList.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,16 @@ export interface AriaGridListProps<T> extends GridListProps<T>, DOMProps, AriaLa
4848
* via the left/right arrow keys or the tab key.
4949
* @default 'arrow'
5050
*/
51-
keyboardNavigationBehavior?: 'arrow' | 'tab'
51+
keyboardNavigationBehavior?: 'arrow' | 'tab',
52+
/**
53+
* Whether pressing the escape key should clear selection in the grid list or not.
54+
*
55+
* Most experiences should not modify this option as it eliminates a keyboard user's ability to
56+
* easily clear selection. Only use if the escape key is being handled externally or should not
57+
* trigger selection clearing contextually.
58+
* @default 'clearSelection'
59+
*/
60+
escapeKeyBehavior?: 'clearSelection' | 'none'
5261
}
5362

5463
export interface AriaGridListOptions<T> extends Omit<AriaGridListProps<T>, 'children'> {
@@ -105,7 +114,8 @@ export function useGridList<T>(props: AriaGridListOptions<T>, state: ListState<T
105114
onAction,
106115
disallowTypeAhead,
107116
linkBehavior = 'action',
108-
keyboardNavigationBehavior = 'arrow'
117+
keyboardNavigationBehavior = 'arrow',
118+
escapeKeyBehavior = 'clearSelection'
109119
} = props;
110120

111121
if (!props['aria-label'] && !props['aria-labelledby']) {
@@ -124,7 +134,8 @@ export function useGridList<T>(props: AriaGridListOptions<T>, state: ListState<T
124134
shouldFocusWrap: props.shouldFocusWrap,
125135
linkBehavior,
126136
disallowTypeAhead,
127-
autoFocus: props.autoFocus
137+
autoFocus: props.autoFocus,
138+
escapeKeyBehavior
128139
});
129140

130141
let id = useId(props.id);

packages/@react-aria/interactions/src/useMove.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ export function useMove(props: MoveEvents): MoveResult {
9393
state.current.didMove = false;
9494
};
9595

96-
if (typeof PointerEvent === 'undefined') {
96+
if (typeof PointerEvent === 'undefined' && process.env.NODE_ENV === 'test') {
9797
let onMouseMove = (e: MouseEvent) => {
9898
if (e.button === 0) {
9999
move(e, 'mouse', e.pageX - (state.current.lastPosition?.pageX ?? 0), e.pageY - (state.current.lastPosition?.pageY ?? 0));
@@ -151,7 +151,7 @@ export function useMove(props: MoveEvents): MoveResult {
151151
addGlobalListener(window, 'touchend', onTouchEnd, false);
152152
addGlobalListener(window, 'touchcancel', onTouchEnd, false);
153153
};
154-
} else if (process.env.NODE_ENV === 'test') {
154+
} else {
155155
let onPointerMove = (e: PointerEvent) => {
156156
if (e.pointerId === state.current.id) {
157157
let pointerType = (e.pointerType || 'mouse') as PointerType;
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
{/* Copyright 2025 Adobe. All rights reserved.
2+
This file is licensed to you under the Apache License, Version 2.0 (the "License");
3+
you may not use this file except in compliance with the License. You may obtain a copy
4+
of the License at http://www.apache.org/licenses/LICENSE-2.0
5+
Unless required by applicable law or agreed to in writing, software distributed under
6+
the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
7+
OF ANY KIND, either express or implied. See the License for the specific language
8+
governing permissions and limitations under the License. */}
9+
10+
import {Layout} from '@react-spectrum/docs';
11+
export default Layout;
12+
13+
import docs from 'docs:@react-aria/overlays';
14+
import {HeaderInfo, PropTable, FunctionAPI, PageDescription} from '@react-spectrum/docs';
15+
import packageData from '@react-aria/overlays/package.json';
16+
17+
---
18+
category: Utilities
19+
keywords: [overlays, portals]
20+
---
21+
22+
# PortalProvider
23+
24+
<PageDescription>{docs.exports.UNSAFE_PortalProvider.description}</PageDescription>
25+
26+
<HeaderInfo
27+
packageData={packageData}
28+
componentNames={['UNSAFE_PortalProvider', 'useUNSAFE_PortalContext']} />
29+
30+
## Introduction
31+
32+
`UNSAFE_PortalProvider` is a utility wrapper component that can be used to set where components like
33+
Modals, Popovers, Toasts, and Tooltips will portal their overlay element to. This is typically used when
34+
your app is already portalling other elements to a location other than the `document.body` and thus requires
35+
your React Aria components to send their overlays to the same container.
36+
37+
Please note that `UNSAFE_PortalProvider` is considered `UNSAFE` because it is an escape hatch, and there are
38+
many places that an application could portal to. Not all of them will work, either with styling, accessibility,
39+
or for a variety of other reasons. Typically, it is best to portal to the root of the entire application, e.g. the `body` element,
40+
outside of any possible overflow or stacking contexts. We envision `UNSAFE_PortalProvider` being used to group all of the portalled
41+
elements into a single container at the root of the app or to control the order of children of the `body` element, but you may have use cases
42+
that need to do otherwise.
43+
44+
## Props
45+
46+
<PropTable links={docs.links} component={docs.exports.UNSAFE_PortalProvider} />
47+
48+
## Example
49+
50+
The example below shows how you can use `UNSAFE_PortalProvider` to portal your Toasts to an arbitrary container. Note that
51+
the Toast in this example is taken directly from the [React Aria Components Toast documentation](Toast.html#example), please visit that page for
52+
a detailed explanation of its implementation.
53+
54+
```tsx import
55+
import {UNSTABLE_ToastRegion as ToastRegion, UNSTABLE_Toast as Toast, UNSTABLE_ToastQueue as ToastQueue, UNSTABLE_ToastContent as ToastContent, Button, Text} from 'react-aria-components';
56+
57+
58+
// Define the type for your toast content.
59+
interface MyToastContent {
60+
title: string,
61+
description?: string
62+
}
63+
64+
// Create a global ToastQueue.
65+
const queue = new ToastQueue<MyToastContent>();
66+
67+
function MyToastRegion() {
68+
return (
69+
<ToastRegion queue={queue}>
70+
{({toast}) => (
71+
<Toast toast={toast}>
72+
<ToastContent>
73+
<Text slot="title">{toast.content.title}</Text>
74+
<Text slot="description">{toast.content.description}</Text>
75+
</ToastContent>
76+
<Button slot="close">x</Button>
77+
</Toast>
78+
)}
79+
</ToastRegion>
80+
81+
);
82+
}
83+
```
84+
85+
```tsx example
86+
import {UNSAFE_PortalProvider} from '@react-aria/overlays';
87+
88+
// See the above Toast docs link for the ToastRegion implementation
89+
function App() {
90+
let container = React.useRef(null);
91+
return (
92+
<>
93+
<UNSAFE_PortalProvider getContainer={() => container.current}>
94+
<MyToastRegion />
95+
<Button
96+
onPress={() => queue.add({
97+
title: 'Toast complete!',
98+
description: 'Great success.'
99+
})}>
100+
Open Toast
101+
</Button>
102+
</UNSAFE_PortalProvider>
103+
<div ref={container} style={{height: '110px', width: '200px', overflow: 'auto', display: 'flex', flexDirection: 'column', gap: '20px', padding: '5px'}}>
104+
Toasts are portalled here!
105+
</div>
106+
</>
107+
);
108+
}
109+
110+
<App />
111+
```
112+
113+
```css hidden
114+
@import '../../../react-aria-components/docs/Button.mdx' layer(button);
115+
@import '../../../react-aria-components/docs/Toast.mdx' layer(toast);
116+
@import "@react-aria/example-theme";
117+
118+
.react-aria-ToastRegion {
119+
position: unset;
120+
}
121+
```
122+
123+
## Contexts
124+
125+
The `getContainer` set by the nearest PortalProvider can be accessed by calling `useUNSAFE_PortalContext`. This can be
126+
used by custom overlay components to ensure that they are also being consistently portalled throughout your app.
127+
128+
<FunctionAPI links={docs.links} function={docs.exports.useUNSAFE_PortalContext} />
129+
130+
```tsx
131+
import {useUNSAFE_PortalContext} from '@react-aria/overlays';
132+
133+
function MyOverlay(props) {
134+
let {children} = props;
135+
let {getContainer} = useUNSAFE_PortalContext();
136+
return ReactDOM.createPortal(children, getContainer());
137+
}
138+
```

packages/@react-aria/overlays/src/Overlay.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,13 @@ import React, {ReactNode, useContext, useMemo, useState} from 'react';
1616
import ReactDOM from 'react-dom';
1717
import {useIsSSR} from '@react-aria/ssr';
1818
import {useLayoutEffect} from '@react-aria/utils';
19-
import {useUNSTABLE_PortalContext} from './PortalProvider';
19+
import {useUNSAFE_PortalContext} from './PortalProvider';
2020

2121
export interface OverlayProps {
2222
/**
2323
* The container element in which the overlay portal will be placed.
2424
* @default document.body
25+
* @deprecated - Use a parent UNSAFE_PortalProvider to set your portal container instead.
2526
*/
2627
portalContainer?: Element,
2728
/** The overlay to render in the portal. */
@@ -55,8 +56,8 @@ export function Overlay(props: OverlayProps): ReactNode | null {
5556
let [contain, setContain] = useState(false);
5657
let contextValue = useMemo(() => ({contain, setContain}), [contain, setContain]);
5758

58-
let {getContainer} = useUNSTABLE_PortalContext();
59-
if (!props.portalContainer && getContainer) {
59+
let {getContainer} = useUNSAFE_PortalContext();
60+
if (!props.portalContainer && getContainer) {
6061
portalContainer = getContainer();
6162
}
6263

packages/@react-aria/overlays/src/PortalProvider.tsx

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,22 +13,29 @@
1313
import React, {createContext, ReactNode, useContext} from 'react';
1414

1515
export interface PortalProviderProps {
16-
/* Should return the element where we should portal to. Can clear the context by passing null. */
17-
getContainer?: () => HTMLElement | null
16+
/** Should return the element where we should portal to. Can clear the context by passing null. */
17+
getContainer?: () => HTMLElement | null,
18+
/** The content of the PortalProvider. Should contain all children that want to portal their overlays to the element returned by the provided `getContainer()`. */
19+
children: ReactNode
1820
}
1921

20-
export const PortalContext = createContext<PortalProviderProps>({});
22+
export interface PortalProviderContextValue extends Omit<PortalProviderProps, 'children'>{};
2123

22-
export function UNSTABLE_PortalProvider(props: PortalProviderProps & {children: ReactNode}): ReactNode {
24+
export const PortalContext = createContext<PortalProviderContextValue>({});
25+
26+
/**
27+
* Sets the portal container for all overlay elements rendered by its children.
28+
*/
29+
export function UNSAFE_PortalProvider(props: PortalProviderProps): ReactNode {
2330
let {getContainer} = props;
24-
let {getContainer: ctxGetContainer} = useUNSTABLE_PortalContext();
31+
let {getContainer: ctxGetContainer} = useUNSAFE_PortalContext();
2532
return (
2633
<PortalContext.Provider value={{getContainer: getContainer === null ? undefined : getContainer ?? ctxGetContainer}}>
2734
{props.children}
2835
</PortalContext.Provider>
2936
);
3037
}
3138

32-
export function useUNSTABLE_PortalContext(): PortalProviderProps {
39+
export function useUNSAFE_PortalContext(): PortalProviderContextValue {
3340
return useContext(PortalContext) ?? {};
3441
}

packages/@react-aria/overlays/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export {ariaHideOutside} from './ariaHideOutside';
1919
export {usePopover} from './usePopover';
2020
export {useModalOverlay} from './useModalOverlay';
2121
export {Overlay, useOverlayFocusContain} from './Overlay';
22-
export {UNSTABLE_PortalProvider, useUNSTABLE_PortalContext} from './PortalProvider';
22+
export {UNSAFE_PortalProvider, useUNSAFE_PortalContext} from './PortalProvider';
2323

2424
export type {AriaPositionProps, PositionAria} from './useOverlayPosition';
2525
export type {AriaOverlayProps, OverlayAria} from './useOverlay';
@@ -30,3 +30,4 @@ export type {AriaPopoverProps, PopoverAria} from './usePopover';
3030
export type {AriaModalOverlayProps, ModalOverlayAria} from './useModalOverlay';
3131
export type {OverlayProps} from './Overlay';
3232
export type {Placement, PlacementAxis, PositionProps} from '@react-types/overlays';
33+
export type {PortalProviderProps, PortalProviderContextValue} from './PortalProvider';

packages/@react-aria/overlays/src/useModal.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {DOMAttributes} from '@react-types/shared';
1414
import React, {AriaAttributes, ReactNode, useContext, useEffect, useMemo, useState} from 'react';
1515
import ReactDOM from 'react-dom';
1616
import {useIsSSR} from '@react-aria/ssr';
17+
import {useUNSAFE_PortalContext} from './PortalProvider';
1718

1819
export interface ModalProviderProps extends DOMAttributes {
1920
children: ReactNode
@@ -112,6 +113,7 @@ export interface OverlayContainerProps extends ModalProviderProps {
112113
/**
113114
* The container element in which the overlay portal will be placed.
114115
* @default document.body
116+
* @deprecated - Use a parent UNSAFE_PortalProvider to set your portal container instead.
115117
*/
116118
portalContainer?: Element
117119
}
@@ -126,6 +128,10 @@ export interface OverlayContainerProps extends ModalProviderProps {
126128
export function OverlayContainer(props: OverlayContainerProps): React.ReactPortal | null {
127129
let isSSR = useIsSSR();
128130
let {portalContainer = isSSR ? null : document.body, ...rest} = props;
131+
let {getContainer} = useUNSAFE_PortalContext();
132+
if (!props.portalContainer && getContainer) {
133+
portalContainer = getContainer();
134+
}
129135

130136
React.useEffect(() => {
131137
if (portalContainer?.closest('[data-overlay-container]')) {

0 commit comments

Comments
 (0)