diff --git a/.changeset/brave-toes-eat.md b/.changeset/brave-toes-eat.md new file mode 100644 index 0000000000..b90310163f --- /dev/null +++ b/.changeset/brave-toes-eat.md @@ -0,0 +1,5 @@ +--- +'@sumup/circuit-ui': patch +--- + +Extended the Popover component to accept custom [modifiers](https://popper.js.org/docs/v2/modifiers/), moved the open state outside of the component, and improved accessibility features. diff --git a/packages/circuit-ui/components/Popover/Popover.spec.tsx b/packages/circuit-ui/components/Popover/Popover.spec.tsx index b2f75d8300..127a2c5b9a 100644 --- a/packages/circuit-ui/components/Popover/Popover.spec.tsx +++ b/packages/circuit-ui/components/Popover/Popover.spec.tsx @@ -17,7 +17,6 @@ import { CirclePlus, Zap } from '@sumup/icons'; import { Placement } from '@popperjs/core'; -import { forwardRef } from 'react'; import { axe, @@ -87,14 +86,14 @@ describe('PopoverItem', () => { }); describe('Popover', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + const renderPopover = (props: Omit) => render( ((triggerProps, ref) => ( - - ))} + component={(triggerProps) => } {...props} />, ); @@ -108,6 +107,8 @@ describe('Popover', () => { }, { type: 'divider' }, ], + isOpen: true, + onToggle: jest.fn(), }; describe('styles', () => { @@ -140,22 +141,36 @@ describe('Popover', () => { }); describe('business logic', () => { - it('should close the popover when clicking outside', async () => { - const { getByRole, queryByRole } = renderPopover(baseProps); + it('should open the popover when clicking the trigger element', () => { + const popoverProps: Omit = { + actions: [ + { + onClick: () => alert('Added'), + children: 'Add', + icon: CirclePlus, + }, + { type: 'divider' }, + ], + isOpen: false, + onToggle: jest.fn(), + }; + const { getByRole } = renderPopover(popoverProps); const popoverTrigger = getByRole('button'); userEvent.click(popoverTrigger); - await waitFor(() => { - expect(queryByRole('menu')).toBeVisible(); - }); + expect(popoverProps.onToggle).toHaveBeenCalledTimes(1); + }); + + it('should close the popover when clicking outside', () => { + const { queryByRole } = renderPopover(baseProps); + + expect(queryByRole('menu')).toBeVisible(); userEvent.click(document.body); - await waitFor(() => { - expect(queryByRole('menu')).toBeNull(); - }); + expect(baseProps.onToggle).toHaveBeenCalledTimes(1); }); it('should close popover when clicking the trigger element', async () => { @@ -163,25 +178,17 @@ describe('Popover', () => { const popoverTrigger = getByRole('button'); - userEvent.click(popoverTrigger); - await waitFor(() => { expect(queryByRole('menu')).toBeVisible(); }); userEvent.click(popoverTrigger); - await waitFor(() => { - expect(queryByRole('menu')).toBeNull(); - }); + expect(baseProps.onToggle).toHaveBeenCalledTimes(1); }); it('should close popover when clicking the ESC key', async () => { - const { getByRole, queryByRole } = renderPopover(baseProps); - - const popoverTrigger = getByRole('button'); - - userEvent.click(popoverTrigger); + const { queryByRole } = renderPopover(baseProps); await waitFor(() => { expect(queryByRole('menu')).toBeVisible(); @@ -191,9 +198,7 @@ describe('Popover', () => { key: 'Escape', }); - await waitFor(() => { - expect(queryByRole('menu')).toBeNull(); - }); + expect(baseProps.onToggle).toHaveBeenCalledTimes(1); }); /** diff --git a/packages/circuit-ui/components/Popover/Popover.stories.tsx b/packages/circuit-ui/components/Popover/Popover.stories.tsx index 520a294f4f..6adec60240 100644 --- a/packages/circuit-ui/components/Popover/Popover.stories.tsx +++ b/packages/circuit-ui/components/Popover/Popover.stories.tsx @@ -15,9 +15,9 @@ /* eslint-disable react/display-name */ -import { forwardRef } from 'react'; import { action } from '@storybook/addon-actions'; import { CirclePlus, PenStroke, Bin } from '@sumup/icons'; +import { useState } from 'react'; import Button from '../Button'; @@ -55,16 +55,22 @@ const actions = [ }, ]; -export const Base = (args: PopoverProps): JSX.Element => ( - ( - - ))} - /> -); +export const Base = (args: PopoverProps): JSX.Element => { + const [isOpen, setOpen] = useState(true); + + return ( + ( + + )} + /> + ); +}; Base.args = { actions, diff --git a/packages/circuit-ui/components/Popover/Popover.tsx b/packages/circuit-ui/components/Popover/Popover.tsx index 90a503b2e9..c74174c301 100644 --- a/packages/circuit-ui/components/Popover/Popover.tsx +++ b/packages/circuit-ui/components/Popover/Popover.tsx @@ -87,7 +87,6 @@ export type PopoverItemProps = BaseProps & LinkElProps & ButtonElProps; type PopoverItemWrapperProps = LinkElProps & ButtonElProps; const itemWrapperStyles = () => css` - label: popover-item; display: flex; justify-content: flex-start; align-items: center; @@ -101,7 +100,6 @@ const PopoverItemWrapper = styled('button')( ); const iconStyles = (theme: Theme) => css` - label: popover__icon; margin-right: ${theme.spacings.byte}; `; @@ -138,40 +136,74 @@ export const PopoverItem = ({ ); }; -const wrapperStyles = ({ theme }: StyleProps) => css` - label: popover; +const wrapperBaseStyles = ({ theme }: StyleProps) => css` padding: ${theme.spacings.byte} 0px; border: 1px solid ${theme.colors.n200}; box-sizing: border-box; border-radius: ${theme.borderRadius.byte}; background-color: ${theme.colors.white}; + z-index: ${theme.zIndex.popover}; + visibility: hidden; ${theme.mq.untilKilo} { + transform: translateY(100%); + transition: transform ${theme.transitions.default}, + visibility ${theme.transitions.default}; border-bottom-right-radius: 0; border-bottom-left-radius: 0; } `; -const PopoverWrapper = styled('div')(wrapperStyles, shadow); +type OpenProps = { isOpen: boolean }; + +const wrapperOpenStyles = ({ theme, isOpen }: StyleProps & OpenProps) => + isOpen && + css` + visibility: visible; + + ${theme.mq.untilKilo} { + transform: translateY(0); + } + `; + +const PopoverWrapper = styled('div')( + shadow, + wrapperBaseStyles, + wrapperOpenStyles, +); const dividerStyles = (theme: Theme) => css` margin: ${theme.spacings.byte} ${theme.spacings.mega}; width: calc(100% - ${theme.spacings.mega}*2); `; -const Overlay = styled.div( - ({ theme }: StyleProps) => css` +const overlayStyles = ({ theme }: StyleProps) => css` + ${theme.mq.untilKilo} { + position: fixed; + top: 0; + bottom: 0; + left: 0; + right: 0; + background-color: ${theme.colors.overlay}; + z-index: ${theme.zIndex.popover - 1}; + pointer-events: none; + visibility: hidden; + opacity: 0; + transition: opacity ${theme.transitions.default}, + visibility ${theme.transitions.default}; + } +`; + +const overlayOpenStyles = ({ theme, isOpen }: StyleProps & OpenProps) => + isOpen && + css` ${theme.mq.untilKilo} { - position: fixed; - top: 0; - bottom: 0; - left: 0; - right: 0; - background-color: ${theme.colors.overlay}; - pointer-events: none; + visibility: visible; + opacity: 1; } - `, -); + `; + +const Overlay = styled.div(overlayStyles, overlayOpenStyles); type Divider = { type: 'divider' }; type Action = PopoverItemProps | Divider; @@ -181,6 +213,14 @@ function isDivider(action: Action): action is Divider { } export interface PopoverProps { + /** + * Determines whether the Popover is open or closed. + */ + isOpen: boolean; + /** + * Function that is called when toggles the Popover. + */ + onToggle: (open: boolean | ((prevOpen: boolean) => boolean)) => void; /** * An array of PopoverItem or Divider. */ @@ -190,30 +230,38 @@ export interface PopoverProps { */ placement?: Placement; /** - * The placements to fallback to when there is not enough space for the Popover. Defaults to ['top', 'right', 'left']. + * The placements to fallback to when there is not enough space for the + * Popover. Defaults to ['top', 'right', 'left']. */ fallbackPlacements?: Placement[]; + /** + * Modifiers are plugins for Popper.js to modify its default behavior. + * [Read the docs](https://popper.js.org/docs/v2/modifiers/). + */ + modifiers?: Partial>>[]; /** * The element that toggles the Popover when clicked. */ component: (props: { 'onClick': (event: MouseEvent | KeyboardEvent) => void; - 'ref': Ref; 'id': string; 'aria-haspopup': boolean; 'aria-controls': string; + 'aria-expanded': boolean; }) => JSX.Element; } export const Popover = ({ + isOpen = false, + onToggle, actions, placement = 'bottom', fallbackPlacements = ['top', 'right', 'left'], component: Component, + modifiers = [], ...props }: PopoverProps): JSX.Element | null => { - const [isOpen, setOpen] = useState(false); - const triggerRef = useRef(null); + const triggerRef = useRef(null); const theme = useTheme(); const id = uniqueId('popover_'); const triggerId = uniqueId('trigger_'); @@ -259,7 +307,7 @@ export const Popover = ({ const [popperElement, setPopperElement] = useState(null); const { styles, attributes } = usePopper(triggerRef.current, popperElement, { placement, - modifiers: [mobilePosition, flip], + modifiers: [mobilePosition, flip, ...modifiers], }); // This is a performance optimization to prevent event listeners from being @@ -272,7 +320,7 @@ export const Popover = ({ } const handleEscapePress = (event: Event) => { if (isEscape(event)) { - setOpen(false); + onToggle(false); } }; @@ -280,7 +328,7 @@ export const Popover = ({ return () => { document.removeEventListener('keydown', handleEscapePress); }; - }, [isOpen]); + }, [isOpen, onToggle]); useClickAway(popperRef, (event) => { // The reference element has its own click handler to toggle the popover. @@ -290,40 +338,42 @@ export const Popover = ({ ) { return; } - setOpen(false); + onToggle(false); }); return ( - setOpen((prev) => !prev)} - /> - {isOpen && ( - - - - {actions.map((action, index) => - isDivider(action) ? ( -
- ) : ( - - ), - )} -
-
- )} +
+ onToggle((prev) => !prev)} + /> +
+ +
+ + {actions.map((action, index) => + isDivider(action) ? ( +
+ ) : ( + + ), + )} +
+
); }; diff --git a/packages/circuit-ui/components/Popover/__snapshots__/Popover.spec.tsx.snap b/packages/circuit-ui/components/Popover/__snapshots__/Popover.spec.tsx.snap index 2c793b69cb..93a6b34a08 100644 --- a/packages/circuit-ui/components/Popover/__snapshots__/Popover.spec.tsx.snap +++ b/packages/circuit-ui/components/Popover/__snapshots__/Popover.spec.tsx.snap @@ -63,26 +63,55 @@ exports[`Popover styles should render popover on auto 1`] = ` left: 0; right: 0; background-color: rgba(0,0,0,0.4); + z-index: 29; pointer-events: none; + visibility: hidden; + opacity: 0; + -webkit-transition: opacity 120ms ease-in-out,visibility 120ms ease-in-out; + transition: opacity 120ms ease-in-out,visibility 120ms ease-in-out; + } +} + +@media (max-width:479px) { + .circuit-0 { + visibility: visible; + opacity: 1; } } .circuit-4 { + box-shadow: 0 3px 8px 0 rgba(0,0,0,0.2); padding: 8px 0px; border: 1px solid #E6E6E6; box-sizing: border-box; border-radius: 8px; background-color: #FFF; - box-shadow: 0 3px 8px 0 rgba(0,0,0,0.2); + z-index: 30; + visibility: hidden; + visibility: visible; } @media (max-width:479px) { .circuit-4 { + -webkit-transform: translateY(100%); + -ms-transform: translateY(100%); + transform: translateY(100%); + -webkit-transition: -webkit-transform 120ms ease-in-out,visibility 120ms ease-in-out; + -webkit-transition: transform 120ms ease-in-out,visibility 120ms ease-in-out; + transition: transform 120ms ease-in-out,visibility 120ms ease-in-out; border-bottom-right-radius: 0; border-bottom-left-radius: 0; } } +@media (max-width:479px) { + .circuit-4 { + -webkit-transform: translateY(0); + -ms-transform: translateY(0); + transform: translateY(0); + } +} + .circuit-3 { display: block; width: 100%; @@ -94,49 +123,55 @@ exports[`Popover styles should render popover on auto 1`] = ` }
- +
+ +
`; @@ -204,26 +239,55 @@ exports[`Popover styles should render popover on bottom 1`] = ` left: 0; right: 0; background-color: rgba(0,0,0,0.4); + z-index: 29; pointer-events: none; + visibility: hidden; + opacity: 0; + -webkit-transition: opacity 120ms ease-in-out,visibility 120ms ease-in-out; + transition: opacity 120ms ease-in-out,visibility 120ms ease-in-out; + } +} + +@media (max-width:479px) { + .circuit-0 { + visibility: visible; + opacity: 1; } } .circuit-4 { + box-shadow: 0 3px 8px 0 rgba(0,0,0,0.2); padding: 8px 0px; border: 1px solid #E6E6E6; box-sizing: border-box; border-radius: 8px; background-color: #FFF; - box-shadow: 0 3px 8px 0 rgba(0,0,0,0.2); + z-index: 30; + visibility: hidden; + visibility: visible; } @media (max-width:479px) { .circuit-4 { + -webkit-transform: translateY(100%); + -ms-transform: translateY(100%); + transform: translateY(100%); + -webkit-transition: -webkit-transform 120ms ease-in-out,visibility 120ms ease-in-out; + -webkit-transition: transform 120ms ease-in-out,visibility 120ms ease-in-out; + transition: transform 120ms ease-in-out,visibility 120ms ease-in-out; border-bottom-right-radius: 0; border-bottom-left-radius: 0; } } +@media (max-width:479px) { + .circuit-4 { + -webkit-transform: translateY(0); + -ms-transform: translateY(0); + transform: translateY(0); + } +} + .circuit-3 { display: block; width: 100%; @@ -235,49 +299,55 @@ exports[`Popover styles should render popover on bottom 1`] = ` }
- +
+ +
`; @@ -345,26 +415,55 @@ exports[`Popover styles should render popover on left 1`] = ` left: 0; right: 0; background-color: rgba(0,0,0,0.4); + z-index: 29; pointer-events: none; + visibility: hidden; + opacity: 0; + -webkit-transition: opacity 120ms ease-in-out,visibility 120ms ease-in-out; + transition: opacity 120ms ease-in-out,visibility 120ms ease-in-out; + } +} + +@media (max-width:479px) { + .circuit-0 { + visibility: visible; + opacity: 1; } } .circuit-4 { + box-shadow: 0 3px 8px 0 rgba(0,0,0,0.2); padding: 8px 0px; border: 1px solid #E6E6E6; box-sizing: border-box; border-radius: 8px; background-color: #FFF; - box-shadow: 0 3px 8px 0 rgba(0,0,0,0.2); + z-index: 30; + visibility: hidden; + visibility: visible; } @media (max-width:479px) { .circuit-4 { + -webkit-transform: translateY(100%); + -ms-transform: translateY(100%); + transform: translateY(100%); + -webkit-transition: -webkit-transform 120ms ease-in-out,visibility 120ms ease-in-out; + -webkit-transition: transform 120ms ease-in-out,visibility 120ms ease-in-out; + transition: transform 120ms ease-in-out,visibility 120ms ease-in-out; border-bottom-right-radius: 0; border-bottom-left-radius: 0; } } +@media (max-width:479px) { + .circuit-4 { + -webkit-transform: translateY(0); + -ms-transform: translateY(0); + transform: translateY(0); + } +} + .circuit-3 { display: block; width: 100%; @@ -376,49 +475,55 @@ exports[`Popover styles should render popover on left 1`] = ` }
- +
+ +
`; @@ -486,26 +591,55 @@ exports[`Popover styles should render popover on right 1`] = ` left: 0; right: 0; background-color: rgba(0,0,0,0.4); + z-index: 29; pointer-events: none; + visibility: hidden; + opacity: 0; + -webkit-transition: opacity 120ms ease-in-out,visibility 120ms ease-in-out; + transition: opacity 120ms ease-in-out,visibility 120ms ease-in-out; + } +} + +@media (max-width:479px) { + .circuit-0 { + visibility: visible; + opacity: 1; } } .circuit-4 { + box-shadow: 0 3px 8px 0 rgba(0,0,0,0.2); padding: 8px 0px; border: 1px solid #E6E6E6; box-sizing: border-box; border-radius: 8px; background-color: #FFF; - box-shadow: 0 3px 8px 0 rgba(0,0,0,0.2); + z-index: 30; + visibility: hidden; + visibility: visible; } @media (max-width:479px) { .circuit-4 { + -webkit-transform: translateY(100%); + -ms-transform: translateY(100%); + transform: translateY(100%); + -webkit-transition: -webkit-transform 120ms ease-in-out,visibility 120ms ease-in-out; + -webkit-transition: transform 120ms ease-in-out,visibility 120ms ease-in-out; + transition: transform 120ms ease-in-out,visibility 120ms ease-in-out; border-bottom-right-radius: 0; border-bottom-left-radius: 0; } } +@media (max-width:479px) { + .circuit-4 { + -webkit-transform: translateY(0); + -ms-transform: translateY(0); + transform: translateY(0); + } +} + .circuit-3 { display: block; width: 100%; @@ -517,49 +651,55 @@ exports[`Popover styles should render popover on right 1`] = ` }
- +
+ +
`; @@ -627,26 +767,55 @@ exports[`Popover styles should render popover on top 1`] = ` left: 0; right: 0; background-color: rgba(0,0,0,0.4); + z-index: 29; pointer-events: none; + visibility: hidden; + opacity: 0; + -webkit-transition: opacity 120ms ease-in-out,visibility 120ms ease-in-out; + transition: opacity 120ms ease-in-out,visibility 120ms ease-in-out; + } +} + +@media (max-width:479px) { + .circuit-0 { + visibility: visible; + opacity: 1; } } .circuit-4 { + box-shadow: 0 3px 8px 0 rgba(0,0,0,0.2); padding: 8px 0px; border: 1px solid #E6E6E6; box-sizing: border-box; border-radius: 8px; background-color: #FFF; - box-shadow: 0 3px 8px 0 rgba(0,0,0,0.2); + z-index: 30; + visibility: hidden; + visibility: visible; } @media (max-width:479px) { .circuit-4 { + -webkit-transform: translateY(100%); + -ms-transform: translateY(100%); + transform: translateY(100%); + -webkit-transition: -webkit-transform 120ms ease-in-out,visibility 120ms ease-in-out; + -webkit-transition: transform 120ms ease-in-out,visibility 120ms ease-in-out; + transition: transform 120ms ease-in-out,visibility 120ms ease-in-out; border-bottom-right-radius: 0; border-bottom-left-radius: 0; } } +@media (max-width:479px) { + .circuit-4 { + -webkit-transform: translateY(0); + -ms-transform: translateY(0); + transform: translateY(0); + } +} + .circuit-3 { display: block; width: 100%; @@ -658,49 +827,55 @@ exports[`Popover styles should render popover on top 1`] = ` }
- +
+ +
`; @@ -768,26 +943,55 @@ exports[`Popover styles should render with default styles 1`] = ` left: 0; right: 0; background-color: rgba(0,0,0,0.4); + z-index: 29; pointer-events: none; + visibility: hidden; + opacity: 0; + -webkit-transition: opacity 120ms ease-in-out,visibility 120ms ease-in-out; + transition: opacity 120ms ease-in-out,visibility 120ms ease-in-out; + } +} + +@media (max-width:479px) { + .circuit-0 { + visibility: visible; + opacity: 1; } } .circuit-4 { + box-shadow: 0 3px 8px 0 rgba(0,0,0,0.2); padding: 8px 0px; border: 1px solid #E6E6E6; box-sizing: border-box; border-radius: 8px; background-color: #FFF; - box-shadow: 0 3px 8px 0 rgba(0,0,0,0.2); + z-index: 30; + visibility: hidden; + visibility: visible; } @media (max-width:479px) { .circuit-4 { + -webkit-transform: translateY(100%); + -ms-transform: translateY(100%); + transform: translateY(100%); + -webkit-transition: -webkit-transform 120ms ease-in-out,visibility 120ms ease-in-out; + -webkit-transition: transform 120ms ease-in-out,visibility 120ms ease-in-out; + transition: transform 120ms ease-in-out,visibility 120ms ease-in-out; border-bottom-right-radius: 0; border-bottom-left-radius: 0; } } +@media (max-width:479px) { + .circuit-4 { + -webkit-transform: translateY(0); + -ms-transform: translateY(0); + transform: translateY(0); + } +} + .circuit-3 { display: block; width: 100%; @@ -799,49 +1003,55 @@ exports[`Popover styles should render with default styles 1`] = ` }
- +
+ +
`; diff --git a/packages/circuit-ui/components/Popover/index.tsx b/packages/circuit-ui/components/Popover/index.tsx index 8e95e5779d..5e49d9ffd8 100644 --- a/packages/circuit-ui/components/Popover/index.tsx +++ b/packages/circuit-ui/components/Popover/index.tsx @@ -30,4 +30,6 @@ import { Popover } from './Popover'; +export type { PopoverProps } from './Popover'; + export default Popover; diff --git a/packages/circuit-ui/index.ts b/packages/circuit-ui/index.ts index 9fa5eb1fc4..5c8a63e5c5 100644 --- a/packages/circuit-ui/index.ts +++ b/packages/circuit-ui/index.ts @@ -119,6 +119,7 @@ export type { ProgressBarProps } from './components/ProgressBar'; export { default as Tag } from './components/Tag'; export type { TagProps } from './components/Tag'; export { default as Popover } from './components/Popover'; +export type { PopoverProps } from './components/Popover'; export { default as Tooltip } from './components/Tooltip'; export { default as BaseStyles } from './components/BaseStyles'; export { ModalProvider } from './components/ModalContext';