Skip to content

Commit

Permalink
feat(Modal): support custom triggers (#558)
Browse files Browse the repository at this point in the history
## 📝 Changes

- Add `<ModalContainer />` component to support custom triggers
- This follows the
[`DialogContainer`](https://react-spectrum.adobe.com/react-spectrum/DialogContainer.html)
pattern from React Spectrum.
- Added a "versions" table to the `Modal` spec but didn't update the
spec itself with the new types. Still unsure whether specs should be
snapshots or living

## ✅ Checklist

- [x] Code is complete and in accordance with our style guide
- [x] Unit tests are written and passing
- [x] TSDoc is written or updated for any component API surface area
- [x] Stories in Storybook accompany any relevant component changes
- [x] Ensure no accessibility violations are reported in Storybook
- [x] Changeset is added
- [x] Specs and documentation are up-to-date
- [x] Cross-browser check is performed (Chrome, Safari, Firefox)
  • Loading branch information
stephenjwatkins authored Aug 24, 2023
1 parent 9089d64 commit ecb5e71
Show file tree
Hide file tree
Showing 9 changed files with 197 additions and 11 deletions.
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
7 changes: 4 additions & 3 deletions documentation/specs/Modal.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,7 @@ function PageWithModal() {

## Versions

| Version | Owner | Change |
| :--------- | :-------------- | :----------------------------------------------- |
| 2023-08-22 | stephenjwatkins | Add `isDisabled` to actions to support disabling |
| Version | Owner | Change |
| :--------- | :-------------- | :-------------------------------------------------- |
| 2023-08-21 | stephenjwatkins | Add `<ModalContainer />` to support custom triggers |
| 2023-08-22 | stephenjwatkins | Add `isDisabled` to actions to support disabling |
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 @@ -36,7 +36,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 @@ -69,6 +69,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 @@ -105,6 +107,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 @@ -126,3 +136,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;
};

0 comments on commit ecb5e71

Please sign in to comment.