-
Notifications
You must be signed in to change notification settings - Fork 24
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(react,style): add AnchoredOverlay component, refactor Tooltip an…
…d Popover to use AnchoredOverlay (#1760)
- Loading branch information
Showing
15 changed files
with
546 additions
and
142 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,189 @@ | ||
--- | ||
title: AnchoredOverlay | ||
description: A component that displays an anchored layered element relative to a target element. | ||
source: https://github.com/dequelabs/cauldron/tree/develop/packages/react/src/components/AnchoredOverlay/index.tsx | ||
--- | ||
|
||
import { useRef, useState } from 'react' | ||
import { Select, Button, AnchoredOverlay } from '@deque/cauldron-react' | ||
export const placements = [ | ||
'top', | ||
'top-start', | ||
'top-end', | ||
'right', | ||
'right-start', | ||
'right-end', | ||
'bottom', | ||
'bottom-start', | ||
'bottom-end', | ||
'left', | ||
'left-start', | ||
'left-end', | ||
'auto', | ||
'auto-start', | ||
'auto-end' | ||
] | ||
|
||
```jsx | ||
import { AnchoredOverlay } from '@deque/cauldron-react' | ||
``` | ||
|
||
Under the hood, `AnchoredOverlay` uses [floating-ui](https://floating-ui.com/) to dynamically position an overlay element relative to a target element. It is intentionally un-styled to be composed with other components, such as [Tooltip]('./Tooltip'), [Popover](./Popover), or via more complex overlay components. | ||
|
||
<Note> | ||
`AnchoredOverlay` is a positioning component and does not include built-in accessibility features like ARIA attributes, focus management, or keyboard interactions that would be needed for components like tooltips, dialogs, or popovers. When using `AnchoredOverlay`, you'll need to implement these accessibility patterns yourself based on your specific use case. | ||
</Note> | ||
|
||
## Examples | ||
|
||
### Placement | ||
|
||
By default, initial placement is set to `auto` when it is not set via props. However the placement can [dynamically change](https://floating-ui.com/docs/autoplacement) when using `auto` or [flip](https://floating-ui.com/docs/flip) when using positional placement. | ||
|
||
If there are presentation elements that are dependent on the position of the `AnchoredOverlay`, you should use `onPlacementChange` to keep these presentation elements in sync with any updated placements. | ||
|
||
```jsx example | ||
function AnchoredOverlayExample() { | ||
const [placement, setPlacement] = useState('top') | ||
const [open, setOpen] = useState(false) | ||
const targetRef = useRef() | ||
const handlePlacementChange = ({ target }) => setPlacement(target.value); | ||
const toggleOpen = () => setOpen(!open) | ||
const handleClose = () => setOpen(false) | ||
|
||
return ( | ||
<> | ||
<Select | ||
label="Placement" | ||
options={placements.map(placement => ({ value: placement }))} | ||
onChange={handlePlacementChange} | ||
/> | ||
<Button | ||
ref={targetRef} | ||
onFocus={toggleOpen} | ||
onBlur={handleClose} | ||
aria-describedby="anchored-overlay" | ||
> | ||
Anchor Element | ||
</Button> | ||
<AnchoredOverlay | ||
id="anchored-overlay" | ||
target={targetRef} | ||
open={open} | ||
placement={placement} | ||
onOpenChange={openState => setOpen(openState)} | ||
style={{ | ||
padding: 'var(--space-small)', | ||
backgroundColor: 'var(--panel-background-color)', | ||
display: open ? 'block' : 'none' | ||
}} | ||
> | ||
Anchored Overlay Element with placement {placement} | ||
</AnchoredOverlay> | ||
</> | ||
) | ||
} | ||
``` | ||
|
||
### Offset | ||
|
||
Optionally, an `offset` value can be set which will offset the aligning edge of the overlay element relative to its anchor. | ||
|
||
```jsx example | ||
function AnchoredOverlayWithOffsetExample() { | ||
const [placement, setPlacement] = useState('top') | ||
const [open, setOpen] = useState(false) | ||
const targetRef = useRef() | ||
const handlePlacementChange = ({ target }) => setPlacement(target.value); | ||
const toggleOpen = () => setOpen(!open) | ||
const handleClose = () => setOpen(false) | ||
|
||
return ( | ||
<> | ||
<Select | ||
label="Placement" | ||
options={placements.map(placement => ({ value: placement }))} | ||
onChange={handlePlacementChange} | ||
/> | ||
<Button | ||
ref={targetRef} | ||
onFocus={toggleOpen} | ||
onBlur={handleClose} | ||
aria-describedby="anchored-overlay-offset" | ||
> | ||
Anchor Element | ||
</Button> | ||
<AnchoredOverlay | ||
id="anchored-overlay-offset" | ||
target={targetRef} | ||
open={open} | ||
placement={placement} | ||
onOpenChange={openState => setOpen(openState)} | ||
offset={20} | ||
style={{ | ||
padding: 'var(--space-small)', | ||
backgroundColor: 'var(--panel-background-color)', | ||
display: open ? 'block' : 'none' | ||
}} | ||
> | ||
Anchored Overlay Element with offset placement {placement} | ||
</AnchoredOverlay> | ||
</> | ||
) | ||
} | ||
``` | ||
|
||
## Props | ||
|
||
<ComponentProps | ||
children={true} | ||
className={true} | ||
refType="HTMLElement" | ||
props={[ | ||
{ | ||
name: 'target', | ||
type: ['HTMLElement', 'React.MutableRefObject<HTMLElement>', 'React.RefObject<HTMLElement>'], | ||
required: true, | ||
description: 'A target element or ref to attach the overlay anchor element.' | ||
}, | ||
{ | ||
name: 'placement', | ||
type: 'string', | ||
defaultValue: 'auto', | ||
description: 'Positional placement value to anchor the overlay element relative to its anchored target.' | ||
}, | ||
{ | ||
name: 'open', | ||
type: 'boolean', | ||
defaultValue: 'false', | ||
description: 'Determines if the overlay anchor is currently visible.' | ||
}, | ||
{ | ||
name: 'onOpenChange', | ||
type: '(open: boolean) => void', | ||
description: 'A callback function that is called when the overlay state changes.' | ||
}, | ||
{ | ||
name: 'onPlacementChange', | ||
type: '(placement: Placement) => void', | ||
description: 'A callback function that is called when the placement of the overlay changes.' | ||
}, | ||
{ | ||
name: 'offset', | ||
type: 'number', | ||
description: 'An optional offset number to position the anchor element from its anchored target.' | ||
}, | ||
{ | ||
name: 'as', | ||
type: 'React.ElementType', | ||
defaultValue: 'div', | ||
description: 'The element type to render as.' | ||
} | ||
]} | ||
/> | ||
|
||
## Related Components | ||
|
||
- [Tooltip](./Tooltip) | ||
- [Popover](./Popover) | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
162 changes: 162 additions & 0 deletions
162
packages/react/src/components/AnchoredOverlay/AnchoredOverlay.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,162 @@ | ||
import React from 'react'; | ||
import { render, screen } from '@testing-library/react'; | ||
import userEvent from '@testing-library/user-event'; | ||
import AnchoredOverlay from './'; | ||
import axe from '../../axe'; | ||
|
||
test('should render children', () => { | ||
const targetRef = { current: document.createElement('button') }; | ||
render( | ||
<AnchoredOverlay target={targetRef} open data-testid="overlay"> | ||
Hello World | ||
</AnchoredOverlay> | ||
); | ||
expect(screen.getByText('Hello World')).toBeInTheDocument(); | ||
}); | ||
|
||
test('should support className prop', () => { | ||
const targetRef = { current: document.createElement('button') }; | ||
render( | ||
<AnchoredOverlay | ||
target={targetRef} | ||
className="custom" | ||
open | ||
data-testid="overlay" | ||
> | ||
Content | ||
</AnchoredOverlay> | ||
); | ||
expect(screen.getByTestId('overlay')).toHaveClass('custom'); | ||
}); | ||
|
||
test('should support as prop for polymorphic rendering', () => { | ||
const targetRef = { current: document.createElement('button') }; | ||
render( | ||
<AnchoredOverlay as="span" target={targetRef} open data-testid="overlay"> | ||
Content | ||
</AnchoredOverlay> | ||
); | ||
expect(screen.getByTestId('overlay').tagName).toBe('SPAN'); | ||
}); | ||
|
||
test('should support auto placement', () => { | ||
const targetRef = { current: document.createElement('button') }; | ||
render( | ||
<AnchoredOverlay | ||
target={targetRef} | ||
placement="auto" | ||
open | ||
data-testid="overlay" | ||
> | ||
Content | ||
</AnchoredOverlay> | ||
); | ||
expect(screen.getByTestId('overlay')).toBeInTheDocument(); | ||
}); | ||
|
||
test('should support auto-start placement', () => { | ||
const targetRef = { current: document.createElement('button') }; | ||
render( | ||
<AnchoredOverlay | ||
target={targetRef} | ||
placement="auto-start" | ||
open | ||
data-testid="overlay" | ||
> | ||
Content | ||
</AnchoredOverlay> | ||
); | ||
expect(screen.getByTestId('overlay')).toBeInTheDocument(); | ||
}); | ||
|
||
test('should support auto-end placement', () => { | ||
const targetRef = { current: document.createElement('button') }; | ||
render( | ||
<AnchoredOverlay | ||
target={targetRef} | ||
placement="auto-end" | ||
open | ||
data-testid="overlay" | ||
> | ||
Content | ||
</AnchoredOverlay> | ||
); | ||
expect(screen.getByTestId('overlay')).toBeInTheDocument(); | ||
}); | ||
|
||
test('should call onOpenChange when escape is pressed', async () => { | ||
const targetRef = { current: document.createElement('button') }; | ||
const onOpenChange = jest.fn(); | ||
const user = userEvent.setup(); | ||
|
||
render( | ||
<AnchoredOverlay | ||
target={targetRef} | ||
open | ||
onOpenChange={onOpenChange} | ||
data-testid="overlay" | ||
> | ||
Content | ||
</AnchoredOverlay> | ||
); | ||
|
||
await user.keyboard('{Escape}'); | ||
expect(onOpenChange).toHaveBeenCalledWith(false); | ||
}); | ||
|
||
test('should call onPlacementChange with initial placement', () => { | ||
const targetRef = { current: document.createElement('button') }; | ||
const onPlacementChange = jest.fn(); | ||
|
||
render( | ||
<AnchoredOverlay | ||
target={targetRef} | ||
placement="top" | ||
open | ||
onPlacementChange={onPlacementChange} | ||
data-testid="overlay" | ||
> | ||
Content | ||
</AnchoredOverlay> | ||
); | ||
|
||
expect(onPlacementChange).toHaveBeenCalledWith('top'); | ||
}); | ||
|
||
test('should support ref prop', () => { | ||
const targetRef = { current: document.createElement('button') }; | ||
const ref = React.createRef<HTMLDivElement>(); | ||
|
||
render( | ||
<AnchoredOverlay ref={ref} target={targetRef} open data-testid="overlay"> | ||
Content | ||
</AnchoredOverlay> | ||
); | ||
|
||
expect(ref.current).toBeInstanceOf(HTMLDivElement); | ||
expect(ref.current).toEqual(screen.getByTestId('overlay')); | ||
}); | ||
|
||
test('should return no axe violations when opened', async () => { | ||
const targetRef = { current: document.createElement('button') }; | ||
render( | ||
<AnchoredOverlay target={targetRef} open data-testid="overlay"> | ||
Content | ||
</AnchoredOverlay> | ||
); | ||
|
||
const results = await axe(screen.getByTestId('overlay')); | ||
expect(results).toHaveNoViolations(); | ||
}); | ||
|
||
test('should return no axe violations when not open', async () => { | ||
const targetRef = { current: document.createElement('button') }; | ||
render( | ||
<AnchoredOverlay target={targetRef} data-testid="overlay"> | ||
Content | ||
</AnchoredOverlay> | ||
); | ||
|
||
const results = await axe(screen.getByTestId('overlay')); | ||
expect(results).toHaveNoViolations(); | ||
}); |
Oops, something went wrong.