Skip to content

Commit

Permalink
feat(react,style): add AnchoredOverlay component, refactor Tooltip an…
Browse files Browse the repository at this point in the history
…d Popover to use AnchoredOverlay (#1760)
  • Loading branch information
scurker authored Dec 18, 2024
1 parent afa8fb4 commit 6773975
Show file tree
Hide file tree
Showing 15 changed files with 546 additions and 142 deletions.
189 changes: 189 additions & 0 deletions docs/pages/components/AnchoredOverlay.mdx
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)

2 changes: 1 addition & 1 deletion docs/pages/components/Popover.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { Popover, Button } from '@deque/cauldron-react'

## Examples

Cauldron's tooltip relies on [Popper](https://popper.js.org/) to position tooltips dynamically. Popover can be triggered from any focusable element via a `target` attribute pointed to an HTMLElement or React ref object.
Cauldron's tooltip relies on [Floating UI](https://floating-ui.com/) to position tooltips dynamically. Popover can be triggered from any focusable element via a `target` attribute pointed to an HTMLElement or React ref object.

### Prompt Popover

Expand Down
2 changes: 1 addition & 1 deletion docs/pages/components/Tooltip.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { Tooltip } from '@deque/cauldron-react'

## Examples

Cauldron's tooltip relies on [Popper](https://popper.js.org/) to position tooltips dynamically. Tooltips can be triggered from any focusable element via a `target` attribute pointed to an HTMLElement or React ref object.
Cauldron's tooltip relies on [Floating UI](https://floating-ui.com/) to position tooltips dynamically. Tooltips can be triggered from any focusable element via a `target` attribute pointed to an HTMLElement or React ref object.

<Note>

Expand Down
3 changes: 1 addition & 2 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,12 @@
"test": "jest --maxWorkers=1 --coverage"
},
"dependencies": {
"@popperjs/core": "^2.5.4",
"@floating-ui/react-dom": "^2.1.2",
"classnames": "^2.2.6",
"focus-trap-react": "^10.2.3",
"focusable": "^2.3.0",
"keyname": "^0.1.0",
"react-id-generator": "^3.0.1",
"react-popper": "^2.2.4",
"react-syntax-highlighter": "^15.5.0",
"tslib": "^2.4.0"
},
Expand Down
162 changes: 162 additions & 0 deletions packages/react/src/components/AnchoredOverlay/AnchoredOverlay.test.tsx
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();
});
Loading

0 comments on commit 6773975

Please sign in to comment.