@react-aria/overlays 3.13.0 → 3.14.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.13.0",
3
+ "version": "3.14.0",
4
4
  "description": "Spectrum UI components in React",
5
5
  "license": "Apache-2.0",
6
6
  "main": "dist/main.js",
@@ -22,16 +22,16 @@
22
22
  "url": "https://github.com/adobe/react-spectrum"
23
23
  },
24
24
  "dependencies": {
25
- "@react-aria/focus": "^3.11.0",
26
- "@react-aria/i18n": "^3.7.0",
27
- "@react-aria/interactions": "^3.14.0",
28
- "@react-aria/ssr": "^3.5.0",
29
- "@react-aria/utils": "^3.15.0",
30
- "@react-aria/visually-hidden": "^3.7.0",
31
- "@react-stately/overlays": "^3.5.0",
32
- "@react-types/button": "^3.7.1",
33
- "@react-types/overlays": "^3.7.0",
34
- "@react-types/shared": "^3.17.0",
25
+ "@react-aria/focus": "^3.12.0",
26
+ "@react-aria/i18n": "^3.7.1",
27
+ "@react-aria/interactions": "^3.15.0",
28
+ "@react-aria/ssr": "^3.6.0",
29
+ "@react-aria/utils": "^3.16.0",
30
+ "@react-aria/visually-hidden": "^3.8.0",
31
+ "@react-stately/overlays": "^3.5.1",
32
+ "@react-types/button": "^3.7.2",
33
+ "@react-types/overlays": "^3.7.1",
34
+ "@react-types/shared": "^3.18.0",
35
35
  "@swc/helpers": "^0.4.14"
36
36
  },
37
37
  "peerDependencies": {
@@ -41,5 +41,5 @@
41
41
  "publishConfig": {
42
42
  "access": "public"
43
43
  },
44
- "gitHead": "a0efee84aa178cb1a202951dfd6d8de02b292307"
44
+ "gitHead": "9d1ba9bd8ebcd63bf3495ade16d349bcb71795ce"
45
45
  }
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import {Axis, Placement, PlacementAxis, SizeAxis} from '@react-types/overlays';
14
+ import {clamp} from '@react-aria/utils';
14
15
 
15
16
  interface Position {
16
17
  top?: number,
@@ -22,6 +23,8 @@ interface Position {
22
23
  interface Dimensions {
23
24
  width: number,
24
25
  height: number,
26
+ totalWidth: number,
27
+ totalHeight: number,
25
28
  top: number,
26
29
  left: number,
27
30
  scroll: Position
@@ -44,6 +47,7 @@ interface Offset {
44
47
  }
45
48
 
46
49
  interface PositionOpts {
50
+ arrowSize: number,
47
51
  placement: Placement,
48
52
  targetNode: Element,
49
53
  overlayNode: Element,
@@ -53,7 +57,8 @@ interface PositionOpts {
53
57
  boundaryElement: Element,
54
58
  offset: number,
55
59
  crossOffset: number,
56
- maxHeight?: number
60
+ maxHeight?: number,
61
+ arrowBoundaryOffset?: number
57
62
  }
58
63
 
59
64
  export interface PositionResult {
@@ -88,19 +93,26 @@ const AXIS_SIZE = {
88
93
  left: 'width'
89
94
  };
90
95
 
96
+ const TOTAL_SIZE = {
97
+ width: 'totalWidth',
98
+ height: 'totalHeight'
99
+ };
100
+
91
101
  const PARSED_PLACEMENT_CACHE = {};
92
102
 
93
103
  // @ts-ignore
94
104
  let visualViewport = typeof window !== 'undefined' && window.visualViewport;
95
105
 
96
106
  function getContainerDimensions(containerNode: Element): Dimensions {
97
- let width = 0, height = 0, top = 0, left = 0;
107
+ let width = 0, height = 0, totalWidth = 0, totalHeight = 0, top = 0, left = 0;
98
108
  let scroll: Position = {};
99
109
 
100
110
  if (containerNode.tagName === 'BODY') {
101
111
  let documentElement = document.documentElement;
102
- width = visualViewport?.width ?? documentElement.clientWidth;
103
- height = visualViewport?.height ?? documentElement.clientHeight;
112
+ totalWidth = documentElement.clientWidth;
113
+ totalHeight = documentElement.clientHeight;
114
+ width = visualViewport?.width ?? totalWidth;
115
+ height = visualViewport?.height ?? totalHeight;
104
116
 
105
117
  scroll.top = documentElement.scrollTop || containerNode.scrollTop;
106
118
  scroll.left = documentElement.scrollLeft || containerNode.scrollLeft;
@@ -108,9 +120,11 @@ function getContainerDimensions(containerNode: Element): Dimensions {
108
120
  ({width, height, top, left} = getOffset(containerNode));
109
121
  scroll.top = containerNode.scrollTop;
110
122
  scroll.left = containerNode.scrollLeft;
123
+ totalWidth = width;
124
+ totalHeight = height;
111
125
  }
112
126
 
113
- return {width, height, scroll, top, left};
127
+ return {width, height, totalWidth, totalHeight, scroll, top, left};
114
128
  }
115
129
 
116
130
  function getScroll(node: Element): Offset {
@@ -181,7 +195,9 @@ function computePosition(
181
195
  offset: number,
182
196
  crossOffset: number,
183
197
  containerOffsetWithBoundary: Offset,
184
- isContainerPositioned: boolean
198
+ isContainerPositioned: boolean,
199
+ arrowSize: number,
200
+ arrowBoundaryOffset: number
185
201
  ) {
186
202
  let {placement, crossPlacement, axis, crossAxis, size, crossSize} = placementInfo;
187
203
  let position: Position = {};
@@ -202,13 +218,11 @@ function computePosition(
202
218
  // add the crossOffset from props
203
219
  position[crossAxis] += crossOffset;
204
220
 
205
- // this is button center position - the overlay size + half of the button to align bottom of overlay with button center
206
- let minViablePosition = childOffset[crossAxis] + (childOffset[crossSize] / 2) - overlaySize[crossSize];
207
- // this is button position of center, aligns top of overlay with button center
208
- let maxViablePosition = childOffset[crossAxis] + (childOffset[crossSize] / 2);
209
-
210
- // clamp it into the range of the min/max positions
211
- position[crossAxis] = Math.min(Math.max(minViablePosition, position[crossAxis]), maxViablePosition);
221
+ // overlay top overlapping arrow with button bottom
222
+ const minPosition = childOffset[crossAxis] - overlaySize[crossSize] + arrowSize + arrowBoundaryOffset;
223
+ // overlay bottom overlapping arrow with button top
224
+ const maxPosition = childOffset[crossAxis] + childOffset[crossSize] - arrowSize - arrowBoundaryOffset;
225
+ position[crossAxis] = clamp(position[crossAxis], minPosition, maxPosition);
212
226
 
213
227
  // Floor these so the position isn't placed on a partial pixel, only whole pixels. Shouldn't matter if it was floored or ceiled, so chose one.
214
228
  if (placement === axis) {
@@ -216,7 +230,7 @@ function computePosition(
216
230
  // height, as `bottom` will be relative to this height. But if the container is static,
217
231
  // then it can only be the `document.body`, and `bottom` will be relative to _its_
218
232
  // container, which should be as large as boundaryDimensions.
219
- const containerHeight = (isContainerPositioned ? containerOffsetWithBoundary[size] : boundaryDimensions[size]);
233
+ const containerHeight = (isContainerPositioned ? containerOffsetWithBoundary[size] : boundaryDimensions[TOTAL_SIZE[size]]);
220
234
  position[FLIPPED_DIRECTION[axis]] = Math.floor(containerHeight - childOffset[axis] + offset);
221
235
  } else {
222
236
  position[axis] = Math.floor(childOffset[axis] + childOffset[size] + offset);
@@ -277,11 +291,13 @@ export function calculatePositionInternal(
277
291
  offset: number,
278
292
  crossOffset: number,
279
293
  isContainerPositioned: boolean,
280
- userSetMaxHeight?: number
294
+ userSetMaxHeight: number | undefined,
295
+ arrowSize: number,
296
+ arrowBoundaryOffset: number
281
297
  ): PositionResult {
282
298
  let placementInfo = parsePlacement(placementInput);
283
299
  let {size, crossAxis, crossSize, placement, crossPlacement} = placementInfo;
284
- let position = computePosition(childOffset, boundaryDimensions, overlaySize, placementInfo, offset, crossOffset, containerOffsetWithBoundary, isContainerPositioned);
300
+ let position = computePosition(childOffset, boundaryDimensions, overlaySize, placementInfo, offset, crossOffset, containerOffsetWithBoundary, isContainerPositioned, arrowSize, arrowBoundaryOffset);
285
301
  let normalizedOffset = offset;
286
302
  let space = getAvailableSpace(
287
303
  boundaryDimensions,
@@ -295,7 +311,7 @@ export function calculatePositionInternal(
295
311
  // Check if the scroll size of the overlay is greater than the available space to determine if we need to flip
296
312
  if (flip && scrollSize[size] > space) {
297
313
  let flippedPlacementInfo = parsePlacement(`${FLIPPED_DIRECTION[placement]} ${crossPlacement}` as Placement);
298
- let flippedPosition = computePosition(childOffset, boundaryDimensions, overlaySize, flippedPlacementInfo, offset, crossOffset, containerOffsetWithBoundary, isContainerPositioned);
314
+ let flippedPosition = computePosition(childOffset, boundaryDimensions, overlaySize, flippedPlacementInfo, offset, crossOffset, containerOffsetWithBoundary, isContainerPositioned, arrowSize, arrowBoundaryOffset);
299
315
  let flippedSpace = getAvailableSpace(
300
316
  boundaryDimensions,
301
317
  containerOffsetWithBoundary,
@@ -331,12 +347,27 @@ export function calculatePositionInternal(
331
347
 
332
348
  overlaySize.height = Math.min(overlaySize.height, maxHeight);
333
349
 
334
- position = computePosition(childOffset, boundaryDimensions, overlaySize, placementInfo, normalizedOffset, crossOffset, containerOffsetWithBoundary, isContainerPositioned);
350
+ position = computePosition(childOffset, boundaryDimensions, overlaySize, placementInfo, normalizedOffset, crossOffset, containerOffsetWithBoundary, isContainerPositioned, arrowSize, arrowBoundaryOffset);
335
351
  delta = getDelta(crossAxis, position[crossAxis], overlaySize[crossSize], boundaryDimensions, padding);
336
352
  position[crossAxis] += delta;
337
353
 
338
354
  let arrowPosition: Position = {};
339
- arrowPosition[crossAxis] = (childOffset[crossAxis] - position[crossAxis] + childOffset[crossSize] / 2);
355
+
356
+ // All values are transformed so that 0 is at the top/left of the overlay depending on the orientation
357
+ // Prefer the arrow being in the center of the trigger/overlay anchor element
358
+ let preferredArrowPosition = childOffset[crossAxis] + .5 * childOffset[crossSize] - overlaySize[crossAxis];
359
+
360
+ // Min/Max position limits for the arrow with respect to the overlay
361
+ const arrowMinPosition = arrowSize / 2 + arrowBoundaryOffset;
362
+ const arrowMaxPosition = overlaySize[crossSize] - (arrowSize / 2) - arrowBoundaryOffset;
363
+
364
+ // Min/Max position limits for the arrow with respect to the trigger/overlay anchor element
365
+ const arrowOverlappingChildMinEdge = childOffset[crossAxis] - overlaySize[crossAxis] + (arrowSize / 2);
366
+ const arrowOverlappingChildMaxEdge = childOffset[crossAxis] + childOffset[crossSize] - overlaySize[crossAxis] - (arrowSize / 2);
367
+
368
+ // Clamp the arrow positioning so that it always is within the bounds of the anchor and the overlay
369
+ const arrowPositionOverlappingChild = clamp(preferredArrowPosition, arrowOverlappingChildMinEdge, arrowOverlappingChildMaxEdge);
370
+ arrowPosition[crossAxis] = clamp(arrowPositionOverlappingChild, arrowMinPosition, arrowMaxPosition);
340
371
 
341
372
  return {
342
373
  position,
@@ -361,16 +392,18 @@ export function calculatePosition(opts: PositionOpts): PositionResult {
361
392
  boundaryElement,
362
393
  offset,
363
394
  crossOffset,
364
- maxHeight
395
+ maxHeight,
396
+ arrowSize,
397
+ arrowBoundaryOffset = 0
365
398
  } = opts;
366
399
 
367
- let container = ((overlayNode instanceof HTMLElement && overlayNode.offsetParent) || document.body) as Element;
368
- let isBodyContainer = container.tagName === 'BODY';
400
+ let container = overlayNode instanceof HTMLElement ? getContainingBlock(overlayNode) : document.documentElement;
401
+ let isViewportContainer = container === document.documentElement;
369
402
  const containerPositionStyle = window.getComputedStyle(container).position;
370
403
  let isContainerPositioned = !!containerPositionStyle && containerPositionStyle !== 'static';
371
- let childOffset: Offset = isBodyContainer ? getOffset(targetNode) : getPosition(targetNode, container);
404
+ let childOffset: Offset = isViewportContainer ? getOffset(targetNode) : getPosition(targetNode, container);
372
405
 
373
- if (!isBodyContainer) {
406
+ if (!isViewportContainer) {
374
407
  let {marginTop, marginLeft} = window.getComputedStyle(targetNode);
375
408
  childOffset.top += parseInt(marginTop, 10) || 0;
376
409
  childOffset.left += parseInt(marginLeft, 10) || 0;
@@ -398,7 +431,9 @@ export function calculatePosition(opts: PositionOpts): PositionResult {
398
431
  offset,
399
432
  crossOffset,
400
433
  isContainerPositioned,
401
- maxHeight
434
+ maxHeight,
435
+ arrowSize,
436
+ arrowBoundaryOffset
402
437
  );
403
438
  }
404
439
 
@@ -433,3 +468,54 @@ function getPosition(node: Element, parent: Element): Offset {
433
468
  offset.left -= parseInt(style.marginLeft, 10) || 0;
434
469
  return offset;
435
470
  }
471
+
472
+ // Returns the containing block of an element, which is the element that
473
+ // this element will be positioned relative to.
474
+ // https://developer.mozilla.org/en-US/docs/Web/CSS/Containing_block
475
+ function getContainingBlock(node: HTMLElement): Element {
476
+ // The offsetParent of an element in most cases equals the containing block.
477
+ // https://w3c.github.io/csswg-drafts/cssom-view/#dom-htmlelement-offsetparent
478
+ let offsetParent = node.offsetParent;
479
+
480
+ // The offsetParent algorithm terminates at the document body,
481
+ // even if the body is not a containing block. Double check that
482
+ // and use the documentElement if so.
483
+ if (
484
+ offsetParent &&
485
+ offsetParent === document.body &&
486
+ window.getComputedStyle(offsetParent).position === 'static' &&
487
+ !isContainingBlock(offsetParent)
488
+ ) {
489
+ offsetParent = document.documentElement;
490
+ }
491
+
492
+ // TODO(later): handle table elements?
493
+
494
+ // The offsetParent can be null if the element has position: fixed, or a few other cases.
495
+ // We have to walk up the tree manually in this case because fixed positioned elements
496
+ // are still positioned relative to their containing block, which is not always the viewport.
497
+ if (offsetParent == null) {
498
+ offsetParent = node.parentElement;
499
+ while (offsetParent && !isContainingBlock(offsetParent)) {
500
+ offsetParent = offsetParent.parentElement;
501
+ }
502
+ }
503
+
504
+ // Fall back to the viewport.
505
+ return offsetParent || document.documentElement;
506
+ }
507
+
508
+ // https://developer.mozilla.org/en-US/docs/Web/CSS/Containing_block#identifying_the_containing_block
509
+ function isContainingBlock(node: Element): boolean {
510
+ let style = window.getComputedStyle(node);
511
+ return (
512
+ style.transform !== 'none' ||
513
+ /transform|perspective/.test(style.willChange) ||
514
+ style.filter !== 'none' ||
515
+ style.contain === 'paint' ||
516
+ // @ts-ignore
517
+ ('backdropFilter' in style && style.backdropFilter !== 'none') ||
518
+ // @ts-ignore
519
+ ('WebkitBackdropFilter' in style && style.WebkitBackdropFilter !== 'none')
520
+ );
521
+ }
package/src/index.ts CHANGED
@@ -28,3 +28,4 @@ export type {DismissButtonProps} from './DismissButton';
28
28
  export type {AriaPopoverProps, PopoverAria} from './usePopover';
29
29
  export type {AriaModalOverlayProps, ModalOverlayAria} from './useModalOverlay';
30
30
  export type {OverlayProps} from './Overlay';
31
+ export type {Placement, PlacementAxis, PositionProps} from '@react-types/overlays';
@@ -19,6 +19,11 @@ import {useLayoutEffect, useResizeObserver} from '@react-aria/utils';
19
19
  import {useLocale} from '@react-aria/i18n';
20
20
 
21
21
  export interface AriaPositionProps extends PositionProps {
22
+ /**
23
+ * Cross size of the overlay arrow in pixels.
24
+ * @default 0
25
+ */
26
+ arrowSize?: number,
22
27
  /**
23
28
  * Element that that serves as the positioning boundary.
24
29
  * @default document.body
@@ -48,7 +53,12 @@ export interface AriaPositionProps extends PositionProps {
48
53
  * The maxHeight specified for the overlay element.
49
54
  * By default, it will take all space up to the current viewport height.
50
55
  */
51
- maxHeight?: number
56
+ maxHeight?: number,
57
+ /**
58
+ * The minimum distance the arrow's edge should be from the edge of the overlay element.
59
+ * @default 0
60
+ */
61
+ arrowBoundaryOffset?: number
52
62
  }
53
63
 
54
64
  export interface PositionAria {
@@ -72,6 +82,7 @@ let visualViewport = typeof window !== 'undefined' && window.visualViewport;
72
82
  export function useOverlayPosition(props: AriaPositionProps): PositionAria {
73
83
  let {direction} = useLocale();
74
84
  let {
85
+ arrowSize = 0,
75
86
  targetRef,
76
87
  overlayRef,
77
88
  scrollRef = overlayRef,
@@ -84,7 +95,8 @@ export function useOverlayPosition(props: AriaPositionProps): PositionAria {
84
95
  shouldUpdatePosition = true,
85
96
  isOpen = true,
86
97
  onClose,
87
- maxHeight
98
+ maxHeight,
99
+ arrowBoundaryOffset = 0
88
100
  } = props;
89
101
  let [position, setPosition] = useState<PositionResult>({
90
102
  position: {},
@@ -107,7 +119,9 @@ export function useOverlayPosition(props: AriaPositionProps): PositionAria {
107
119
  crossOffset,
108
120
  isOpen,
109
121
  direction,
110
- maxHeight
122
+ maxHeight,
123
+ arrowBoundaryOffset,
124
+ arrowSize
111
125
  ];
112
126
 
113
127
  let updatePosition = useCallback(() => {
@@ -125,7 +139,9 @@ export function useOverlayPosition(props: AriaPositionProps): PositionAria {
125
139
  boundaryElement,
126
140
  offset,
127
141
  crossOffset,
128
- maxHeight
142
+ maxHeight,
143
+ arrowSize,
144
+ arrowBoundaryOffset
129
145
  });
130
146
 
131
147
  // Modify overlay styles directly so positioning happens immediately without the need of a second render
@@ -168,9 +184,11 @@ export function useOverlayPosition(props: AriaPositionProps): PositionAria {
168
184
  };
169
185
 
170
186
  visualViewport?.addEventListener('resize', onResize);
187
+ visualViewport?.addEventListener('scroll', onResize);
171
188
 
172
189
  return () => {
173
190
  visualViewport?.removeEventListener('resize', onResize);
191
+ visualViewport?.removeEventListener('scroll', onResize);
174
192
  };
175
193
  }, [updatePosition]);
176
194
 
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import {AriaButtonProps} from '@react-types/button';
14
- import {DOMAttributes} from '@react-types/shared';
14
+ import {DOMProps} from '@react-types/shared';
15
15
  import {onCloseMap} from './useCloseOnScroll';
16
16
  import {OverlayTriggerState} from '@react-stately/overlays';
17
17
  import {RefObject, useEffect} from 'react';
@@ -27,7 +27,7 @@ export interface OverlayTriggerAria {
27
27
  triggerProps: AriaButtonProps,
28
28
 
29
29
  /** Props for the overlay container element. */
30
- overlayProps: DOMAttributes
30
+ overlayProps: DOMProps
31
31
  }
32
32
 
33
33
  /**