feat(popover): component created, a11y passing, tests passing

This commit is contained in:
Junior Garcia 2023-04-07 00:44:52 -03:00
parent f51d19b09b
commit fdaaad4210
8 changed files with 471 additions and 126 deletions

View File

@ -1,11 +1,21 @@
import * as React from "react";
import {render} from "@testing-library/react";
import {render, fireEvent, act} from "@testing-library/react";
import {Button} from "@nextui-org/button";
import {Popover} from "../src";
import {Popover, PopoverContent, PopoverTrigger} from "../src";
describe("Popover", () => {
it("should render correctly", () => {
const wrapper = render(<Popover />);
const wrapper = render(
<Popover>
<PopoverTrigger>
<button>Open popover</button>
</PopoverTrigger>
<PopoverContent>
<p>This is the content of the popover.</p>
</PopoverContent>
</Popover>,
);
expect(() => wrapper.unmount()).not.toThrow();
});
@ -13,7 +23,105 @@ describe("Popover", () => {
it("ref should be forwarded", () => {
const ref = React.createRef<HTMLDivElement>();
render(<Popover ref={ref} />);
render(
<Popover ref={ref}>
<PopoverTrigger>
<button>Open popover</button>
</PopoverTrigger>
<PopoverContent>
<p>This is the content of the popover.</p>
</PopoverContent>
</Popover>,
);
expect(ref.current).not.toBeNull();
});
it("should hide the popover when pressing the escape key", () => {
const onClose = jest.fn();
const wrapper = render(
<Popover isOpen onOpenChange={(isOpen) => (!isOpen ? onClose() : undefined)}>
<PopoverTrigger>
<button>Open popover</button>
</PopoverTrigger>
<PopoverContent data-testid="content-test">
<p>This is the content of the popover.</p>
</PopoverContent>
</Popover>,
);
const content = wrapper.getByTestId("content-test");
fireEvent.keyDown(content, {key: "Escape"});
expect(onClose).toHaveBeenCalledTimes(1);
});
it("should still hide the popover when pressing the escape key ", () => {
const onClose = jest.fn();
const wrapper = render(
<Popover isOpen onOpenChange={(isOpen) => (!isOpen ? onClose() : undefined)}>
<PopoverTrigger>
<button>Open popover</button>
</PopoverTrigger>
<PopoverContent data-testid="content-test">
<p>This is the content of the popover.</p>
</PopoverContent>
</Popover>,
);
const content = wrapper.getByTestId("content-test");
fireEvent.keyDown(content, {key: "Escape"});
expect(onClose).toHaveBeenCalledTimes(1);
});
it("should hide the popover on blur ", () => {
const onClose = jest.fn();
const wrapper = render(
<Popover isOpen onOpenChange={(isOpen) => (!isOpen ? onClose() : undefined)}>
<PopoverTrigger>
<button>Open popover</button>
</PopoverTrigger>
<PopoverContent data-testid="content-test">
<p>This is the content of the popover.</p>
</PopoverContent>
</Popover>,
);
const content = wrapper.getByTestId("content-test");
fireEvent.blur(content);
expect(onClose).toHaveBeenCalledTimes(1);
});
it("should work with NextUI button", () => {
const onClose = jest.fn();
const wrapper = render(
<Popover onOpenChange={(isOpen) => (!isOpen ? onClose() : undefined)}>
<PopoverTrigger>
<Button data-testid="trigger-test">Open popover</Button>
</PopoverTrigger>
<PopoverContent>
<p>This is the content of the popover.</p>
</PopoverContent>
</Popover>,
);
const trigger = wrapper.getByTestId("trigger-test");
// open popover
act(() => {
trigger.click();
});
expect(onClose).toHaveBeenCalledTimes(0);
// close popover
act(() => {
trigger.click();
});
expect(onClose).toHaveBeenCalledTimes(1);
});
});

View File

@ -10,5 +10,8 @@ export type {PopoverContentProps} from "./popover-content";
// export hooks
export {usePopover} from "./use-popover";
// export context
export * from "./popover-context";
// export components
export {Popover, PopoverTrigger, PopoverContent};

View File

@ -1,14 +1,17 @@
import {ReactNode, useMemo, useCallback} from "react";
import type {AriaDialogProps} from "@react-aria/dialog";
import {ReactNode, useMemo, useRef} from "react";
import {forwardRef} from "@nextui-org/system";
import {DismissButton} from "@react-aria/overlays";
import {TRANSITION_VARIANTS} from "@nextui-org/framer-transitions";
import {FocusScope} from "@react-aria/focus";
import {motion} from "framer-motion";
import {getTransformOrigins} from "@nextui-org/aria-utils";
import {useDialog} from "@react-aria/dialog";
import {mergeProps} from "@react-aria/utils";
import {usePopoverContext} from "./popover-context";
export interface PopoverContentProps {
export interface PopoverContentProps extends AriaDialogProps {
children: ReactNode;
}
@ -17,19 +20,25 @@ const PopoverContent = forwardRef<PopoverContentProps, "section">((props, _) =>
const {
Component: OverlayComponent,
isOpen,
placement,
showArrow,
motionProps,
disableAnimation,
getPopoverProps,
getArrowProps,
getDialogProps,
onClose,
} = usePopoverContext();
const Component = as || OverlayComponent || "div";
const {style, className, ...otherPopoverProps} = getPopoverProps(otherProps);
const dialogRef = useRef(null);
const {dialogProps} = useDialog(
{
role: "dialog",
},
dialogRef,
);
const arrowContent = useMemo(() => {
if (!showArrow) return null;
@ -37,42 +46,24 @@ const PopoverContent = forwardRef<PopoverContentProps, "section">((props, _) =>
return <span {...getArrowProps()} />;
}, [showArrow, getArrowProps]);
const ContentWrapper = useCallback(
({children}: {children: ReactNode}) => {
return (
<FocusScope restoreFocus>
<Component className={className}>
<DismissButton onDismiss={onClose} />
{children}
{arrowContent}
<DismissButton onDismiss={onClose} />
</Component>
</FocusScope>
);
},
[Component, className, onClose, arrowContent],
const content = (
<>
<DismissButton onDismiss={onClose} />
<Component {...getDialogProps(mergeProps(dialogProps, otherProps))} ref={dialogRef}>
{children}
{arrowContent}
</Component>
<DismissButton onDismiss={onClose} />
</>
);
const visibility = useMemo(() => {
if (disableAnimation) return isOpen ? "visible" : "hidden";
return "visible";
}, [disableAnimation, isOpen]);
return (
<div
{...otherPopoverProps}
style={{
...style,
visibility,
outline: "none",
}}
>
<div {...getPopoverProps()}>
{disableAnimation ? (
<ContentWrapper>{children}</ContentWrapper>
content
) : (
<motion.div
animate={isOpen ? "enter" : "exit"}
animate="enter"
exit="exit"
initial="exit"
style={{
@ -81,7 +72,7 @@ const PopoverContent = forwardRef<PopoverContentProps, "section">((props, _) =>
variants={TRANSITION_VARIANTS.scaleSpring}
{...motionProps}
>
<ContentWrapper>{children}</ContentWrapper>
{content}
</motion.div>
)}
</div>

View File

@ -1,6 +1,7 @@
import {forwardRef} from "@nextui-org/system";
import {OverlayContainer} from "@react-aria/overlays";
import {Children, ReactNode} from "react";
import {AnimatePresence} from "framer-motion";
import {Overlay} from "@react-aria/overlays";
import {UsePopoverProps, usePopover} from "./use-popover";
import {PopoverProvider} from "./popover-context";
@ -19,12 +20,16 @@ const Popover = forwardRef<PopoverProps, "div">((props, ref) => {
const [trigger, content] = Children.toArray(children);
const mountOverlay = context.isOpen;
return (
<PopoverProvider value={context}>
{trigger}
{mountOverlay && <OverlayContainer>{content}</OverlayContainer>}
{context.disableAnimation && context.isOpen ? (
<Overlay>{content}</Overlay>
) : (
<AnimatePresence initial={false}>
{context.isOpen ? <Overlay>{content}</Overlay> : null}
</AnimatePresence>
)}
</PopoverProvider>
);
});

View File

@ -1,35 +1,32 @@
import type {PopoverVariantProps, SlotsToClasses, PopoverSlots} from "@nextui-org/theme";
import type {HTMLMotionProps} from "framer-motion";
import type {OverlayPlacement, OverlayOptions} from "@nextui-org/aria-utils";
import type {OverlayPlacement} from "@nextui-org/aria-utils";
import type {RefObject, Ref} from "react";
import {useOverlayTriggerState} from "@react-stately/overlays";
import {useFocusRing} from "@react-aria/focus";
import {
AriaOverlayProps,
AriaPopoverProps,
useOverlayTrigger,
useOverlayPosition,
useOverlay,
useModal,
usePopover as useReactAriaPopover,
} from "@react-aria/overlays";
import {useDialog} from "@react-aria/dialog";
import {OverlayTriggerProps} from "@react-types/overlays";
import {HTMLNextUIProps, mapPropsVariants, PropGetter} from "@nextui-org/system";
import {toReactAriaPlacement, getArrowPlacement} from "@nextui-org/aria-utils";
import {popover} from "@nextui-org/theme";
import {chain, mergeProps, mergeRefs} from "@react-aria/utils";
import {mergeProps, mergeRefs} from "@react-aria/utils";
import {createDOMRef} from "@nextui-org/dom-utils";
import {ReactRef, clsx} from "@nextui-org/shared-utils";
import {useId, useMemo, useCallback, useImperativeHandle, useRef} from "react";
export interface Props extends HTMLNextUIProps<"div", PopoverVariantProps> {
export interface Props extends HTMLNextUIProps<"div"> {
/**
* Ref to the DOM node.
*/
ref?: ReactRef<HTMLElement | null>;
/**
* A ref for the scrollable region within the overlay.
* @default overlayRef
* @default popoverRef
*/
scrollRef?: RefObject<HTMLElement>;
/**
@ -41,6 +38,11 @@ export interface Props extends HTMLNextUIProps<"div", PopoverVariantProps> {
* @default 'top'
*/
placement?: OverlayPlacement;
/**
* Whether the element should render an arrow.
* @default false
*/
showArrow?: boolean;
/**
* Type of overlay that is opened by the trigger.
*/
@ -62,11 +64,12 @@ export interface Props extends HTMLNextUIProps<"div", PopoverVariantProps> {
* ```
*/
styles?: SlotsToClasses<PopoverSlots>;
/** Handler that is called when the overlay should close. */
onClose?: () => void;
}
export type UsePopoverProps = Props & AriaOverlayProps & OverlayTriggerProps & OverlayOptions;
export type UsePopoverProps = Props &
Omit<AriaPopoverProps, "placement" | "triggerRef" | "popoverRef"> &
OverlayTriggerProps &
PopoverVariantProps;
export function usePopover(originalProps: UsePopoverProps) {
const [props, variantProps] = mapPropsVariants(originalProps, popover.variantKeys);
@ -87,29 +90,25 @@ export function usePopover(originalProps: UsePopoverProps) {
showArrow = false,
offset = 7,
crossOffset = 0,
isDismissable = true,
shouldCloseOnBlur = true,
isKeyboardDismissDisabled = true,
shouldCloseOnInteractOutside,
isKeyboardDismissDisabled,
motionProps,
className,
styles,
onClose,
...otherProps
} = props;
const Component = as || "div";
const popoverId = useId();
const overlayRef = useRef<HTMLDivElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const domTriggerRef = useRef<HTMLElement>(null);
const triggerRef = triggerRefProp || domTriggerRef;
// Sync ref with overlayRef from passed ref.
// Sync ref with popoverRef from passed ref.
useImperativeHandle(ref, () =>
// @ts-ignore
createDOMRef(overlayRef),
createDOMRef(popoverRef),
);
const state = useOverlayTriggerState({
@ -118,47 +117,25 @@ export function usePopover(originalProps: UsePopoverProps) {
onOpenChange,
});
const {triggerProps, overlayProps: overlayTriggerProps} = useOverlayTrigger(
{type: triggerType},
state,
triggerRef,
);
const {overlayProps: positionProps, arrowProps, placement} = useOverlayPosition({
overlayRef,
scrollRef,
isOpen: isOpen,
targetRef: triggerRef,
placement: toReactAriaPlacement(placementProp),
offset: showArrow ? offset + 3 : offset,
crossOffset,
shouldFlip,
containerPadding,
});
const {overlayProps} = useOverlay(
const {popoverProps, arrowProps, placement} = useReactAriaPopover(
{
isOpen: state.isOpen,
onClose: chain(state.close, onClose),
isDismissable,
shouldCloseOnBlur,
triggerRef,
popoverRef,
placement: toReactAriaPlacement(placementProp),
offset: showArrow ? offset + 3 : offset,
scrollRef,
crossOffset,
shouldFlip,
containerPadding,
isKeyboardDismissDisabled,
shouldCloseOnInteractOutside,
},
overlayRef,
state,
);
const {triggerProps} = useOverlayTrigger({type: triggerType}, state, triggerRef);
const {isFocusVisible, focusProps} = useFocusRing();
const {modalProps} = useModal({isDisabled: true});
const {dialogProps} = useDialog(
{
role: "dialog",
},
overlayRef,
);
const slots = useMemo(
() =>
popover({
@ -171,21 +148,20 @@ export function usePopover(originalProps: UsePopoverProps) {
const baseStyles = clsx(styles?.base, className);
const getPopoverProps: PropGetter = (props = {}) => ({
ref: overlayRef,
...mergeProps(
overlayTriggerProps,
overlayProps,
modalProps,
dialogProps,
positionProps,
focusProps,
otherProps,
props,
),
className: slots.base({class: clsx(baseStyles, props.className)}),
ref: popoverRef,
...mergeProps(popoverProps, otherProps, props),
id: popoverId,
});
const getDialogProps: PropGetter = (props = {}) => ({
className: slots.base({class: clsx(baseStyles, props.className)}),
...mergeProps(focusProps, props),
style: {
// this prevent the dialog to have a default outline
outline: "none",
},
});
const getTriggerProps = useCallback<PropGetter>(
(props = {}, _ref: Ref<any> | null | undefined = null) => {
return {
@ -218,9 +194,11 @@ export function usePopover(originalProps: UsePopoverProps) {
onClose: state.close,
disableAnimation: originalProps.disableAnimation ?? false,
motionProps,
focusProps,
getPopoverProps,
getTriggerProps,
getArrowProps,
getDialogProps,
};
}

View File

@ -1,6 +1,6 @@
import React from "react";
import {ComponentStory, ComponentMeta} from "@storybook/react";
import {popover} from "@nextui-org/theme";
import {popover, ButtonVariantProps} from "@nextui-org/theme";
import {Button} from "@nextui-org/button";
import {Popover, PopoverTrigger, PopoverContent, PopoverProps} from "../src";
@ -56,11 +56,6 @@ export default {
type: "boolean",
},
},
isDisabled: {
control: {
type: "boolean",
},
},
showArrow: {
control: {
type: "boolean",
@ -91,35 +86,295 @@ const defaultProps = {
placement: "top",
offset: 7,
defaultOpen: false,
isDisabled: false,
disableAnimation: false,
};
const content = (
<PopoverContent>
<div className="px-1 py-2">
<div className="text-sm font-bold">Popover Content</div>
<div className="text-xs">This is a content of the popover</div>
</div>
</PopoverContent>
);
const Template: ComponentStory<typeof Popover> = (args: PopoverProps) => {
return (
<Popover {...args}>
<PopoverTrigger>
<Button>Open popover</Button>
</PopoverTrigger>
<PopoverContent>
<div className="px-1 py-2">
<div className="text-sm font-bold">Popover Content</div>
<div className="text-xs">This is a content of the popover</div>
</div>
</PopoverContent>
{content}
</Popover>
);
};
const OpenChangeTemplate: ComponentStory<typeof Popover> = (args: PopoverProps) => {
const [isOpen, setIsOpen] = React.useState(false);
return (
<div className="flex flex-col gap-2">
<Popover {...args} onOpenChange={(open) => setIsOpen(open)}>
<PopoverTrigger>
<Button>Open popover</Button>
</PopoverTrigger>
<PopoverContent>
<div className="px-1 py-2">
<div className="text-sm font-bold">Popover Content</div>
<div className="text-xs">This is a content of the popover</div>
</div>
</PopoverContent>
</Popover>
<p className="text-sm">isOpen: {isOpen ? "true" : "false"}</p>
</div>
);
};
const VariantsTemplate: ComponentStory<typeof Popover> = (args: PopoverProps) => {
const buttonColor = args.color as ButtonVariantProps["color"];
const content = (
<PopoverContent>
<div className="px-1 py-2">
<div className="text-sm font-bold">Popover Content</div>
<div className="text-xs">This is a content of the popover</div>
</div>
</PopoverContent>
);
return (
<div className="flex gap-2">
<Popover {...args} variant="solid">
<PopoverTrigger>
<Button color={buttonColor}>Solid</Button>
</PopoverTrigger>
{content}
</Popover>
<Popover {...args} variant="bordered">
<PopoverTrigger>
<Button color={buttonColor} variant="bordered">
Bordered
</Button>
</PopoverTrigger>
{content}
</Popover>
<Popover {...args} variant="light">
<PopoverTrigger>
<Button color={buttonColor} variant="light">
Light
</Button>
</PopoverTrigger>
{content}
</Popover>
<Popover {...args} variant="flat">
<PopoverTrigger>
<Button color={buttonColor} variant="flat">
Flat
</Button>
</PopoverTrigger>
{content}
</Popover>
<Popover {...args} variant="faded">
<PopoverTrigger>
<Button color={buttonColor} variant="faded">
Faded
</Button>
</PopoverTrigger>
{content}
</Popover>
<Popover {...args} variant="shadow">
<PopoverTrigger>
<Button color={buttonColor} variant="shadow">
Shadow
</Button>
</PopoverTrigger>
{content}
</Popover>
</div>
);
};
const PlacementsTemplate: ComponentStory<typeof Popover> = (args: PopoverProps) => {
const buttonColor = args.color as ButtonVariantProps["color"];
return (
<div className="inline-grid grid-cols-3 gap-4">
<Popover {...args} placement="top-start">
<PopoverTrigger>
<Button color={buttonColor} variant="flat">
Top Start
</Button>
</PopoverTrigger>
{content}
</Popover>
<Popover {...args}>
<PopoverTrigger>
<Button color={buttonColor} variant="flat">
Top
</Button>
</PopoverTrigger>
{content}
</Popover>
<Popover {...args} placement="top-end">
<PopoverTrigger>
<Button color={buttonColor} variant="flat">
Top End
</Button>
</PopoverTrigger>
{content}
</Popover>
<Popover {...args} placement="bottom-start">
<PopoverTrigger>
<Button color={buttonColor} variant="flat">
Bottom Start
</Button>
</PopoverTrigger>
{content}
</Popover>
<Popover {...args} placement="bottom">
<PopoverTrigger>
<Button color={buttonColor} variant="flat">
Bottom
</Button>
</PopoverTrigger>
{content}
</Popover>
<Popover {...args} placement="bottom-end">
<PopoverTrigger>
<Button color={buttonColor} variant="flat">
Bottom End
</Button>
</PopoverTrigger>
{content}
</Popover>
<Popover {...args} placement="right-start">
<PopoverTrigger>
<Button color={buttonColor} variant="flat">
Right Start
</Button>
</PopoverTrigger>
{content}
</Popover>
<Popover {...args} placement="right">
<PopoverTrigger>
<Button color={buttonColor} variant="flat">
Right
</Button>
</PopoverTrigger>
{content}
</Popover>
<Popover {...args} placement="right-end">
<PopoverTrigger>
<Button color={buttonColor} variant="flat">
Right End
</Button>
</PopoverTrigger>
{content}
</Popover>
<Popover {...args} placement="left-start">
<PopoverTrigger>
<Button color={buttonColor} variant="flat">
Left Start
</Button>
</PopoverTrigger>
{content}
</Popover>
<Popover {...args} placement="left">
<PopoverTrigger>
<Button color={buttonColor} variant="flat">
Left
</Button>
</PopoverTrigger>
{content}
</Popover>
<Popover {...args} placement="left-end">
<PopoverTrigger>
<Button color={buttonColor} variant="flat">
Left End
</Button>
</PopoverTrigger>
{content}
</Popover>
</div>
);
};
const OffsetTemplate: ComponentStory<typeof Popover> = (args: PopoverProps) => (
<div className="flex gap-2">
<Popover {...args}>
<PopoverTrigger>
<Button color="warning" variant="faded">
Default offset (7)
</Button>
</PopoverTrigger>
{content}
</Popover>
<Popover {...args} offset={15}>
<PopoverTrigger>
<Button color="warning" variant="faded">
15 offset
</Button>
</PopoverTrigger>
{content}
</Popover>
<Popover {...args} offset={-7}>
<PopoverTrigger>
<Button color="warning" variant="faded">
-7 offset
</Button>
</PopoverTrigger>
{content}
</Popover>
</div>
);
export const Default = Template.bind({});
Default.args = {
...defaultProps,
showArrow: true,
};
export const DisableAnimation = Template.bind({});
DisableAnimation.args = {
...defaultProps,
showArrow: true,
disableAnimation: true,
};
export const WithArrow = Template.bind({});
WithArrow.args = {
...defaultProps,
showArrow: true,
};
export const OpenChange = OpenChangeTemplate.bind({});
OpenChange.args = {
...defaultProps,
};
export const Variants = VariantsTemplate.bind({});
Variants.args = {
...defaultProps,
color: "primary",
};
export const Placements = PlacementsTemplate.bind({});
Placements.args = {
...defaultProps,
color: "secondary",
};
export const WithOffset = OffsetTemplate.bind({});
WithOffset.args = {
...defaultProps,
color: "warning",
};

View File

@ -84,7 +84,6 @@ const Tooltip = forwardRef<TooltipProps, "div">((props, ref) => {
{content}
{arrowContent}
</Component>
;
</OverlayContainer>
) : (
<AnimatePresence initial={false}>

View File

@ -303,6 +303,12 @@ Default.args = {
...defaultProps,
};
export const DisableAnimation = Template.bind({});
DisableAnimation.args = {
...defaultProps,
disableAnimation: true,
};
export const WithArrow = Template.bind({});
WithArrow.args = {
...defaultProps,