feat(modal): initial tests added

This commit is contained in:
Junior Garcia 2023-04-14 23:34:29 -03:00
parent 93e7bd5105
commit 0bbdc4df8b
5 changed files with 168 additions and 11 deletions

View File

@ -1,19 +1,116 @@
import * as React from "react";
import {render} from "@testing-library/react";
import {act, render, fireEvent} from "@testing-library/react";
import {Modal} from "../src";
import {Modal, ModalTrigger, ModalContent, ModalBody, ModalHeader, ModalFooter} from "../src";
describe("Modal", () => {
it("should render correctly", () => {
const wrapper = render(<Modal />);
const wrapper = render(
<Modal>
<ModalTrigger>
<button>Open Modal</button>
</ModalTrigger>
<ModalContent>
<ModalHeader>Modal header</ModalHeader>
<ModalBody>Modal body</ModalBody>
<ModalFooter>Modal footer</ModalFooter>
</ModalContent>
</Modal>,
);
expect(() => wrapper.unmount()).not.toThrow();
});
it("ref should be forwarded", () => {
const ref = React.createRef<HTMLDivElement>();
const ref = React.createRef<HTMLElement>();
render(<Modal ref={ref} />);
render(
<Modal ref={ref}>
<ModalTrigger>
<button>Open Modal</button>
</ModalTrigger>
<ModalContent>
<ModalHeader>Modal header</ModalHeader>
<ModalBody>Modal body</ModalBody>
<ModalFooter>Modal footer</ModalFooter>
</ModalContent>
</Modal>,
);
expect(ref.current).not.toBeNull();
});
test("should have the proper 'aria' attributes", () => {
const {getByRole, getByText} = render(
<Modal isOpen>
<ModalTrigger>
<button>Open Modal</button>
</ModalTrigger>
<ModalContent>
<ModalHeader>Modal header</ModalHeader>
<ModalBody>Modal body</ModalBody>
<ModalFooter>Modal footer</ModalFooter>
</ModalContent>
</Modal>,
);
const modal = getByRole("dialog");
expect(modal).toHaveAttribute("aria-modal", "true");
expect(modal).toHaveAttribute("role", "dialog");
const modalHeader = getByText("Modal header");
expect(modal).toHaveAttribute("aria-labelledby", modalHeader.id);
const modalBody = getByText("Modal body");
expect(modal).toHaveAttribute("aria-describedby", modalBody.id);
});
test("should fire 'onOpenChange' callback when close button is clicked", () => {
const onClose = jest.fn();
const {getByLabelText} = render(
<Modal isOpen onClose={onClose}>
<ModalTrigger>
<button>Open Modal</button>
</ModalTrigger>
<ModalContent>
<ModalHeader>Modal header</ModalHeader>
<ModalBody>Modal body</ModalBody>
<ModalFooter>Modal footer</ModalFooter>
</ModalContent>
</Modal>,
);
const closeButton = getByLabelText("Close");
act(() => {
closeButton.click();
});
expect(onClose).toHaveBeenCalled();
});
it("should hide the modal when pressing the escape key", () => {
const onClose = jest.fn();
const wrapper = render(
<Modal isOpen onClose={onClose}>
<ModalTrigger>
<button>Open Modal</button>
</ModalTrigger>
<ModalContent>
<ModalHeader>Modal header</ModalHeader>
<ModalBody>Modal body</ModalBody>
<ModalFooter>Modal footer</ModalFooter>
</ModalContent>
</Modal>,
);
const modal = wrapper.getByRole("dialog");
fireEvent.keyDown(modal, {key: "Escape"});
expect(onClose).toHaveBeenCalledTimes(1);
});
});

View File

@ -14,7 +14,7 @@ export interface ModalProps extends Omit<UseModalProps, "ref"> {
children: ReactNode[];
}
const Modal = forwardRef<ModalProps, "div">((props, ref) => {
const Modal = forwardRef<ModalProps, "section">((props, ref) => {
const {children, ...otherProps} = props;
const context = useModal({ref, ...otherProps});

View File

@ -23,7 +23,7 @@ import {useOverlayTriggerState} from "@react-stately/overlays";
import {OverlayTriggerProps} from "@react-stately/overlays";
import {mergeRefs, mergeProps} from "@react-aria/utils";
interface Props extends HTMLNextUIProps<"div"> {
interface Props extends HTMLNextUIProps<"section"> {
/**
* Ref to the DOM node.
*/
@ -35,7 +35,7 @@ interface Props extends HTMLNextUIProps<"div"> {
/**
* The props to modify the framer motion animation. Use the `variants` API to create your own animation.
*/
motionProps?: HTMLMotionProps<"div">;
motionProps?: HTMLMotionProps<"section">;
/**
* Determines if the modal should have a close button in the top right corner.
* @default true
@ -46,6 +46,10 @@ interface Props extends HTMLNextUIProps<"div"> {
* @default false
*/
disableAnimation?: boolean;
/**
* Callback fired when the modal is closed.
*/
onClose?: () => void;
/**
* Classname or List of classes to change the classNames of the element.
* if `className` is passed, it will be added to the base slot.
@ -85,6 +89,7 @@ export function useModal(originalProps: UseModalProps) {
isDismissable = true,
showCloseButton = true,
isKeyboardDismissDisabled = false,
onClose,
...otherProps
} = props;
@ -112,7 +117,12 @@ export function useModal(originalProps: UseModalProps) {
const state = useOverlayTriggerState({
isOpen,
defaultOpen,
onOpenChange,
onOpenChange: (isOpen) => {
onOpenChange?.(isOpen);
if (!isOpen) {
onClose?.();
}
},
});
const {triggerProps} = useOverlayTrigger({type: "dialog"}, state, triggerRef);
@ -175,6 +185,7 @@ export function useModal(originalProps: UseModalProps) {
return {
role: "button",
tabIndex: 0,
"aria-label": "Close",
className: slots.closeButton({class: classNames?.closeButton}),
...mergeProps(closeButtonProps, closeButtonFocusProps),
};

View File

@ -26,7 +26,7 @@ export default {
size: {
control: {
type: "select",
options: ["xs", "sm", "md", "lg", "xl", "2xl", "3xl", "4xl", "5xl", "6xl", "full", "prose"],
options: ["xs", "sm", "md", "lg", "xl", "2xl", "3xl", "4xl", "5xl", "6xl", "full"],
},
},
radius: {
@ -179,11 +179,50 @@ const OutsideScrollTemplate: ComponentStory<typeof Modal> = (args: ModalProps) =
</Modal>
);
const OpenChangeTemplate: ComponentStory<typeof Modal> = (args: ModalProps) => {
const [isOpen, setIsOpen] = React.useState(false);
return (
<div className="flex flex-col gap-2">
<Modal {...args} onOpenChange={(open) => setIsOpen(open)}>
<ModalTrigger>
<Button disableAnimation={!!args.disableAnimation}>Open Modal</Button>
</ModalTrigger>
<ModalContent>
{(onClose) => (
<>
<ModalHeader>Modal Title</ModalHeader>
<ModalBody>
<Lorem size={5} />
</ModalBody>
<ModalFooter>
<Button onPress={onClose}>Close</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
<p className="text-sm">isOpen: {isOpen ? "true" : "false"}</p>
</div>
);
};
export const Default = Template.bind({});
Default.args = {
...defaultProps,
};
export const DefaultOpen = Template.bind({});
DefaultOpen.args = {
...defaultProps,
defaultOpen: true,
};
export const OpenChange = OpenChangeTemplate.bind({});
OpenChange.args = {
...defaultProps,
};
export const InsideScroll = InsideScrollTemplate.bind({});
InsideScroll.args = {
...defaultProps,

View File

@ -69,6 +69,10 @@ export interface Props extends HTMLNextUIProps<"div"> {
* ```
*/
classNames?: SlotsToClasses<PopoverSlots>;
/**
* Callback fired when the popover is closed.
*/
onClose?: () => void;
}
export type UsePopoverProps = Props &
@ -100,6 +104,7 @@ export function usePopover(originalProps: UsePopoverProps) {
motionProps,
className,
classNames,
onClose,
...otherProps
} = props;
@ -122,7 +127,12 @@ export function usePopover(originalProps: UsePopoverProps) {
const innerState = useOverlayTriggerState({
isOpen,
defaultOpen,
onOpenChange,
onOpenChange: (isOpen) => {
onOpenChange?.(isOpen);
if (!isOpen) {
onClose?.();
}
},
});
const state = stateProp || innerState;