Skip to content

Commit 12e9bf2

Browse files
dgreifsmockle
andauthored
feat(Overlay): slide away from anchor based on position (#1322)
* feat(Overlay): slide away from anchor based on position * fix: handle position changes when re-opening AnchoredOverlay * refactor: use js animation for slide to fix safari * fix: Tests were failing with Axe violations - https://dequeuniversity.com/rules/axe/4.1/aria-dialog-name - https://dequeuniversity.com/rules/axe/4.2/presentation-role-conflict - https://www.w3.org/TR/wai-aria-practices-1.1/examples/menu-button/menu-button-links.html First, 'Overlay's aren’t 'listbox'es, because (when used in 'DropdownMenu' or 'ActionMenu') they contain 'menuitem's, 'menuitemradio's, or 'menuitemcheckbox'es. Second, 'Overlay's aren’t 'dialog's, because (as demonstrated in the WAI ARIA practices page linked above), 'menu's need not be contained in a 'dialog', and also (as noted in the 'aria-dialog-name' link above), 'dialog's must have an 'aria-label', 'aria-labelledby', or 'title', but neither 'DropdownMenu' nor 'ActionMenu' have any kind of header element that could be used for this. Third, if 'Overlay's are 'none', they can’t be focusable (as noted in the 'presentation-role-conflict' link above), but one of our hooks (maybe 'FocusTrap', maybe 'FocusZone') was setting 'tabIndex' to '0' (in the test component), because it did not contain a focusable child. This PR adds a focusable button child so the 'none' 'Overlay' container won’t receive 'tabIndex' '0'. * fix: Resolve lint errors Co-authored-by: Clay Miller <[email protected]>
1 parent 726b247 commit 12e9bf2

File tree

12 files changed

+160
-91
lines changed

12 files changed

+160
-91
lines changed

docs/content/Overlay.mdx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,8 @@ render(<Demo />)
7373
| 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. |
7474
| anchorRef | `React.RefObject<HTMLElement>` | `undefined` | Required. Element the `Overlay` should be anchored to. |
7575
| returnFocusRef | `React.RefObject<HTMLElement>` | `undefined` | Required. Ref for the element to focus when the `Overlay` is closed. |
76-
| onClickOutside | `function` | `undefined` | Required. Function to call when clicking outside of the `Overlay`. Typically this function sets the `Overlay` visibility state to `false`. |
77-
| onEscape | `function` | `undefined` | Required. Function to call when user presses `Escape`. Typically this function sets the `Overlay` visibility state to `false`. |
76+
| onClickOutside | `function` | `undefined` | Required. Function to call when clicking outside of the `Overlay`. Typically this function removes the Overlay. |
77+
| onEscape | `function` | `undefined` | Required. Function to call when user presses `Escape`. Typically this function removes the Overlay. |
7878
| 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`. |
7979
| 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`. |
80-
| visibility | `'visible', 'hidden'` | `visible` | Sets the visibility of the `Overlay`. |
80+
| anchorSide | `AnchorSide` | undefined | Optional. If provided, the Overlay will slide into position from the side of the anchor with a brief animation |

src/AnchoredOverlay/AnchoredOverlay.tsx

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, {useCallback, useMemo} from 'react'
1+
import React, {useCallback, useEffect, useMemo} from 'react'
22
import Overlay, {OverlayProps} from '../Overlay'
33
import {FocusTrapHookSettings, useFocusTrap} from '../hooks/useFocusTrap'
44
import {FocusZoneHookSettings, useFocusZone} from '../hooks/useFocusZone'
@@ -124,9 +124,16 @@ export const AnchoredOverlay: React.FC<AnchoredOverlayProps> = ({
124124
[overlayRef.current]
125125
)
126126
const overlayPosition = useMemo(() => {
127-
return position && {top: `${position.top}px`, left: `${position.left}px`}
127+
return position && {top: `${position.top}px`, left: `${position.left}px`, anchorSide: position.anchorSide}
128128
}, [position])
129129

130+
useEffect(() => {
131+
// ensure overlay ref gets cleared when closed, so position can reset between closing/re-opening
132+
if (!open && overlayRef.current) {
133+
updateOverlayRef(null)
134+
}
135+
}, [open, overlayRef, updateOverlayRef])
136+
130137
useFocusZone({
131138
containerRef: overlayRef,
132139
disabled: !open || !position,
@@ -141,7 +148,7 @@ export const AnchoredOverlay: React.FC<AnchoredOverlayProps> = ({
141148
ref: anchorRef,
142149
id: anchorId,
143150
'aria-labelledby': anchorId,
144-
'aria-haspopup': 'listbox',
151+
'aria-haspopup': 'true',
145152
tabIndex: 0,
146153
onClick: onAnchorClick,
147154
onKeyDown: onAnchorKeyDown
@@ -153,8 +160,7 @@ export const AnchoredOverlay: React.FC<AnchoredOverlayProps> = ({
153160
ignoreClickRefs={[anchorRef]}
154161
onEscape={onEscape}
155162
ref={updateOverlayRef}
156-
role="listbox"
157-
visibility={position ? 'visible' : 'hidden'}
163+
role="none"
158164
height={height}
159165
width={width}
160166
{...overlayPosition}

src/Overlay.tsx

Lines changed: 47 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
import styled from 'styled-components'
2-
import React, {ReactElement, useEffect, useRef} from 'react'
2+
import React, {ReactElement, useEffect, useLayoutEffect, useRef} from 'react'
33
import {get, COMMON, POSITION, SystemPositionProps, SystemCommonProps} from './constants'
44
import {ComponentProps} from './utils/types'
55
import {useOverlay, TouchOrMouseEvent} from './hooks'
66
import Portal from './Portal'
77
import sx, {SxProp} from './sx'
88
import {useCombinedRefs} from './hooks/useCombinedRefs'
9+
import {AnchorSide} from './behaviors/anchoredPosition'
10+
import {useTheme} from './ThemeProvider'
911

1012
type StyledOverlayProps = {
1113
width?: keyof typeof widthMap
1214
height?: keyof typeof heightMap
1315
maxHeight?: keyof Omit<typeof heightMap, 'auto' | 'initial'>
14-
visibility?: 'visible' | 'hidden'
16+
anchorSide?: AnchorSide
1517
}
1618

1719
const heightMap = {
@@ -32,6 +34,21 @@ const widthMap = {
3234
xxlarge: '960px',
3335
auto: 'auto'
3436
}
37+
const animationDuration = 200
38+
39+
function getSlideAnimationStartingVector(anchorSide?: AnchorSide): {x: number; y: number} {
40+
if (anchorSide?.endsWith('bottom')) {
41+
return {x: 0, y: -1}
42+
} else if (anchorSide?.endsWith('top')) {
43+
return {x: 0, y: 1}
44+
} else if (anchorSide?.endsWith('right')) {
45+
return {x: -1, y: 0}
46+
} else if (anchorSide?.endsWith('left')) {
47+
return {x: 1, y: 0}
48+
}
49+
50+
return {x: 0, y: 0}
51+
}
3552

3653
const StyledOverlay = styled.div<StyledOverlayProps & SystemCommonProps & SystemPositionProps & SxProp>`
3754
background-color: ${get('colors.bg.overlay')};
@@ -44,18 +61,17 @@ const StyledOverlay = styled.div<StyledOverlayProps & SystemCommonProps & System
4461
width: ${props => widthMap[props.width || 'auto']};
4562
border-radius: 12px;
4663
overflow: hidden;
47-
animation: overlay-appear 200ms ${get('animation.easeOutCubic')};
64+
animation: overlay-appear ${animationDuration}ms ${get('animation.easeOutCubic')};
4865
4966
@keyframes overlay-appear {
5067
0% {
5168
opacity: 0;
52-
transform: translateY(${get('space.2')});
5369
}
5470
100% {
5571
opacity: 1;
5672
}
5773
}
58-
visibility: ${props => props.visibility || 'visible'};
74+
5975
:focus {
6076
outline: none;
6177
}
@@ -69,9 +85,8 @@ export type OverlayProps = {
6985
returnFocusRef: React.RefObject<HTMLElement>
7086
onClickOutside: (e: TouchOrMouseEvent) => void
7187
onEscape: (e: KeyboardEvent) => void
72-
visibility?: 'visible' | 'hidden'
7388
[additionalKey: string]: unknown
74-
} & Omit<ComponentProps<typeof StyledOverlay>, 'visibility' | keyof SystemPositionProps>
89+
} & Omit<ComponentProps<typeof StyledOverlay>, keyof SystemPositionProps>
7590

7691
/**
7792
* An `Overlay` is a flexible floating surface, used to display transient content such as menus,
@@ -80,30 +95,33 @@ export type OverlayProps = {
8095
* @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.
8196
* @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.
8297
* @param returnFocusRef Required. Ref for the element to focus when the `Overlay` is closed.
83-
* @param onClickOutside Required. Function to call when clicking outside of the `Overlay`. Typically this function sets the `Overlay` visibility state to `false`.
84-
* @param onEscape Required. Function to call when user presses `Escape`. Typically this function sets the `Overlay` visibility state to `false`.
98+
* @param onClickOutside Required. Function to call when clicking outside of the `Overlay`. Typically this function removes the Overlay.
99+
* @param onEscape Required. Function to call when user presses `Escape`. Typically this function removes the Overlay.
85100
* @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`.
86101
* @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`.
87102
* @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`.
88-
* @param visibility Sets the visibility of the `Overlay`
103+
* @param anchorSide If provided, the Overlay will slide into position from the side of the anchor with a brief animation
89104
*/
90105
const Overlay = React.forwardRef<HTMLDivElement, OverlayProps>(
91106
(
92107
{
93108
onClickOutside,
94-
role = 'dialog',
109+
role = 'none',
95110
initialFocusRef,
96111
returnFocusRef,
97112
ignoreClickRefs,
98113
onEscape,
99-
visibility,
100114
height,
115+
anchorSide,
101116
...rest
102117
},
103118
forwardedRef
104119
): ReactElement => {
105120
const overlayRef = useRef<HTMLDivElement>(null)
106121
const combinedRef = useCombinedRefs(overlayRef, forwardedRef)
122+
const {theme} = useTheme()
123+
const slideAnimationDistance = parseInt(get('space.2')(theme).replace('px', ''))
124+
const slideAnimationEasing = get('animation.easeOutCubic')(theme)
107125

108126
useOverlay({
109127
overlayRef,
@@ -120,16 +138,25 @@ const Overlay = React.forwardRef<HTMLDivElement, OverlayProps>(
120138
}
121139
}, [height, combinedRef])
122140

141+
useLayoutEffect(() => {
142+
const {x, y} = getSlideAnimationStartingVector(anchorSide)
143+
if ((!x && !y) || !overlayRef.current?.animate) {
144+
return
145+
}
146+
147+
// JS animation is required because Safari does not allow css animations to start paused and then run
148+
overlayRef.current.animate(
149+
{transform: [`translate(${slideAnimationDistance * x}px, ${slideAnimationDistance * y}px)`, `translate(0, 0)`]},
150+
{
151+
duration: animationDuration,
152+
easing: slideAnimationEasing
153+
}
154+
)
155+
}, [anchorSide, slideAnimationDistance, slideAnimationEasing])
156+
123157
return (
124158
<Portal>
125-
<StyledOverlay
126-
aria-modal="true"
127-
role={role}
128-
height={height}
129-
{...rest}
130-
ref={combinedRef}
131-
visibility={visibility}
132-
/>
159+
<StyledOverlay height={height} role={role} {...rest} ref={combinedRef} />
133160
</Portal>
134161
)
135162
}

src/__tests__/AnchoredOverlay.tsx

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import {render as HTMLRender, cleanup, fireEvent} from '@testing-library/react'
55
import {axe, toHaveNoViolations} from 'jest-axe'
66
import 'babel-polyfill'
77
import {Button} from '../index'
8+
import theme from '../theme'
9+
import BaseStyles from '../BaseStyles'
10+
import {ThemeProvider} from '../ThemeProvider'
811
expect.extend(toHaveNoViolations)
912

1013
type TestComponentSettings = {
@@ -34,14 +37,18 @@ const AnchoredOverlayTestComponent = ({
3437
[onCloseCallback]
3538
)
3639
return (
37-
<AnchoredOverlay
38-
open={open}
39-
onOpen={onOpen}
40-
onClose={onClose}
41-
renderAnchor={props => <Button {...props}>Anchor Button</Button>}
42-
>
43-
Contents
44-
</AnchoredOverlay>
40+
<ThemeProvider theme={theme}>
41+
<BaseStyles>
42+
<AnchoredOverlay
43+
open={open}
44+
onOpen={onOpen}
45+
onClose={onClose}
46+
renderAnchor={props => <Button {...props}>Anchor Button</Button>}
47+
>
48+
<button type="button">Focusable Child</button>
49+
</AnchoredOverlay>
50+
</BaseStyles>
51+
</ThemeProvider>
4552
)
4653
}
4754

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

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

98105
expect(mockOpenCallback).toHaveBeenCalledTimes(1)
@@ -127,7 +134,7 @@ describe('AnchoredOverlay', () => {
127134
onCloseCallback={mockCloseCallback}
128135
/>
129136
)
130-
const overlay = await anchoredOverlay.findByRole('listbox')
137+
const overlay = await anchoredOverlay.findByRole('none')
131138
fireEvent.keyDown(overlay, {key: 'Escape'})
132139

133140
expect(mockOpenCallback).toHaveBeenCalledTimes(0)

src/__tests__/Overlay.tsx

Lines changed: 31 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ import {Overlay, Position, Flex, Text, ButtonDanger, Button} from '..'
33
import {render, cleanup, waitFor, fireEvent, act} from '@testing-library/react'
44
import userEvent from '@testing-library/user-event'
55
import {axe, toHaveNoViolations} from 'jest-axe'
6+
import theme from '../theme'
7+
import BaseStyles from '../BaseStyles'
8+
import {ThemeProvider} from '../ThemeProvider'
69

710
expect.extend(toHaveNoViolations)
811

@@ -22,30 +25,34 @@ const TestComponent = ({initialFocus, callback}: TestComponentSettings) => {
2225
}
2326
}
2427
return (
25-
<Position position="absolute" top={0} left={0} bottom={0} right={0} ref={anchorRef}>
26-
<Button ref={buttonRef} onClick={() => setIsOpen(!isOpen)}>
27-
open overlay
28-
</Button>
29-
<Button>outside</Button>
30-
{isOpen ? (
31-
<Overlay
32-
initialFocusRef={initialFocus === 'button' ? confirmButtonRef : undefined}
33-
returnFocusRef={buttonRef}
34-
ignoreClickRefs={[buttonRef]}
35-
onEscape={closeOverlay}
36-
onClickOutside={closeOverlay}
37-
width="small"
38-
>
39-
<Flex flexDirection="column" p={2}>
40-
<Text>Are you sure?</Text>
41-
<ButtonDanger onClick={closeOverlay}>Cancel</ButtonDanger>
42-
<Button onClick={closeOverlay} ref={confirmButtonRef}>
43-
Confirm
44-
</Button>
45-
</Flex>
46-
</Overlay>
47-
) : null}
48-
</Position>
28+
<ThemeProvider theme={theme}>
29+
<BaseStyles>
30+
<Position position="absolute" top={0} left={0} bottom={0} right={0} ref={anchorRef}>
31+
<Button ref={buttonRef} onClick={() => setIsOpen(!isOpen)}>
32+
open overlay
33+
</Button>
34+
<Button>outside</Button>
35+
{isOpen ? (
36+
<Overlay
37+
initialFocusRef={initialFocus === 'button' ? confirmButtonRef : undefined}
38+
returnFocusRef={buttonRef}
39+
ignoreClickRefs={[buttonRef]}
40+
onEscape={closeOverlay}
41+
onClickOutside={closeOverlay}
42+
width="small"
43+
>
44+
<Flex flexDirection="column" p={2}>
45+
<Text>Are you sure?</Text>
46+
<ButtonDanger onClick={closeOverlay}>Cancel</ButtonDanger>
47+
<Button onClick={closeOverlay} ref={confirmButtonRef}>
48+
Confirm
49+
</Button>
50+
</Flex>
51+
</Overlay>
52+
) : null}
53+
</Position>
54+
</BaseStyles>
55+
</ThemeProvider>
4956
)
5057
}
5158

src/__tests__/__snapshots__/ActionMenu.tsx.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ exports[`ActionMenu renders consistently 1`] = `
6868
}
6969
7070
<button
71-
aria-haspopup="listbox"
71+
aria-haspopup="true"
7272
aria-label="menu"
7373
aria-labelledby="__primer_id_10000"
7474
className="c0"

0 commit comments

Comments
 (0)