Skip to content

Commit

Permalink
Add Modal, Dialog and AlertDialog components (#481)
Browse files Browse the repository at this point in the history
* scaffolding Dialog component

* scaffolding Modal component

* boilerplate stories

* modal dialog example boilerplate

* roughing out Dialog structure

* fleshing out Modal/Dialog structure and styling

* building out example Modal Dialog on vite

* close button behaviour

* overlay animation

* forking AlertDialog component

* alert dialog variants and theming

* expose and set ARIA role

* set default role to dialog instead of alertdialog

* import modal props and update styling

* stripping Dialog down to the studs

* make left icon hideable

* make close button toggleable on Dialog and AlertDialog

* expose size controls for Modal container

* simplifying generic Dialog structure

* close button positioning in generic Dialog

* sketching out stories

* working on generic Dialog example

* clean up Modal example

* finishing up Modal and Dialog styling and behaviour

* simplifying modal and dialog props and styling

* cleaning up examples on vite

* fleshing out stories for Modal, Dialog and AlertDialog

* WIP dialogs docs

* add AlertDialog docs and expand Dialogs docs

* Dialog/Alert Dialog docs

* remove redundant prop declaration from Dialog

* Use ReactAriaComponents ModalOverlayProps

* simplify modal structure and styling of modaloverlay

* add width and z-index to Modal CSS

* Add passing test for Modal

* Add DialogRenderProps type information to Dialog to make Modal test pass

* Add passing test for Dialog

* Remove unused React import from AlertDialog, reorder SVG imports

* Export DialogRenderProps interface from Dialog, use in AlertDialog

* Add missing key props to kitchen sink InlineAlert buttons

* Add missing label to kitchen sink TextField instance

* Add passing test for AlertDialog

* Dialog isCloseable defaults to true to match AlertDialog

* Update Dialog tests for updated isCloseable prop default behavior

* Remove non-functional onPress props from AlertDialog instances in kitchen sink app

* Add key props to Button instances in AlertDialogs in kitchen sink app

* Add key props to Button instances in AlertDialog stories

* AlertDialogWithoutCloseButton story uses Modal isDismissable to close with click outside

* Update AlertDialog interface comment for  prop

* Refactor AlertDialog to use children slot instead of description string

* Flatten nested CSS rules for AlertDialog

* Flatten nested CSS rules for Dialog

* Use JS tokens for styles in Dialog stories

* ModalOverlay animation added for fade-in and fade-out

---------

Co-authored-by: Tyler Krys <[email protected]>
  • Loading branch information
mkernohanbc and ty2k authored Sep 19, 2024
1 parent b1a5560 commit 2b948cf
Show file tree
Hide file tree
Showing 25 changed files with 1,372 additions and 6 deletions.
2 changes: 2 additions & 0 deletions packages/react-components/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
ButtonGroupPage,
CheckboxGroupPage,
InlineAlertPage,
ModalDialogPage,
RadioGroupPage,
SelectPage,
TagGroupPage,
Expand Down Expand Up @@ -155,6 +156,7 @@ function App() {
<CheckboxGroupPage />
<SwitchPage />
<InlineAlertPage />
<ModalDialogPage />
<RadioGroupPage />
<SelectPage />
<TagGroupPage />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
.bcds-react-aria-AlertDialog {
display: flex;
flex-direction: column;
}

/* Variant icons */
.bcds-react-aria-AlertDialog.info .bcds-react-aria-AlertDialog--Icon {
color: var(--icons-color-primary);
}
.bcds-react-aria-AlertDialog.confirmation .bcds-react-aria-AlertDialog--Icon {
color: var(--icons-color-success);
}
.bcds-react-aria-AlertDialog.warning .bcds-react-aria-AlertDialog--Icon {
color: var(--icons-color-warning);
}
.bcds-react-aria-AlertDialog.error .bcds-react-aria-AlertDialog--Icon {
color: var(--icons-color-danger);
}
.bcds-react-aria-AlertDialog.destructive .bcds-react-aria-AlertDialog--Icon {
color: var(--icons-color-danger);
}

.bcds-react-aria-AlertDialog--Header {
display: inline-flex;
flex-direction: row;
gap: var(--layout-padding-small);
justify-content: space-between;
padding: var(--layout-padding-medium) var(--layout-padding-large);
border-bottom: var(--layout-border-width-small) solid
var(--surface-color-border-default);
}

.bcds-react-aria-AlertDialog--Title {
flex-grow: 1;
font: var(--typography-bold-h5);
color: var(--typography-color-primary);
}

.bcds-react-aria-AlertDialog--Icon {
justify-self: flex-start;
align-self: center;
padding-top: var(--layout-padding-xsmall);
}

.bcds-react-aria-AlertDialog--closeIcon {
justify-self: flex-end;
color: var(--icons-color-primary);
}

.bcds-react-aria-AlertDialog--children {
font: var(--typography-regular-body);
color: var(--typography-color-primary);
padding: var(--layout-padding-medium) var(--layout-padding-large);
border-bottom: var(--layout-border-width-small) solid
var(--surface-color-border-default);
}

.bcds-react-aria-AlertDialog > .bcds-ButtonGroup {
padding: var(--layout-padding-medium) var(--layout-padding-large);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { fireEvent, render, screen } from "@testing-library/react";
import "@testing-library/jest-dom";

import AlertDialog from "./AlertDialog";
import Button from "../Button";
import { DialogTrigger } from "../Dialog";
import Modal from "../Modal";

describe("AlertDialog", () => {
it("text within AlertDialog is not initially visible", () => {
render(
<DialogTrigger>
<Button>Open</Button>
<Modal>
<AlertDialog>Lorem ipsum</AlertDialog>
</Modal>
</DialogTrigger>
);

const text = screen.queryByText(/lorem ipsum/i);
expect(text).not.toBeInTheDocument();
});

it("text within AlertDialog is visible after pressing open button", () => {
render(
<DialogTrigger>
<Button>Open</Button>
<Modal>
<AlertDialog>Lorem ipsum</AlertDialog>
</Modal>
</DialogTrigger>
);

const button = screen.getByText(/open/i);
fireEvent.click(button);
const text = screen.queryByText(/lorem ipsum/i);
expect(text).toBeInTheDocument();
});

it("with `isCloseable` set to `false`, no close button is present", () => {
render(
<DialogTrigger>
<Button>Open</Button>
<Modal>
<AlertDialog isCloseable={false}>Lorem ipsum</AlertDialog>
</Modal>
</DialogTrigger>
);

const missingModalText = screen.queryByText(/lorem ipsum/i);
expect(missingModalText).not.toBeInTheDocument();

const openButton = screen.getByText(/open/i);
fireEvent.click(openButton);
const presentModalText = screen.queryByText(/lorem ipsum/i);
expect(presentModalText).toBeInTheDocument();

const closeButton = screen.queryByLabelText(/close/i);
expect(closeButton).not.toBeInTheDocument();
});

it("by default, a close button is present and pushing it closes the AlertDialog", () => {
render(
<DialogTrigger>
<Button>Open</Button>
<Modal>
<AlertDialog>Lorem ipsum</AlertDialog>
</Modal>
</DialogTrigger>
);

const missingModalText = screen.queryByText(/lorem ipsum/i);
expect(missingModalText).not.toBeInTheDocument();

const openButton = screen.getByText(/open/i);
fireEvent.click(openButton);
const presentModalText = screen.queryByText(/lorem ipsum/i);
expect(presentModalText).toBeInTheDocument();

const closeButton = screen.getByLabelText(/close/i);
expect(closeButton).toBeInTheDocument();
fireEvent.click(closeButton);
const dismissedModalText = screen.queryByText(/lorem ipsum/i);
expect(dismissedModalText).not.toBeInTheDocument();
});

it("can be passed Button components that can be pressed when open", () => {
const handleCancel = jest.fn();
const handleSubmit = jest.fn();
render(
<DialogTrigger>
<Button>Open</Button>
<Modal>
<AlertDialog
buttons={[
<Button
variant="secondary"
onPress={handleCancel}
key="secondary-button"
>
Cancel
</Button>,
<Button
variant="primary"
onPress={handleSubmit}
key="primary-button"
>
Submit
</Button>,
]}
>
Lorem ipsum
</AlertDialog>
</Modal>
</DialogTrigger>
);

const missingCancelButton = screen.queryByText(/cancel/i);
expect(missingCancelButton).not.toBeInTheDocument();
const missingSubmitButton = screen.queryByText(/submit/i);
expect(missingSubmitButton).not.toBeInTheDocument();

const openButton = screen.getByText(/open/i);
fireEvent.click(openButton);

const presentCancelButton = screen.getByText(/cancel/i);
const presentSubmitButton = screen.getByText(/submit/i);
fireEvent.click(presentCancelButton);
expect(handleCancel).toHaveBeenCalledTimes(1);
fireEvent.click(presentSubmitButton);
expect(handleSubmit).toHaveBeenCalledTimes(1);
});
});
112 changes: 112 additions & 0 deletions packages/react-components/src/components/AlertDialog/AlertDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import {
Dialog as ReactAriaDialog,
DialogTrigger,
DialogProps as ReactAriaDialogProps,
} from "react-aria-components";

import Button from "../Button";
import ButtonGroup from "../ButtonGroup";
import { DialogRenderProps } from "../Dialog/Dialog";
import SvgCheckCircleIcon from "../Icons/SvgCheckCircleIcon";
import SvgCloseIcon from "../Icons/SvgCloseIcon";
import SvgExclamationCircleIcon from "../Icons/SvgExclamationCircleIcon";
import SvgExclamationIcon from "../Icons/SvgExclamationIcon";
import SvgInfoIcon from "../Icons/SvgInfoIcon";

import "./AlertDialog.css";

export interface AlertDialogProps extends ReactAriaDialogProps {
/* Dialog theme */
variant?: "info" | "confirmation" | "warning" | "error" | "destructive";
/* Dialog title */
title?: string;
/* Show or hide left icon */
isIconHidden?: boolean;
/* Show or hide close button */
isCloseable?: boolean;
/* Array of Button components */
buttons?: React.ReactNode;
}

/* Sets correct left icon for selected variant */
function getIcon(variant: string) {
switch (variant) {
case "info":
return <SvgInfoIcon />;
case "confirmation":
return <SvgCheckCircleIcon />;
case "warning":
return <SvgExclamationIcon />;
case "error":
return <SvgExclamationCircleIcon />;
case "destructive":
return <SvgExclamationCircleIcon />;
default:
return;
}
}

export default function AlertDialog({
children,
variant = "info",
role = "dialog",
title,
isCloseable = true,
isIconHidden = false,
buttons,
...props
}: AlertDialogProps) {
return (
<ReactAriaDialog
className={`bcds-react-aria-AlertDialog ${variant}`}
role={role}
{...props}
>
{({ close }: DialogRenderProps) => (
<>
<div className="bcds-react-aria-AlertDialog--Header">
{!isIconHidden && (
<div className="bcds-react-aria-AlertDialog--Icon">
{getIcon(variant)}
</div>
)}
{title && (
<div className="bcds-react-aria-AlertDialog--Title" slot="title">
{title}
</div>
)}
{isCloseable && (
<div className="bcds-react-aria-AlertDialog--closeIcon">
<Button
variant="tertiary"
isIconButton
size="small"
aria-label="Close"
type="button"
onPress={close}
>
<SvgCloseIcon />
</Button>
</div>
)}
</div>
{children && (
<div
className="bcds-react-aria-AlertDialog--children"
slot="children"
>
<>{children}</>
</div>
)}
{buttons && (
<ButtonGroup alignment="end" orientation="horizontal">
{buttons}
</ButtonGroup>
)}
</>
)}
</ReactAriaDialog>
);
}

export { DialogTrigger };
2 changes: 2 additions & 0 deletions packages/react-components/src/components/AlertDialog/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default, DialogTrigger } from "./AlertDialog";
export type { AlertDialogProps } from "./AlertDialog";
22 changes: 22 additions & 0 deletions packages/react-components/src/components/Dialog/Dialog.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
.bcds-react-aria-Dialog {
display: flex;
flex-direction: column;
min-height: var(--layout-margin-xxxlarge);
}

.bcds-react-aria-Dialog--Container {
position: relative;
}

/* Close icon */
.bcds-react-aria-Dialog--closeIcon {
color: var(--icons-color-primary);
position: absolute;
top: 0;
right: 0;
padding: var(--layout-padding-small);
}
.bcds-react-aria-Dialog--closeIcon > svg {
min-width: var(--icons-size-medium);
height: var(--icons-size-medium);
}
Loading

0 comments on commit 2b948cf

Please sign in to comment.