diff --git a/docs/content/Overlay.mdx b/docs/content/Overlay.mdx
index 685db5826de..5cc607101ac 100644
--- a/docs/content/Overlay.mdx
+++ b/docs/content/Overlay.mdx
@@ -73,8 +73,8 @@ render()
| initialFocusRef | `React.RefObject` | `undefined` | Optional. Ref for the element to focus when the `Overlay` is opened. If nothing is provided, the first focusable element in the `Overlay` body is focused. |
| anchorRef | `React.RefObject` | `undefined` | Required. Element the `Overlay` should be anchored to. |
| returnFocusRef | `React.RefObject` | `undefined` | Required. Ref for the element to focus when the `Overlay` is closed. |
-| onClickOutside | `function` | `undefined` | Required. Function to call when clicking outside of the `Overlay`. Typically this function sets the `Overlay` visibility state to `false`. |
-| onEscape | `function` | `undefined` | Required. Function to call when user presses `Escape`. Typically this function sets the `Overlay` visibility state to `false`. |
+| onClickOutside | `function` | `undefined` | Required. Function to call when clicking outside of the `Overlay`. Typically this function removes the Overlay. |
+| onEscape | `function` | `undefined` | Required. Function to call when user presses `Escape`. Typically this function removes the Overlay. |
| width | `'small' │ 'medium' │ 'large' │ 'xlarge' │ 'xxlarge' │ 'auto'` | `auto` | Sets the width of the `Overlay`, pick from our set list of widths, or pass `auto` to automatically set the width based on the content of the `Overlay`. `small` corresponds to `256px`, `medium` corresponds to `320px`, `large` corresponds to `480px`, `xlarge` corresponds to `640px`, `xxlarge` corresponds to `960px`. |
| height | `'xsmall', 'small', 'medium', 'large', 'xlarge', 'auto'` | `auto` | Sets the height of the `Overlay`, pick from our set list of heights, or pass `auto` to automatically set the height based on the content of the `Overlay`. `xsmall` corresponds to `192px`, `small` corresponds to `256px`, `medium` corresponds to `320px`, `large` corresponds to `432px`, `xlarge` corresponds to `600px`. |
-| visibility | `'visible', 'hidden'` | `visible` | Sets the visibility of the `Overlay`. |
+| anchorSide | `AnchorSide` | undefined | Optional. If provided, the Overlay will slide into position from the side of the anchor with a brief animation |
diff --git a/src/AnchoredOverlay/AnchoredOverlay.tsx b/src/AnchoredOverlay/AnchoredOverlay.tsx
index 436605f8e6a..baab28528ed 100644
--- a/src/AnchoredOverlay/AnchoredOverlay.tsx
+++ b/src/AnchoredOverlay/AnchoredOverlay.tsx
@@ -1,4 +1,4 @@
-import React, {useCallback, useMemo} from 'react'
+import React, {useCallback, useEffect, useMemo} from 'react'
import Overlay, {OverlayProps} from '../Overlay'
import {FocusTrapHookSettings, useFocusTrap} from '../hooks/useFocusTrap'
import {FocusZoneHookSettings, useFocusZone} from '../hooks/useFocusZone'
@@ -124,9 +124,16 @@ export const AnchoredOverlay: React.FC = ({
[overlayRef.current]
)
const overlayPosition = useMemo(() => {
- return position && {top: `${position.top}px`, left: `${position.left}px`}
+ return position && {top: `${position.top}px`, left: `${position.left}px`, anchorSide: position.anchorSide}
}, [position])
+ useEffect(() => {
+ // ensure overlay ref gets cleared when closed, so position can reset between closing/re-opening
+ if (!open && overlayRef.current) {
+ updateOverlayRef(null)
+ }
+ }, [open, overlayRef, updateOverlayRef])
+
useFocusZone({
containerRef: overlayRef,
disabled: !open || !position,
@@ -141,7 +148,7 @@ export const AnchoredOverlay: React.FC = ({
ref: anchorRef,
id: anchorId,
'aria-labelledby': anchorId,
- 'aria-haspopup': 'listbox',
+ 'aria-haspopup': 'true',
tabIndex: 0,
onClick: onAnchorClick,
onKeyDown: onAnchorKeyDown
@@ -153,8 +160,7 @@ export const AnchoredOverlay: React.FC = ({
ignoreClickRefs={[anchorRef]}
onEscape={onEscape}
ref={updateOverlayRef}
- role="listbox"
- visibility={position ? 'visible' : 'hidden'}
+ role="none"
height={height}
width={width}
{...overlayPosition}
diff --git a/src/Overlay.tsx b/src/Overlay.tsx
index 3696dfbccdc..06666d02a94 100644
--- a/src/Overlay.tsx
+++ b/src/Overlay.tsx
@@ -1,17 +1,19 @@
import styled from 'styled-components'
-import React, {ReactElement, useEffect, useRef} from 'react'
+import React, {ReactElement, useEffect, useLayoutEffect, useRef} from 'react'
import {get, COMMON, POSITION, SystemPositionProps, SystemCommonProps} from './constants'
import {ComponentProps} from './utils/types'
import {useOverlay, TouchOrMouseEvent} from './hooks'
import Portal from './Portal'
import sx, {SxProp} from './sx'
import {useCombinedRefs} from './hooks/useCombinedRefs'
+import {AnchorSide} from './behaviors/anchoredPosition'
+import {useTheme} from './ThemeProvider'
type StyledOverlayProps = {
width?: keyof typeof widthMap
height?: keyof typeof heightMap
maxHeight?: keyof Omit
- visibility?: 'visible' | 'hidden'
+ anchorSide?: AnchorSide
}
const heightMap = {
@@ -32,6 +34,21 @@ const widthMap = {
xxlarge: '960px',
auto: 'auto'
}
+const animationDuration = 200
+
+function getSlideAnimationStartingVector(anchorSide?: AnchorSide): {x: number; y: number} {
+ if (anchorSide?.endsWith('bottom')) {
+ return {x: 0, y: -1}
+ } else if (anchorSide?.endsWith('top')) {
+ return {x: 0, y: 1}
+ } else if (anchorSide?.endsWith('right')) {
+ return {x: -1, y: 0}
+ } else if (anchorSide?.endsWith('left')) {
+ return {x: 1, y: 0}
+ }
+
+ return {x: 0, y: 0}
+}
const StyledOverlay = styled.div`
background-color: ${get('colors.bg.overlay')};
@@ -44,18 +61,17 @@ const StyledOverlay = styled.div widthMap[props.width || 'auto']};
border-radius: 12px;
overflow: hidden;
- animation: overlay-appear 200ms ${get('animation.easeOutCubic')};
+ animation: overlay-appear ${animationDuration}ms ${get('animation.easeOutCubic')};
@keyframes overlay-appear {
0% {
opacity: 0;
- transform: translateY(${get('space.2')});
}
100% {
opacity: 1;
}
}
- visibility: ${props => props.visibility || 'visible'};
+
:focus {
outline: none;
}
@@ -69,9 +85,8 @@ export type OverlayProps = {
returnFocusRef: React.RefObject
onClickOutside: (e: TouchOrMouseEvent) => void
onEscape: (e: KeyboardEvent) => void
- visibility?: 'visible' | 'hidden'
[additionalKey: string]: unknown
-} & Omit, 'visibility' | keyof SystemPositionProps>
+} & Omit, keyof SystemPositionProps>
/**
* An `Overlay` is a flexible floating surface, used to display transient content such as menus,
@@ -80,30 +95,33 @@ export type OverlayProps = {
* @param ignoreClickRefs Optional. An array of ref objects to ignore clicks on in the `onOutsideClick` behavior. This is often used to ignore clicking on the element that toggles the open/closed state for the `Overlay` to prevent the `Overlay` from being toggled twice.
* @param initialFocusRef Optional. Ref for the element to focus when the `Overlay` is opened. If nothing is provided, the first focusable element in the `Overlay` body is focused.
* @param returnFocusRef Required. Ref for the element to focus when the `Overlay` is closed.
- * @param onClickOutside Required. Function to call when clicking outside of the `Overlay`. Typically this function sets the `Overlay` visibility state to `false`.
- * @param onEscape Required. Function to call when user presses `Escape`. Typically this function sets the `Overlay` visibility state to `false`.
+ * @param onClickOutside Required. Function to call when clicking outside of the `Overlay`. Typically this function removes the Overlay.
+ * @param onEscape Required. Function to call when user presses `Escape`. Typically this function removes the Overlay.
* @param width Sets the width of the `Overlay`, pick from our set list of widths, or pass `auto` to automatically set the width based on the content of the `Overlay`. `small` corresponds to `256px`, `medium` corresponds to `320px`, `large` corresponds to `480px`, `xlarge` corresponds to `640px`, `xxlarge` corresponds to `960px`.
* @param height Sets the height of the `Overlay`, pick from our set list of heights, or pass `auto` to automatically set the height based on the content of the `Overlay`, or pass `initial` to set the height based on the initial content of the `Overlay` (i.e. ignoring content changes). `xsmall` corresponds to `192px`, `small` corresponds to `256px`, `medium` corresponds to `320px`, `large` corresponds to `432px`, `xlarge` corresponds to `600px`.
* @param maxHeight Sets the maximum height of the `Overlay`, pick from our set list of heights. `xsmall` corresponds to `192px`, `small` corresponds to `256px`, `medium` corresponds to `320px`, `large` corresponds to `432px`, `xlarge` corresponds to `600px`.
- * @param visibility Sets the visibility of the `Overlay`
+ * @param anchorSide If provided, the Overlay will slide into position from the side of the anchor with a brief animation
*/
const Overlay = React.forwardRef(
(
{
onClickOutside,
- role = 'dialog',
+ role = 'none',
initialFocusRef,
returnFocusRef,
ignoreClickRefs,
onEscape,
- visibility,
height,
+ anchorSide,
...rest
},
forwardedRef
): ReactElement => {
const overlayRef = useRef(null)
const combinedRef = useCombinedRefs(overlayRef, forwardedRef)
+ const {theme} = useTheme()
+ const slideAnimationDistance = parseInt(get('space.2')(theme).replace('px', ''))
+ const slideAnimationEasing = get('animation.easeOutCubic')(theme)
useOverlay({
overlayRef,
@@ -120,16 +138,25 @@ const Overlay = React.forwardRef(
}
}, [height, combinedRef])
+ useLayoutEffect(() => {
+ const {x, y} = getSlideAnimationStartingVector(anchorSide)
+ if ((!x && !y) || !overlayRef.current?.animate) {
+ return
+ }
+
+ // JS animation is required because Safari does not allow css animations to start paused and then run
+ overlayRef.current.animate(
+ {transform: [`translate(${slideAnimationDistance * x}px, ${slideAnimationDistance * y}px)`, `translate(0, 0)`]},
+ {
+ duration: animationDuration,
+ easing: slideAnimationEasing
+ }
+ )
+ }, [anchorSide, slideAnimationDistance, slideAnimationEasing])
+
return (
-
+
)
}
diff --git a/src/__tests__/AnchoredOverlay.tsx b/src/__tests__/AnchoredOverlay.tsx
index 90e9aaff497..66d6baef5c2 100644
--- a/src/__tests__/AnchoredOverlay.tsx
+++ b/src/__tests__/AnchoredOverlay.tsx
@@ -5,6 +5,9 @@ import {render as HTMLRender, cleanup, fireEvent} from '@testing-library/react'
import {axe, toHaveNoViolations} from 'jest-axe'
import 'babel-polyfill'
import {Button} from '../index'
+import theme from '../theme'
+import BaseStyles from '../BaseStyles'
+import {ThemeProvider} from '../ThemeProvider'
expect.extend(toHaveNoViolations)
type TestComponentSettings = {
@@ -34,14 +37,18 @@ const AnchoredOverlayTestComponent = ({
[onCloseCallback]
)
return (
- }
- >
- Contents
-
+
+
+ }
+ >
+
+
+
+
)
}
@@ -78,7 +85,7 @@ describe('AnchoredOverlay', () => {
const anchoredOverlay = HTMLRender(
)
- const anchor = anchoredOverlay.baseElement.querySelector('[aria-haspopup="listbox"]')!
+ const anchor = anchoredOverlay.baseElement.querySelector('[aria-haspopup="true"]')!
fireEvent.click(anchor)
expect(mockOpenCallback).toHaveBeenCalledTimes(1)
@@ -92,7 +99,7 @@ describe('AnchoredOverlay', () => {
const anchoredOverlay = HTMLRender(
)
- const anchor = anchoredOverlay.baseElement.querySelector('[aria-haspopup="listbox"]')!
+ const anchor = anchoredOverlay.baseElement.querySelector('[aria-haspopup="true"]')!
fireEvent.keyDown(anchor, {key: ' '})
expect(mockOpenCallback).toHaveBeenCalledTimes(1)
@@ -127,7 +134,7 @@ describe('AnchoredOverlay', () => {
onCloseCallback={mockCloseCallback}
/>
)
- const overlay = await anchoredOverlay.findByRole('listbox')
+ const overlay = await anchoredOverlay.findByRole('none')
fireEvent.keyDown(overlay, {key: 'Escape'})
expect(mockOpenCallback).toHaveBeenCalledTimes(0)
diff --git a/src/__tests__/Overlay.tsx b/src/__tests__/Overlay.tsx
index 21ef6374335..9976156665f 100644
--- a/src/__tests__/Overlay.tsx
+++ b/src/__tests__/Overlay.tsx
@@ -3,6 +3,9 @@ import {Overlay, Position, Flex, Text, ButtonDanger, Button} from '..'
import {render, cleanup, waitFor, fireEvent, act} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {axe, toHaveNoViolations} from 'jest-axe'
+import theme from '../theme'
+import BaseStyles from '../BaseStyles'
+import {ThemeProvider} from '../ThemeProvider'
expect.extend(toHaveNoViolations)
@@ -22,30 +25,34 @@ const TestComponent = ({initialFocus, callback}: TestComponentSettings) => {
}
}
return (
-
-
-
- {isOpen ? (
-
-
- Are you sure?
- Cancel
-
-
-
- ) : null}
-
+
+
+
+
+
+ {isOpen ? (
+
+
+ Are you sure?
+ Cancel
+
+
+
+ ) : null}
+
+
+
)
}
diff --git a/src/__tests__/__snapshots__/ActionMenu.tsx.snap b/src/__tests__/__snapshots__/ActionMenu.tsx.snap
index a92e9626f63..43aaddf600d 100644
--- a/src/__tests__/__snapshots__/ActionMenu.tsx.snap
+++ b/src/__tests__/__snapshots__/ActionMenu.tsx.snap
@@ -68,7 +68,7 @@ exports[`ActionMenu renders consistently 1`] = `
}
+
+
`;
diff --git a/src/__tests__/__snapshots__/DropdownMenu.tsx.snap b/src/__tests__/__snapshots__/DropdownMenu.tsx.snap
index 0ed5d845df6..0324c63d005 100644
--- a/src/__tests__/__snapshots__/DropdownMenu.tsx.snap
+++ b/src/__tests__/__snapshots__/DropdownMenu.tsx.snap
@@ -72,7 +72,7 @@ exports[`DropdownMenu renders consistently 1`] = `
}