diff --git a/src/core/components/index.ts b/src/core/components/index.ts index 454a4b3f7..77e4851d5 100644 --- a/src/core/components/index.ts +++ b/src/core/components/index.ts @@ -3,6 +3,7 @@ export * from './breadcrumbs' export * from './dialog' export * from './hotkeys' export * from './menu' +export * from './popover-button' export * from './skeleton' export * from './tab' export * from './toast' diff --git a/src/core/components/popover-button/PopoverButton.test.tsx b/src/core/components/popover-button/PopoverButton.test.tsx new file mode 100644 index 000000000..78b77d568 --- /dev/null +++ b/src/core/components/popover-button/PopoverButton.test.tsx @@ -0,0 +1,235 @@ +/** @jest-environment jsdom */ +import {fireEvent, screen} from '@testing-library/react' +import '../../../../test/mocks/matchMedia.mock' +import {render} from '../../../../test' +import {PopoverButton} from './PopoverButton' + +describe('PopoverButton', () => { + it('should open popover when button is clicked', async () => { + render( + } + renderContent={() =>
Content
} + />, + ) + + expect(screen.queryByText('Content')).not.toBeInTheDocument() + + const trigger = screen.getByRole('button', {name: 'Trigger'}) + + fireEvent.click(trigger) + + expect(screen.queryByText('Content')).toBeInTheDocument() + }) + + it('should close popover when button is clicked again', async () => { + render( + } + renderContent={() =>
Content
} + />, + ) + + expect(screen.queryByText('Content')).not.toBeInTheDocument() + + const trigger = screen.getByRole('button', {name: 'Trigger'}) + + fireEvent.click(trigger) + + expect(screen.queryByText('Content')).toBeInTheDocument() + + fireEvent.click(trigger) + + expect(screen.queryByText('Content')).not.toBeInTheDocument() + }) + + it('should close popover when Escape key is pressed and focus is returned to button', async () => { + render( + } + renderContent={() =>
Content
} + />, + ) + + expect(screen.queryByText('Content')).not.toBeInTheDocument() + + const trigger = screen.getByRole('button', {name: 'Trigger'}) + + fireEvent.click(trigger) + + expect(screen.queryByText('Content')).toBeInTheDocument() + + fireEvent.keyDown(screen.getByText('Content'), {key: 'Escape'}) + + expect(screen.queryByText('Content')).not.toBeInTheDocument() + expect(trigger).toHaveFocus() + + // Check that the button regains focus + expect(document.activeElement).toBe(trigger) + }) + + it('should focus the first focusable element in the popover when opened', async () => { + render( + } + renderContent={() => ( +
+ + +
+ )} + />, + ) + + expect(screen.queryByTestId('menu')).not.toBeInTheDocument() + expect(screen.queryByText('First')).not.toBeInTheDocument() + + const trigger = screen.getByRole('button', {name: 'Trigger'}) + + fireEvent.click(trigger) + + // Check that the first focusable element is focused + expect(document.activeElement).toBe(screen.getByRole('button', {name: 'First'})) + }) + + it('should close popover when clicking outside and focus returned to button', async () => { + render( + } + renderContent={() =>
Content
} + />, + ) + + expect(screen.queryByText('Content')).not.toBeInTheDocument() + + const trigger = screen.getByRole('button', {name: 'Trigger'}) + + fireEvent.click(trigger) + + expect(screen.queryByText('Content')).toBeInTheDocument() + + // Simulate outside click + fireEvent.mouseDown(document.body) + + // Check that the popover is closed and focus is returned to the trigger button + expect(screen.queryByText('Content')).not.toBeInTheDocument() + expect(trigger).toHaveFocus() + + // Check that the button regains focus + expect(document.activeElement).toBe(trigger) + }) + + it('should call `onOpen` when popover is opened', async () => { + const onOpen = jest.fn() + + render( + } + renderContent={() =>
Content
} + />, + ) + + const trigger = screen.getByRole('button', {name: 'Trigger'}) + + fireEvent.click(trigger) + + expect(onOpen).toHaveBeenCalledTimes(1) + }) + + it('should call `onClose` when popover is closed', async () => { + const onClose = jest.fn() + + render( + } + renderContent={() =>
Content
} + />, + ) + + const trigger = screen.getByRole('button', {name: 'Trigger'}) + + fireEvent.click(trigger) + fireEvent.click(trigger) + + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('should set appropriate ARIA attributes on the button and popover content', async () => { + render( + } + renderContent={() =>
Content
} + />, + ) + + const trigger = screen.getByRole('button', {name: 'Trigger'}) + const ariaControls = trigger.getAttribute('aria-controls') + + expect(trigger).toHaveAttribute('aria-haspopup', 'true') + expect(trigger).toHaveAttribute('aria-expanded', 'false') + expect(trigger).toHaveAttribute('aria-controls') + + fireEvent.click(trigger) + + // After clicking, aria-expanded should be true + expect(trigger).toHaveAttribute('aria-expanded', 'true') + + const content = screen.getByText('Content') + + expect(content).toHaveAttribute('aria-labelledby', trigger.id) + expect(content).toHaveAttribute('id', ariaControls) + + fireEvent.click(trigger) + + // After clicking again, aria-expanded should revert to false + expect(trigger).toHaveAttribute('aria-expanded', 'false') + }) + + it('should close popover when invoking `close` callback from `renderContent`', async () => { + render( + } + renderContent={({close}) => } + />, + ) + + expect(screen.queryByText('Close')).not.toBeInTheDocument() + + const trigger = screen.getByRole('button', {name: 'Trigger'}) + + fireEvent.click(trigger) + + expect(screen.queryByText('Close')).toBeInTheDocument() + + fireEvent.click(screen.getByText('Close')) + + expect(screen.queryByText('Close')).not.toBeInTheDocument() + }) + + it('should return `isOpen` from `renderButton`', async () => { + render( + } + renderContent={() =>
Content
} + />, + ) + + expect(screen.getByRole('button')).toHaveTextContent('Closed') + + fireEvent.click(screen.getByRole('button')) + + expect(screen.getByRole('button')).toHaveTextContent('Open') + }) +}) diff --git a/src/core/components/popover-button/PopoverButton.tsx b/src/core/components/popover-button/PopoverButton.tsx new file mode 100644 index 000000000..c7526b07d --- /dev/null +++ b/src/core/components/popover-button/PopoverButton.tsx @@ -0,0 +1,200 @@ +import { + cloneElement, + FocusEvent, + ForwardedRef, + forwardRef, + HTMLProps, + KeyboardEvent, + useCallback, + useEffect, + useId, + useImperativeHandle, + useRef, + useState, +} from 'react' +import {css, styled} from 'styled-components' +import {focusFirstDescendant, focusLastDescendant} from '../../helpers' +import {useClickOutsideEvent} from '../../hooks' +import {Popover} from '../../primitives' +import {PopoverButtonProps} from './types' + +interface StyledPopoverProps { + $maxHeight?: number + $maxWidth?: number + $minHeight?: number + $minWidth?: number +} + +const StyledPopover = styled(Popover)((props) => { + const {$maxHeight, $maxWidth, $minHeight, $minWidth} = props + + return css` + ${$maxHeight && `max-height: ${$maxHeight}px;`} + ${$maxWidth && `max-width: ${$maxWidth}px;`} + ${$minHeight && `min-height: ${$minHeight}px;`} + ${$minWidth && `min-width: ${$minWidth}px;`} + ` +}) + +/** + * A wrapper around the `Popover` primitive that handles: + * - Opening and closing the popover when the button is clicked + * - Focusing the first element in the popover when it opens + * - Focusing the button when the popover closes with the Escape key + * - Locking focus within the popover when it is open + * - Closing the popover when a click occurs outside the button or popover + * - Sets appropriate ARIA attributes on the button and popover content + * + * @beta + */ +export const PopoverButton = forwardRef(function PopoverButton( + props: PopoverButtonProps, + forwardedRef: ForwardedRef, +) { + const { + ariaHasPopUp, + maxHeight, + maxWidth, + minHeight, + minWidth, + onClose, + onOpen, + renderButton, + renderContent, + ...popoverProps + } = props + + const [open, setOpen] = useState(false) + + const [popoverElement, setPopoverElement] = useState(null) + const [buttonElement, setButtonElement] = useState(null) + const [contentElement, setContentElement] = useState(null) + + const focusGuardPreRef = useRef(null) + const focusGuardPostRef = useRef(null) + + const buttonId = useId() + + const handleClose = useCallback(() => { + if (!open) return + + setOpen(false) + onClose?.() + + buttonElement?.focus() + }, [buttonElement, onClose, open]) + + const handleOpen = useCallback(() => { + if (open) return + + setOpen(true) + onOpen?.() + }, [onOpen, open]) + + const handleButtonClick = useCallback(() => { + if (open) { + handleClose() + } else { + handleOpen() + } + }, [handleClose, handleOpen, open]) + + const handlePopoverKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Escape' && open) { + handleClose() + } + }, + [handleClose, open], + ) + + const handlePopoverFocus = useCallback( + (event: FocusEvent) => { + const target = event.target + + if (contentElement && target === focusGuardPreRef.current) { + focusLastDescendant(contentElement) + + return + } + + if (contentElement && target === focusGuardPostRef.current) { + focusFirstDescendant(contentElement) + + return + } + }, + [contentElement], + ) + + // Expose the button element to the parent component + useImperativeHandle( + forwardedRef, + () => buttonElement, + [buttonElement], + ) + + useEffect(() => { + if (open && contentElement) { + focusFirstDescendant(contentElement) + } + }, [contentElement, open]) + + useClickOutsideEvent( + () => handleClose(), + () => [buttonElement, popoverElement], + ) + + // Button + const buttonProps: HTMLProps & {'data-ui': string} = { + 'aria-controls': `content-${buttonId}`, + 'aria-expanded': open, + 'aria-haspopup': ariaHasPopUp, + 'data-ui': 'PopoverButton', + 'id': buttonId, + 'onClick': handleButtonClick, + 'ref': setButtonElement, + 'selected': open, + } + + const renderedButton = renderButton({isOpen: open}) + + const button = cloneElement(renderedButton, buttonProps) + + // Content + const contentProps: HTMLProps = { + 'aria-labelledby': buttonProps.id, + 'id': buttonProps['aria-controls'], + 'ref': setContentElement, + } + + const renderedContent = renderContent({close: handleClose}) + + const content = ( + <> +