@react-aria/overlays 3.11.0 → 3.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@react-aria/overlays",
3
- "version": "3.11.0",
3
+ "version": "3.12.0",
4
4
  "description": "Spectrum UI components in React",
5
5
  "license": "Apache-2.0",
6
6
  "main": "dist/main.js",
@@ -18,16 +18,16 @@
18
18
  },
19
19
  "dependencies": {
20
20
  "@babel/runtime": "^7.6.2",
21
- "@react-aria/focus": "^3.9.0",
22
- "@react-aria/i18n": "^3.6.1",
23
- "@react-aria/interactions": "^3.12.0",
24
- "@react-aria/ssr": "^3.3.0",
25
- "@react-aria/utils": "^3.14.0",
26
- "@react-aria/visually-hidden": "^3.5.0",
27
- "@react-stately/overlays": "^3.4.2",
28
- "@react-types/button": "^3.6.2",
29
- "@react-types/overlays": "^3.6.4",
30
- "@react-types/shared": "^3.15.0"
21
+ "@react-aria/focus": "^3.10.0",
22
+ "@react-aria/i18n": "^3.6.2",
23
+ "@react-aria/interactions": "^3.13.0",
24
+ "@react-aria/ssr": "^3.4.0",
25
+ "@react-aria/utils": "^3.14.1",
26
+ "@react-aria/visually-hidden": "^3.6.0",
27
+ "@react-stately/overlays": "^3.4.3",
28
+ "@react-types/button": "^3.7.0",
29
+ "@react-types/overlays": "^3.6.5",
30
+ "@react-types/shared": "^3.16.0"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0",
@@ -36,5 +36,5 @@
36
36
  "publishConfig": {
37
37
  "access": "public"
38
38
  },
39
- "gitHead": "9202ef59e8c104dd06ffe33148445ef7932a5d1b"
39
+ "gitHead": "2954307ddbefe149241685440c81f80ece6b2c83"
40
40
  }
package/src/Overlay.tsx CHANGED
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import {FocusScope} from '@react-aria/focus';
14
- import React, {ReactNode, useContext, useState} from 'react';
14
+ import React, {ReactNode, useContext, useMemo, useState} from 'react';
15
15
  import ReactDOM from 'react-dom';
16
16
  import {useIsSSR} from '@react-aria/ssr';
17
17
  import {useLayoutEffect} from '@react-aria/utils';
@@ -26,7 +26,7 @@ export interface OverlayProps {
26
26
  children: ReactNode
27
27
  }
28
28
 
29
- const OverlayContext = React.createContext(null);
29
+ export const OverlayContext = React.createContext(null);
30
30
 
31
31
  /**
32
32
  * A container which renders an overlay such as a popover or modal in a portal,
@@ -36,13 +36,14 @@ export function Overlay(props: OverlayProps) {
36
36
  let isSSR = useIsSSR();
37
37
  let {portalContainer = isSSR ? null : document.body} = props;
38
38
  let [contain, setContain] = useState(false);
39
+ let contextValue = useMemo(() => ({contain, setContain}), [contain, setContain]);
39
40
 
40
41
  if (!portalContainer) {
41
42
  return null;
42
43
  }
43
44
 
44
45
  let contents = (
45
- <OverlayContext.Provider value={setContain}>
46
+ <OverlayContext.Provider value={contextValue}>
46
47
  <FocusScope restoreFocus contain={contain}>
47
48
  {props.children}
48
49
  </FocusScope>
@@ -54,7 +55,8 @@ export function Overlay(props: OverlayProps) {
54
55
 
55
56
  /** @private */
56
57
  export function useOverlayFocusContain() {
57
- let setContain = useContext(OverlayContext);
58
+ let ctx = useContext(OverlayContext);
59
+ let setContain = ctx?.setContain;
58
60
  useLayoutEffect(() => {
59
61
  setContain?.(true);
60
62
  }, [setContain]);
@@ -13,6 +13,7 @@
13
13
  // Keeps a ref count of all hidden elements. Added to when hiding an element, and
14
14
  // subtracted from when showing it again. When it reaches zero, aria-hidden is removed.
15
15
  let refCountMap = new WeakMap<Element, number>();
16
+ let observerStack = [];
16
17
 
17
18
  /**
18
19
  * 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) {
73
74
  refCountMap.set(node, refCount + 1);
74
75
  };
75
76
 
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
+
76
83
  let node = walker.nextNode() as Element;
77
84
  while (node != null) {
78
85
  hide(node);
@@ -101,6 +108,17 @@ export function ariaHideOutside(targets: Element[], root = document.body) {
101
108
 
102
109
  observer.observe(root, {childList: true, subtree: true});
103
110
 
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
+
104
122
  return () => {
105
123
  observer.disconnect();
106
124
 
@@ -113,5 +131,15 @@ export function ariaHideOutside(targets: Element[], root = document.body) {
113
131
  refCountMap.set(node, count - 1);
114
132
  }
115
133
  }
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
+ }
116
144
  };
117
145
  }
@@ -30,7 +30,7 @@ export function useCloseOnScroll(opts: CloseOnScrollOptions) {
30
30
  let {triggerRef, isOpen, onClose} = opts;
31
31
 
32
32
  useEffect(() => {
33
- if (!isOpen) {
33
+ if (!isOpen || onClose === null) {
34
34
  return;
35
35
  }
36
36
 
package/src/useOverlay.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import {DOMAttributes} from '@react-types/shared';
14
+ import {isElementInChildOfActiveScope} from '@react-aria/focus';
14
15
  import {RefObject, SyntheticEvent, useEffect} from 'react';
15
16
  import {useFocusWithin, useInteractOutside} from '@react-aria/interactions';
16
17
 
@@ -124,6 +125,13 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject<Element>): Ov
124
125
  let {focusWithinProps} = useFocusWithin({
125
126
  isDisabled: !shouldCloseOnBlur,
126
127
  onBlurWithin: (e) => {
128
+ // If focus is moving into a child focus scope (e.g. menu inside a dialog),
129
+ // do not close the outer overlay. At this point, the active scope should
130
+ // still be the outer overlay, since blur events run before focus.
131
+ if (e.relatedTarget && isElementInChildOfActiveScope(e.relatedTarget)) {
132
+ return;
133
+ }
134
+
127
135
  if (!shouldCloseOnInteractOutside || shouldCloseOnInteractOutside(e.relatedTarget as Element)) {
128
136
  onClose();
129
137
  }
@@ -15,7 +15,7 @@ import {DOMAttributes} from '@react-types/shared';
15
15
  import {Placement, PlacementAxis, PositionProps} from '@react-types/overlays';
16
16
  import {RefObject, useCallback, useRef, useState} from 'react';
17
17
  import {useCloseOnScroll} from './useCloseOnScroll';
18
- import {useLayoutEffect} from '@react-aria/utils';
18
+ import {useLayoutEffect, useResizeObserver} from '@react-aria/utils';
19
19
  import {useLocale} from '@react-aria/i18n';
20
20
 
21
21
  export interface AriaPositionProps extends PositionProps {
@@ -129,14 +129,22 @@ export function useOverlayPosition(props: AriaPositionProps): PositionAria {
129
129
  maxHeight
130
130
  })
131
131
  );
132
+ // eslint-disable-next-line react-hooks/exhaustive-deps
132
133
  }, deps);
133
134
 
134
135
  // Update position when anything changes
136
+ // eslint-disable-next-line react-hooks/exhaustive-deps
135
137
  useLayoutEffect(updatePosition, deps);
136
138
 
137
139
  // Update position on window resize
138
140
  useResize(updatePosition);
139
141
 
142
+ // Update position when the overlay changes size (might need to flip).
143
+ useResizeObserver({
144
+ ref: overlayRef,
145
+ onResize: updatePosition
146
+ });
147
+
140
148
  // Reposition the overlay and do not close on scroll while the visual viewport is resizing.
141
149
  // This will ensure that overlays adjust their positioning when the iOS virtual keyboard appears.
142
150
  let isResizing = useRef(false);
@@ -171,7 +179,7 @@ export function useOverlayPosition(props: AriaPositionProps): PositionAria {
171
179
  useCloseOnScroll({
172
180
  triggerRef: targetRef,
173
181
  isOpen,
174
- onClose: onClose ? close : undefined
182
+ onClose: onClose && close
175
183
  });
176
184
 
177
185
  return {
package/src/usePopover.ts CHANGED
@@ -11,15 +11,16 @@
11
11
  */
12
12
 
13
13
  import {ariaHideOutside} from './ariaHideOutside';
14
+ import {AriaPositionProps, useOverlayPosition} from './useOverlayPosition';
14
15
  import {DOMAttributes} from '@react-types/shared';
15
- import {mergeProps} from '@react-aria/utils';
16
+ import {mergeProps, useLayoutEffect} from '@react-aria/utils';
16
17
  import {OverlayTriggerState} from '@react-stately/overlays';
17
- import {PositionProps} from '@react-types/overlays';
18
- import {RefObject, useEffect} from 'react';
18
+ import {PlacementAxis} from '@react-types/overlays';
19
+ import {RefObject, useState} from 'react';
19
20
  import {useOverlay} from './useOverlay';
20
- import {useOverlayPosition} from './useOverlayPosition';
21
+ import {usePreventScroll} from './usePreventScroll';
21
22
 
22
- export interface AriaPopoverProps extends Omit<PositionProps, 'isOpen'> {
23
+ export interface AriaPopoverProps extends Omit<AriaPositionProps, 'isOpen' | 'onClose' | 'targetRef' | 'overlayRef'> {
23
24
  /**
24
25
  * The ref for the element which the popover positions itself with respect to.
25
26
  */
@@ -36,14 +37,27 @@ export interface AriaPopoverProps extends Omit<PositionProps, 'isOpen'> {
36
37
  * reader experience. Only use with components such as combobox, which are designed
37
38
  * to handle this situation carefully.
38
39
  */
39
- isNonModal?: boolean
40
+ isNonModal?: boolean,
41
+ /**
42
+ * Whether pressing the escape key to close the popover should be disabled.
43
+ *
44
+ * Most popovers should not use this option. When set to true, an alternative
45
+ * way to close the popover with a keyboard must be provided.
46
+ *
47
+ * @default false
48
+ */
49
+ isKeyboardDismissDisabled?: boolean
40
50
  }
41
51
 
42
52
  export interface PopoverAria {
43
53
  /** Props for the popover element. */
44
54
  popoverProps: DOMAttributes,
45
55
  /** Props for the popover tip arrow if any. */
46
- arrowProps: DOMAttributes
56
+ arrowProps: DOMAttributes,
57
+ /** Props to apply to the underlay element, if any. */
58
+ underlayProps: DOMAttributes,
59
+ /** Placement of the popover with respect to the trigger. */
60
+ placement: PlacementAxis
47
61
  }
48
62
 
49
63
  /**
@@ -55,34 +69,53 @@ export function usePopover(props: AriaPopoverProps, state: OverlayTriggerState):
55
69
  triggerRef,
56
70
  popoverRef,
57
71
  isNonModal,
72
+ isKeyboardDismissDisabled,
58
73
  ...otherProps
59
74
  } = props;
60
75
 
61
- let {overlayProps} = useOverlay(
76
+ let {overlayProps, underlayProps} = useOverlay(
62
77
  {
63
78
  isOpen: state.isOpen,
64
79
  onClose: state.close,
65
80
  shouldCloseOnBlur: true,
66
- isDismissable: true
81
+ isDismissable: !isNonModal,
82
+ isKeyboardDismissDisabled
67
83
  },
68
84
  popoverRef
69
85
  );
70
86
 
71
- let {overlayProps: positionProps, arrowProps} = useOverlayPosition({
87
+ let {overlayProps: positionProps, arrowProps, placement} = useOverlayPosition({
72
88
  ...otherProps,
73
89
  targetRef: triggerRef,
74
90
  overlayRef: popoverRef,
75
- isOpen: state.isOpen
91
+ isOpen: state.isOpen,
92
+ onClose: null
93
+ });
94
+
95
+ // Delay preventing scroll until popover is positioned to avoid extra scroll padding.
96
+ // This requires a layout effect so that positioning has been committed to the DOM
97
+ // by the time usePreventScroll measures the element.
98
+ let [isPositioned, setPositioned] = useState(false);
99
+ useLayoutEffect(() => {
100
+ if (!isNonModal && placement) {
101
+ setPositioned(true);
102
+ }
103
+ }, [isNonModal, placement]);
104
+
105
+ usePreventScroll({
106
+ isDisabled: isNonModal || !isPositioned
76
107
  });
77
108
 
78
- useEffect(() => {
79
- if (state.isOpen && !isNonModal) {
109
+ useLayoutEffect(() => {
110
+ if (state.isOpen && !isNonModal && popoverRef.current) {
80
111
  return ariaHideOutside([popoverRef.current]);
81
112
  }
82
113
  }, [isNonModal, state.isOpen, popoverRef]);
83
114
 
84
115
  return {
85
116
  popoverProps: mergeProps(overlayProps, positionProps),
86
- arrowProps
117
+ arrowProps,
118
+ underlayProps,
119
+ placement
87
120
  };
88
121
  }