Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(Modal): support custom triggers #558

Merged
merged 7 commits into from
Aug 24, 2023
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/odd-paws-brake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@easypost/easy-ui": minor
---

feat(Modal): support custom triggers
8 changes: 8 additions & 0 deletions documentation/specs/Modal.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,3 +271,11 @@ function PageWithModal() {

- `useDialog`, `useModalOverlay` from `react-aria`
- `IntersectionObserver` for styling when header and footer are stuck, as denoted here: https://ryanmulligan.dev/blog/sticky-header-scroll-shadow/

---

## Versions

| Version | Owner | Change |
| :--------- | :-------------- | :-------------------------------------------------- |
| 2023-08-21 | stephenjwatkins | Add `<ModalContainer />` to support custom triggers |
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Saw your comment on this. I'm not sure if it's too much info to be added as documentation in Storybook but might be cool to have them living and attached

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i agree. i'll think about how we could do that

18 changes: 16 additions & 2 deletions easy-ui-react/src/Modal/Modal.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from "react";
import { ArgTypes, Canvas, Meta, Controls } from "@storybook/blocks";
import { Modal } from "./Modal";
import { Modal, ModalContainer } from "./Modal";
import * as ModalStories from "./Modal.stories";

<Meta of={ModalStories} />
Expand Down Expand Up @@ -35,7 +35,7 @@ A `<Modal />` is a page overlay that displays information and blocks interaction

The first child should be a trigger element, such as a `<Button />`, where the trigger events and appropriate accessibility attributes can be applied.

The second element must be a `<Modal />`. If access to programmatically close the modal is needed, a child with a function that has a close method and returns a `<Modal />` (`(close) => <Modal />`) can be passed as the second child instead of a `<Modal />`.
The second element must be a `<Modal />`. If access to programmatically close the modal inline is needed, a child with a function that has a close method and returns a `<Modal />` (`(close) => <Modal />`) can be passed as the second child instead of a `<Modal />`.

_Modal with close function injected:_

Expand Down Expand Up @@ -68,6 +68,8 @@ It can be made to be a controlled component by using the `isOpen` and `onOpenCha

It can be made to open by default using the `defaultOpen` prop on `<Modal.Trigger />`.

Use the `useModalTrigger()` hook inside a Modal component to access the modal state. Use the modal trigger state to determine if the modal `isOpen`. The modal trigger state can also be used to programmatically `close()` the modal.

### Focus containment

A `<Modal />` traps focus until it's closed. When a modal is closed, it returns focus to the element that triggered the modal.
Expand Down Expand Up @@ -104,6 +106,14 @@ A `<Modal />` comes in three sizes—`sm`, `md`, or `lg`—which governs the wid

<Controls of={ModalStories.Size} />

## Custom Triggers

A `<Modal />` can be wrapped in a `<ModalContainer />` to entirely control the triggering process.

A `<ModalContainer />` accepts a single `<Modal />` as a child. If no child is provided, the `<ModalContainer />` renders itself as closed. Use the `onDismiss` prop to know when the modal closes.

<Canvas of={ModalStories.MenuTrigger} />

## Properties

### Modal.Trigger
Expand All @@ -125,3 +135,7 @@ A `<Modal />` comes in three sizes—`sm`, `md`, or `lg`—which governs the wid
### Modal.Footer

<ArgTypes of={Modal.Footer} />

### ModalContainer

<ArgTypes of={ModalContainer} />
53 changes: 51 additions & 2 deletions easy-ui-react/src/Modal/Modal.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { action } from "@storybook/addon-actions";
import { Meta, StoryObj } from "@storybook/react";
import React from "react";
import React, { Key, useState } from "react";
import { Button } from "../Button";
import {
EasyPostLogo,
PlaceholderBox,
StripeLogo,
} from "../utilities/storybook";
import { Modal } from "./Modal";
import { Modal, ModalContainer, useModalTrigger } from "./Modal";
import { ModalTrigger } from "./ModalTrigger";
import { Menu } from "../Menu";
import { DropdownButton } from "../DropdownButton";

type ModalStory = StoryObj<typeof Modal>;
type ModalTriggerStory = StoryObj<typeof ModalTrigger>;
Expand Down Expand Up @@ -200,3 +202,50 @@ export const Controlled: ModalTriggerStory = {
controls: { include: ["isOpen"] },
},
};

export const MenuTrigger: ModalTriggerStory = {
render: () => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const [modal, setModal] = useState<Key | null>(null);
return (
<>
<Menu>
<Menu.Trigger>
<DropdownButton>Account actions</DropdownButton>
</Menu.Trigger>
<Menu.Overlay onAction={(key) => setModal(key)}>
<Menu.Item key="manage">Manage Account</Menu.Item>
</Menu.Overlay>
</Menu>
<ModalContainer
onDismiss={() => {
setModal(null);
}}
>
{modal === "manage" && <ManageAccountModel />}
</ModalContainer>
</>
);
},
};

function ManageAccountModel() {
const modalTriggerState = useModalTrigger();
return (
<Modal>
<Modal.Header>Manage Account</Modal.Header>
<Modal.Body>
<PlaceholderBox width="100%">Space for content</PlaceholderBox>
</Modal.Body>
<Modal.Footer
primaryAction={{
content: "Save",
onAction: () => {
action("Save clicked!");
modalTriggerState.close();
},
}}
/>
</Modal>
);
}
46 changes: 43 additions & 3 deletions easy-ui-react/src/Modal/Modal.test.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { screen } from "@testing-library/react";
import React from "react";
import { vi } from "vitest";
import { Button } from "../Button";
import {
mockGetComputedStyle,
mockIntersectionObserver,
render,
} from "../utilities/test";
import { Modal, ModalProps } from "./Modal";
import { Button } from "../Button";
import { ModalTriggerProps } from "./ModalTrigger";
import { Modal, ModalContainer, ModalProps, useModalTrigger } from "./Modal";
import { ModalHeaderProps } from "./ModalHeader";
import { ModalTriggerProps } from "./ModalTrigger";

describe("<Modal />", () => {
let restoreGetComputedStyle: () => void;
Expand Down Expand Up @@ -100,6 +100,46 @@ describe("<Modal />", () => {
renderModal({ isOpen: true });
expect(screen.getByRole("dialog")).toBeInTheDocument();
});

it("should support rendering in a container", async () => {
function CustomModal() {
const modalTrigger = useModalTrigger();
return (
<Modal>
<Modal.Header>Header</Modal.Header>
<Modal.Body>Content</Modal.Body>
<Modal.Footer
primaryAction={{
content: "Modal Action Button",
onAction: () => {
modalTrigger.close();
},
}}
/>
</Modal>
);
}

const handleDismiss = vi.fn();
const { user, rerender } = render(
<ModalContainer onDismiss={handleDismiss}>
{true ? <CustomModal /> : null}
</ModalContainer>,
);
expect(screen.getByRole("dialog")).toBeInTheDocument();

await user.click(
screen.getByRole("button", { name: "Modal Action Button" }),
);
expect(handleDismiss).toBeCalled();

rerender(
<ModalContainer onDismiss={handleDismiss}>
{false ? <CustomModal /> : null}
</ModalContainer>,
);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
});

const CustomSymbol = (props: object) => <span {...props} />;
Expand Down
4 changes: 4 additions & 0 deletions easy-ui-react/src/Modal/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { ModalHeader } from "./ModalHeader";
import { ModalTrigger } from "./ModalTrigger";
import { ModalContext } from "./context";
import { useIntersectionDetection } from "./useIntersectionDetection";
import { ModalContainer } from "./ModalContainer";
import { useModalTrigger } from "./context";

import styles from "./Modal.module.scss";

Expand Down Expand Up @@ -119,3 +121,5 @@ Modal.Body = ModalBody;
* Represents the footer of a `<Modal />`.
*/
Modal.Footer = ModalFooter;

export { ModalContainer, useModalTrigger };
68 changes: 68 additions & 0 deletions easy-ui-react/src/Modal/ModalContainer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import React, { ReactElement, cloneElement, useContext, useMemo } from "react";
import { useOverlayTrigger } from "react-aria";
import { useOverlayTriggerState } from "react-stately";
import { CloseableModalElement } from "./ModalTrigger";
import { ModalUnderlay } from "./ModalUnderlay";
import { ModalTriggerContext } from "./context";

type ModalContainerProps = {
/**
* Modal wrap content.
*/
children: CloseableModalElement | ReactElement | null | undefined | false;

/**
* Whether or not the modal is dismissable.
*/
isDismissable?: boolean;

/**
* Handler that is called when the overlay is closed.
*/
onDismiss?: () => void;
};

/**
* Represents a container for `<Modal />`s.
*
* @remarks
* A `<ModalContainer />` accepts a single Modal as a child, and manages
* showing and hiding it in a modal. Useful in cases where there is no trigger
* element or when the trigger unmounts while the modal is open.
*/
export function ModalContainer(props: ModalContainerProps) {
const { children, isDismissable = true, onDismiss = () => {} } = props;

const existingModalTriggerContext = useContext(ModalTriggerContext);
if (existingModalTriggerContext) {
throw new Error("Modal.Container must be used outside of a Modal.Trigger");
}

const state = useOverlayTriggerState({
isOpen: Boolean(children),
onOpenChange: (isOpen) => {
if (!isOpen) {
onDismiss();
}
},
});
const { overlayProps } = useOverlayTrigger({ type: "dialog" }, state);

const context = useMemo(() => {
return { state, isDismissable };
}, [state, isDismissable]);

return (
<ModalTriggerContext.Provider value={context}>
{state.isOpen && (
<ModalUnderlay state={state} isDismissable={isDismissable}>
{children
? typeof children === "function"
? children(state.close)
: cloneElement(children, overlayProps)
: null}
</ModalUnderlay>
)}
</ModalTriggerContext.Provider>
);
}
2 changes: 1 addition & 1 deletion easy-ui-react/src/Modal/ModalTrigger.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useOverlayTriggerState } from "react-stately";
import { ModalUnderlay } from "./ModalUnderlay";
import { ModalTriggerContext } from "./context";

type CloseableModalElement = (close: () => void) => ReactElement;
export type CloseableModalElement = (close: () => void) => ReactElement;

export type ModalTriggerProps = {
/**
Expand Down
5 changes: 5 additions & 0 deletions easy-ui-react/src/Modal/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,8 @@ export const useModalTriggerContext = () => {
}
return modalTriggerContext;
};

export const useModalTrigger = () => {
const modalTriggerContext = useModalTriggerContext();
return modalTriggerContext.state;
};