Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/content/Overlay.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,8 @@ render(<Demo />)
| initialFocusRef | `React.RefObject<HTMLElement>` | `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<HTMLElement>` | `undefined` | Required. Element the `Overlay` should be anchored to. |
| returnFocusRef | `React.RefObject<HTMLElement>` | `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 |
16 changes: 11 additions & 5 deletions src/AnchoredOverlay/AnchoredOverlay.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -124,9 +124,16 @@ export const AnchoredOverlay: React.FC<AnchoredOverlayProps> = ({
[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,
Expand All @@ -141,7 +148,7 @@ export const AnchoredOverlay: React.FC<AnchoredOverlayProps> = ({
ref: anchorRef,
id: anchorId,
'aria-labelledby': anchorId,
'aria-haspopup': 'listbox',
'aria-haspopup': 'true',
tabIndex: 0,
onClick: onAnchorClick,
onKeyDown: onAnchorKeyDown
Expand All @@ -153,8 +160,7 @@ export const AnchoredOverlay: React.FC<AnchoredOverlayProps> = ({
ignoreClickRefs={[anchorRef]}
onEscape={onEscape}
ref={updateOverlayRef}
role="listbox"
visibility={position ? 'visible' : 'hidden'}
role="none"
height={height}
width={width}
{...overlayPosition}
Expand Down
67 changes: 47 additions & 20 deletions src/Overlay.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof heightMap, 'auto' | 'initial'>
visibility?: 'visible' | 'hidden'
anchorSide?: AnchorSide
}

const heightMap = {
Expand All @@ -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<StyledOverlayProps & SystemCommonProps & SystemPositionProps & SxProp>`
background-color: ${get('colors.bg.overlay')};
Expand All @@ -44,18 +61,17 @@ const StyledOverlay = styled.div<StyledOverlayProps & SystemCommonProps & System
width: ${props => 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;
}
Expand All @@ -69,9 +85,8 @@ export type OverlayProps = {
returnFocusRef: React.RefObject<HTMLElement>
onClickOutside: (e: TouchOrMouseEvent) => void
onEscape: (e: KeyboardEvent) => void
visibility?: 'visible' | 'hidden'
[additionalKey: string]: unknown
} & Omit<ComponentProps<typeof StyledOverlay>, 'visibility' | keyof SystemPositionProps>
} & Omit<ComponentProps<typeof StyledOverlay>, keyof SystemPositionProps>

/**
* An `Overlay` is a flexible floating surface, used to display transient content such as menus,
Expand All @@ -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<HTMLDivElement, OverlayProps>(
(
{
onClickOutside,
role = 'dialog',
role = 'none',
initialFocusRef,
returnFocusRef,
ignoreClickRefs,
onEscape,
visibility,
height,
anchorSide,
...rest
},
forwardedRef
): ReactElement => {
const overlayRef = useRef<HTMLDivElement>(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,
Expand All @@ -120,16 +138,25 @@ const Overlay = React.forwardRef<HTMLDivElement, OverlayProps>(
}
}, [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 (
<Portal>
<StyledOverlay
aria-modal="true"
role={role}
height={height}
{...rest}
ref={combinedRef}
visibility={visibility}
/>
<StyledOverlay height={height} role={role} {...rest} ref={combinedRef} />
</Portal>
)
}
Expand Down
29 changes: 18 additions & 11 deletions src/__tests__/AnchoredOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -34,14 +37,18 @@ const AnchoredOverlayTestComponent = ({
[onCloseCallback]
)
return (
<AnchoredOverlay
open={open}
onOpen={onOpen}
onClose={onClose}
renderAnchor={props => <Button {...props}>Anchor Button</Button>}
>
Contents
</AnchoredOverlay>
<ThemeProvider theme={theme}>
<BaseStyles>
<AnchoredOverlay
open={open}
onOpen={onOpen}
onClose={onClose}
renderAnchor={props => <Button {...props}>Anchor Button</Button>}
>
<button type="button">Focusable Child</button>
</AnchoredOverlay>
</BaseStyles>
</ThemeProvider>
)
}

Expand Down Expand Up @@ -78,7 +85,7 @@ describe('AnchoredOverlay', () => {
const anchoredOverlay = HTMLRender(
<AnchoredOverlayTestComponent onOpenCallback={mockOpenCallback} onCloseCallback={mockCloseCallback} />
)
const anchor = anchoredOverlay.baseElement.querySelector('[aria-haspopup="listbox"]')!
const anchor = anchoredOverlay.baseElement.querySelector('[aria-haspopup="true"]')!
fireEvent.click(anchor)

expect(mockOpenCallback).toHaveBeenCalledTimes(1)
Expand All @@ -92,7 +99,7 @@ describe('AnchoredOverlay', () => {
const anchoredOverlay = HTMLRender(
<AnchoredOverlayTestComponent onOpenCallback={mockOpenCallback} onCloseCallback={mockCloseCallback} />
)
const anchor = anchoredOverlay.baseElement.querySelector('[aria-haspopup="listbox"]')!
const anchor = anchoredOverlay.baseElement.querySelector('[aria-haspopup="true"]')!
fireEvent.keyDown(anchor, {key: ' '})

expect(mockOpenCallback).toHaveBeenCalledTimes(1)
Expand Down Expand Up @@ -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)
Expand Down
55 changes: 31 additions & 24 deletions src/__tests__/Overlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -22,30 +25,34 @@ const TestComponent = ({initialFocus, callback}: TestComponentSettings) => {
}
}
return (
<Position position="absolute" top={0} left={0} bottom={0} right={0} ref={anchorRef}>
<Button ref={buttonRef} onClick={() => setIsOpen(!isOpen)}>
open overlay
</Button>
<Button>outside</Button>
{isOpen ? (
<Overlay
initialFocusRef={initialFocus === 'button' ? confirmButtonRef : undefined}
returnFocusRef={buttonRef}
ignoreClickRefs={[buttonRef]}
onEscape={closeOverlay}
onClickOutside={closeOverlay}
width="small"
>
<Flex flexDirection="column" p={2}>
<Text>Are you sure?</Text>
<ButtonDanger onClick={closeOverlay}>Cancel</ButtonDanger>
<Button onClick={closeOverlay} ref={confirmButtonRef}>
Confirm
</Button>
</Flex>
</Overlay>
) : null}
</Position>
<ThemeProvider theme={theme}>
<BaseStyles>
<Position position="absolute" top={0} left={0} bottom={0} right={0} ref={anchorRef}>
<Button ref={buttonRef} onClick={() => setIsOpen(!isOpen)}>
open overlay
</Button>
<Button>outside</Button>
{isOpen ? (
<Overlay
initialFocusRef={initialFocus === 'button' ? confirmButtonRef : undefined}
returnFocusRef={buttonRef}
ignoreClickRefs={[buttonRef]}
onEscape={closeOverlay}
onClickOutside={closeOverlay}
width="small"
>
<Flex flexDirection="column" p={2}>
<Text>Are you sure?</Text>
<ButtonDanger onClick={closeOverlay}>Cancel</ButtonDanger>
<Button onClick={closeOverlay} ref={confirmButtonRef}>
Confirm
</Button>
</Flex>
</Overlay>
) : null}
</Position>
</BaseStyles>
</ThemeProvider>
)
}

Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/__snapshots__/ActionMenu.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ exports[`ActionMenu renders consistently 1`] = `
}

<button
aria-haspopup="listbox"
aria-haspopup="true"
aria-label="menu"
aria-labelledby="__primer_id_10000"
className="c0"
Expand Down
Loading