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`] = ` }