Skip to content

Commit 168ca5a

Browse files
authored
Migrate React Spectrum to new overlay hooks (#3677)
1 parent d264e8e commit 168ca5a

File tree

30 files changed

+519
-599
lines changed

30 files changed

+519
-599
lines changed

packages/@adobe/spectrum-css-temp/components/underlay/index.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ governing permissions and limitations under the License.
3737
/* Exit animations */
3838
transition: opacity var(--spectrum-dialog-background-exit-animation-duration) var(--spectrum-dialog-background-exit-animation-ease) var(--spectrum-dialog-background-exit-animation-delay),
3939
visibility 0ms linear calc(var(--spectrum-dialog-background-exit-animation-delay) + var(--spectrum-dialog-background-exit-animation-duration));
40+
41+
&.spectrum-Underlay--transparent {
42+
transition: none;
43+
background: none;
44+
}
4045
}
4146

4247
.spectrum-Underlay.is-open {

packages/@react-aria/focus/src/FocusScope.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -470,7 +470,7 @@ function shouldRestoreFocus(scopeRef: ScopeRef) {
470470
scope = scope.parent;
471471
}
472472

473-
return true;
473+
return scope?.scopeRef === scopeRef;
474474
}
475475

476476
function useRestoreFocus(scopeRef: RefObject<Element[]>, restoreFocus: boolean, contain: boolean) {

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

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

1313
import {FocusScope} from '@react-aria/focus';
14-
import React, {ReactNode, useContext, useState} from 'react';
14+
import React, {ReactNode, useContext, useMemo, useState} from 'react';
1515
import ReactDOM from 'react-dom';
1616
import {useIsSSR} from '@react-aria/ssr';
1717
import {useLayoutEffect} from '@react-aria/utils';
@@ -26,7 +26,7 @@ export interface OverlayProps {
2626
children: ReactNode
2727
}
2828

29-
const OverlayContext = React.createContext(null);
29+
export const OverlayContext = React.createContext(null);
3030

3131
/**
3232
* A container which renders an overlay such as a popover or modal in a portal,
@@ -36,13 +36,14 @@ export function Overlay(props: OverlayProps) {
3636
let isSSR = useIsSSR();
3737
let {portalContainer = isSSR ? null : document.body} = props;
3838
let [contain, setContain] = useState(false);
39+
let contextValue = useMemo(() => ({contain, setContain}), [contain, setContain]);
3940

4041
if (!portalContainer) {
4142
return null;
4243
}
4344

4445
let contents = (
45-
<OverlayContext.Provider value={setContain}>
46+
<OverlayContext.Provider value={contextValue}>
4647
<FocusScope restoreFocus contain={contain}>
4748
{props.children}
4849
</FocusScope>
@@ -54,7 +55,8 @@ export function Overlay(props: OverlayProps) {
5455

5556
/** @private */
5657
export function useOverlayFocusContain() {
57-
let setContain = useContext(OverlayContext);
58+
let ctx = useContext(OverlayContext);
59+
let setContain = ctx?.setContain;
5860
useLayoutEffect(() => {
5961
setContain?.(true);
6062
}, [setContain]);

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
// Keeps a ref count of all hidden elements. Added to when hiding an element, and
1414
// subtracted from when showing it again. When it reaches zero, aria-hidden is removed.
1515
let refCountMap = new WeakMap<Element, number>();
16+
let observerStack = [];
1617

1718
/**
1819
* Hides all elements in the DOM outside the given targets from screen readers using aria-hidden,
@@ -73,6 +74,12 @@ export function ariaHideOutside(targets: Element[], root = document.body) {
7374
refCountMap.set(node, refCount + 1);
7475
};
7576

77+
// If there is already a MutationObserver listening from a previous call,
78+
// disconnect it so the new on takes over.
79+
if (observerStack.length) {
80+
observerStack[observerStack.length - 1].disconnect();
81+
}
82+
7683
let node = walker.nextNode() as Element;
7784
while (node != null) {
7885
hide(node);
@@ -101,6 +108,17 @@ export function ariaHideOutside(targets: Element[], root = document.body) {
101108

102109
observer.observe(root, {childList: true, subtree: true});
103110

111+
let observerWrapper = {
112+
observe() {
113+
observer.observe(root, {childList: true, subtree: true});
114+
},
115+
disconnect() {
116+
observer.disconnect();
117+
}
118+
};
119+
120+
observerStack.push(observerWrapper);
121+
104122
return () => {
105123
observer.disconnect();
106124

@@ -113,5 +131,15 @@ export function ariaHideOutside(targets: Element[], root = document.body) {
113131
refCountMap.set(node, count - 1);
114132
}
115133
}
134+
135+
// Remove this observer from the stack, and start the previous one.
136+
if (observerWrapper === observerStack[observerStack.length - 1]) {
137+
observerStack.pop();
138+
if (observerStack.length) {
139+
observerStack[observerStack.length - 1].observe();
140+
}
141+
} else {
142+
observerStack.splice(observerStack.indexOf(observerWrapper), 1);
143+
}
116144
};
117145
}

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {DOMAttributes} from '@react-types/shared';
1515
import {Placement, PlacementAxis, PositionProps} from '@react-types/overlays';
1616
import {RefObject, useCallback, useRef, useState} from 'react';
1717
import {useCloseOnScroll} from './useCloseOnScroll';
18-
import {useLayoutEffect} from '@react-aria/utils';
18+
import {useLayoutEffect, useResizeObserver} from '@react-aria/utils';
1919
import {useLocale} from '@react-aria/i18n';
2020

2121
export interface AriaPositionProps extends PositionProps {
@@ -139,6 +139,12 @@ export function useOverlayPosition(props: AriaPositionProps): PositionAria {
139139
// Update position on window resize
140140
useResize(updatePosition);
141141

142+
// Update position when the overlay changes size (might need to flip).
143+
useResizeObserver({
144+
ref: overlayRef,
145+
onResize: updatePosition
146+
});
147+
142148
// Reposition the overlay and do not close on scroll while the visual viewport is resizing.
143149
// This will ensure that overlays adjust their positioning when the iOS virtual keyboard appears.
144150
let isResizing = useRef(false);

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

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,17 @@
1111
*/
1212

1313
import {ariaHideOutside} from './ariaHideOutside';
14+
import {AriaPositionProps, useOverlayPosition} from './useOverlayPosition';
1415
import {DOMAttributes} from '@react-types/shared';
15-
import {mergeProps} from '@react-aria/utils';
16+
import {mergeProps, useLayoutEffect} from '@react-aria/utils';
17+
import {OverlayContext} from './Overlay';
1618
import {OverlayTriggerState} from '@react-stately/overlays';
17-
import {PositionProps} from '@react-types/overlays';
18-
import {RefObject, useEffect} from 'react';
19+
import {PlacementAxis} from '@react-types/overlays';
20+
import {RefObject, useContext} from 'react';
1921
import {useOverlay} from './useOverlay';
20-
import {useOverlayPosition} from './useOverlayPosition';
22+
import {usePreventScroll} from './usePreventScroll';
2123

22-
export interface AriaPopoverProps extends Omit<PositionProps, 'isOpen'> {
24+
export interface AriaPopoverProps extends Omit<AriaPositionProps, 'isOpen' | 'onClose' | 'targetRef' | 'overlayRef'> {
2325
/**
2426
* The ref for the element which the popover positions itself with respect to.
2527
*/
@@ -36,14 +38,23 @@ export interface AriaPopoverProps extends Omit<PositionProps, 'isOpen'> {
3638
* reader experience. Only use with components such as combobox, which are designed
3739
* to handle this situation carefully.
3840
*/
39-
isNonModal?: boolean
41+
isNonModal?: boolean,
42+
/**
43+
* Whether pressing the escape key to close the popover should be disabled.
44+
* @default false
45+
*/
46+
isKeyboardDismissDisabled?: boolean
4047
}
4148

4249
export interface PopoverAria {
4350
/** Props for the popover element. */
4451
popoverProps: DOMAttributes,
4552
/** Props for the popover tip arrow if any. */
46-
arrowProps: DOMAttributes
53+
arrowProps: DOMAttributes,
54+
/** Props to apply to the underlay element, if any. */
55+
underlayProps: DOMAttributes,
56+
/** Placement of the popover with respect to the trigger. */
57+
placement: PlacementAxis
4758
}
4859

4960
/**
@@ -55,34 +66,45 @@ export function usePopover(props: AriaPopoverProps, state: OverlayTriggerState):
5566
triggerRef,
5667
popoverRef,
5768
isNonModal,
69+
isKeyboardDismissDisabled,
5870
...otherProps
5971
} = props;
6072

61-
let {overlayProps} = useOverlay(
73+
let ctx = useContext(OverlayContext);
74+
let {overlayProps, underlayProps} = useOverlay(
6275
{
6376
isOpen: state.isOpen,
6477
onClose: state.close,
65-
shouldCloseOnBlur: true,
66-
isDismissable: true
78+
// Close on blur if the overlay's FocusScope does not contain focus.
79+
shouldCloseOnBlur: ctx && !ctx.contain,
80+
isDismissable: !isNonModal,
81+
isKeyboardDismissDisabled
6782
},
6883
popoverRef
6984
);
7085

71-
let {overlayProps: positionProps, arrowProps} = useOverlayPosition({
86+
let {overlayProps: positionProps, arrowProps, placement} = useOverlayPosition({
7287
...otherProps,
7388
targetRef: triggerRef,
7489
overlayRef: popoverRef,
7590
isOpen: state.isOpen
7691
});
92+
93+
usePreventScroll({
94+
// Delay preventing scroll until popover is positioned to avoid extra scroll padding.
95+
isDisabled: isNonModal || !placement
96+
});
7797

78-
useEffect(() => {
79-
if (state.isOpen && !isNonModal) {
98+
useLayoutEffect(() => {
99+
if (state.isOpen && !isNonModal && popoverRef.current) {
80100
return ariaHideOutside([popoverRef.current]);
81101
}
82102
}, [isNonModal, state.isOpen, popoverRef]);
83103

84104
return {
85105
popoverProps: mergeProps(overlayProps, positionProps),
86-
arrowProps
106+
arrowProps,
107+
underlayProps,
108+
placement
87109
};
88110
}

packages/@react-spectrum/autocomplete/src/MobileSearchAutocomplete.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,6 @@ export const MobileSearchAutocomplete = React.forwardRef(function MobileSearchAu
8888
}
8989
};
9090

91-
let onClose = () => state.commit();
92-
9391
return (
9492
<>
9593
<Field
@@ -112,10 +110,10 @@ export const MobileSearchAutocomplete = React.forwardRef(function MobileSearchAu
112110
{state.inputValue || props.placeholder || ''}
113111
</SearchAutocompleteButton>
114112
</Field>
115-
<Tray isOpen={state.isOpen} onClose={onClose} isFixedHeight isNonModal {...overlayProps}>
113+
<Tray state={state} isFixedHeight {...overlayProps}>
116114
<SearchAutocompleteTray
117115
{...props}
118-
onClose={onClose}
116+
onClose={state.close}
119117
overlayProps={overlayProps}
120118
state={state} />
121119
</Tray>

packages/@react-spectrum/autocomplete/src/SearchAutocomplete.tsx

Lines changed: 4 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
import {AriaButtonProps} from '@react-types/button';
1313
import {classNames, useFocusableRef, useIsMobileDevice, useResizeObserver, useUnwrapDOMRef} from '@react-spectrum/utils';
1414
import {ClearButton} from '@react-spectrum/button';
15-
import {DismissButton, useOverlayPosition} from '@react-aria/overlays';
1615
import {DOMRefValue, FocusableRef} from '@react-types/shared';
1716
import {Field} from '@react-spectrum/label';
1817
import {FocusRing} from '@react-aria/focus';
@@ -21,7 +20,6 @@ import intlMessages from '../intl/*.json';
2120
import {ListBoxBase, useListBoxLayout} from '@react-spectrum/listbox';
2221
import Magnifier from '@spectrum-icons/ui/Magnifier';
2322
import {MobileSearchAutocomplete} from './MobileSearchAutocomplete';
24-
import {Placement} from '@react-types/overlays';
2523
import {Popover} from '@react-spectrum/overlays';
2624
import {ProgressCircle} from '@react-spectrum/progress';
2725
import React, {forwardRef, InputHTMLAttributes, RefObject, useCallback, useEffect, useRef, useState} from 'react';
@@ -100,16 +98,6 @@ const SearchAutocompleteBase = React.forwardRef(function SearchAutocompleteBase<
10098
state
10199
);
102100

103-
let {overlayProps, placement, updatePosition} = useOverlayPosition({
104-
targetRef: inputRef,
105-
overlayRef: unwrappedPopoverRef,
106-
scrollRef: listBoxRef,
107-
placement: `${direction} end` as Placement,
108-
shouldFlip: shouldFlip,
109-
isOpen: state.isOpen,
110-
onClose: state.close
111-
});
112-
113101
// Measure the width of the inputfield to inform the width of the menu (below).
114102
let [menuWidth, setMenuWidth] = useState(null);
115103
let {scale} = useProvider();
@@ -128,19 +116,7 @@ const SearchAutocompleteBase = React.forwardRef(function SearchAutocompleteBase<
128116

129117
useLayoutEffect(onResize, [scale, onResize]);
130118

131-
// Update position once the ListBox has rendered. This ensures that
132-
// it flips properly when it doesn't fit in the available space.
133-
// TODO: add ResizeObserver to useOverlayPosition so we don't need this.
134-
useLayoutEffect(() => {
135-
if (state.isOpen) {
136-
requestAnimationFrame(() => {
137-
updatePosition();
138-
});
139-
}
140-
}, [state.isOpen, updatePosition]);
141-
142119
let style = {
143-
...overlayProps.style,
144120
width: isQuiet ? null : menuWidth,
145121
minWidth: isQuiet ? `calc(${menuWidth}px + calc(2 * var(--spectrum-dropdown-quiet-offset)))` : menuWidth
146122
};
@@ -157,14 +133,15 @@ const SearchAutocompleteBase = React.forwardRef(function SearchAutocompleteBase<
157133
clearButtonProps={clearButtonProps} />
158134
</Field>
159135
<Popover
160-
isOpen={state.isOpen}
136+
state={state}
161137
UNSAFE_style={style}
162138
UNSAFE_className={classNames(styles, 'spectrum-InputGroup-popover', {'spectrum-InputGroup-popover--quiet': isQuiet})}
163139
ref={popoverRef}
164-
placement={placement}
140+
triggerRef={inputRef}
141+
placement={`${direction} end`}
165142
hideArrow
166143
isNonModal
167-
isDismissable={false}>
144+
shouldFlip={shouldFlip}>
168145
<ListBoxBase
169146
{...listBoxProps}
170147
ref={listBoxRef}
@@ -182,7 +159,6 @@ const SearchAutocompleteBase = React.forwardRef(function SearchAutocompleteBase<
182159
{stringFormatter.format('noResults')}
183160
</span>
184161
)} />
185-
<DismissButton onDismiss={() => state.close()} />
186162
</Popover>
187163
</>
188164
);

packages/@react-spectrum/autocomplete/test/SearchAutocomplete.test.js

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -734,11 +734,9 @@ describe('SearchAutocomplete', function () {
734734
let {getByRole} = renderSearchAutocomplete({allowsCustomValue: true});
735735
let searchAutocomplete = getByRole('combobox');
736736
// Change input value to something matching a searchAutocomplete value
737-
act(() => {
738-
searchAutocomplete.focus();
739-
fireEvent.change(searchAutocomplete, {target: {value: 'Two'}});
740-
jest.runAllTimers();
741-
});
737+
act(() => searchAutocomplete.focus());
738+
fireEvent.change(searchAutocomplete, {target: {value: 'Two'}});
739+
act(() => jest.runAllTimers());
742740

743741
let listbox = getByRole('listbox');
744742
let items = within(listbox).getAllByRole('option');
@@ -748,11 +746,9 @@ describe('SearchAutocomplete', function () {
748746
expect(items[0].textContent).toBe('Two');
749747

750748
// Change input text to something that doesn't match any searchAutocomplete items but still shows the menu
751-
act(() => {
752-
searchAutocomplete.focus();
753-
fireEvent.change(searchAutocomplete, {target: {value: 'Tw'}});
754-
jest.runAllTimers();
755-
});
749+
act(() => searchAutocomplete.focus());
750+
fireEvent.change(searchAutocomplete, {target: {value: 'Tw'}});
751+
act(() => jest.runAllTimers());
756752

757753
// check that no item is focused in the menu
758754
listbox = getByRole('listbox');
@@ -1399,10 +1395,8 @@ describe('SearchAutocomplete', function () {
13991395

14001396
expect(queryByRole('progressbar')).toBeNull();
14011397

1402-
act(() => {
1403-
typeText(searchAutocomplete, 'o');
1404-
jest.runAllTimers();
1405-
});
1398+
typeText(searchAutocomplete, 'o');
1399+
act(() => jest.runAllTimers());
14061400

14071401
let listbox = getByRole('listbox');
14081402
expect(listbox).toBeVisible();

0 commit comments

Comments
 (0)