Skip to content

Commit

Permalink
[@kadena/react-ui] Split the dialog/ modal and add tests
Browse files Browse the repository at this point in the history
  • Loading branch information
salamaashoush authored and eileenmguo committed Nov 7, 2023
1 parent ec1a431 commit 4b076bc
Show file tree
Hide file tree
Showing 19 changed files with 660 additions and 250 deletions.
5 changes: 5 additions & 0 deletions .changeset/few-worms-hang.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@kadena/react-ui': minor
---

Imporve `Dialog` and `Modal` components
3 changes: 3 additions & 0 deletions packages/libs/react-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
},
"dependencies": {
"@kadena/fonts": "~0.0.1",
"@react-aria/utils": "^3.21.1",
"@vanilla-extract/css": "1.13.0",
"@vanilla-extract/recipes": "0.4.0",
"@vanilla-extract/sprinkles": "1.6.1",
Expand Down Expand Up @@ -76,6 +77,7 @@
"@storybook/react-webpack5": "^7.4.0",
"@storybook/theming": "^7.4.0",
"@testing-library/react": "~14.0.0",
"@testing-library/user-event": "~14.5.1",
"@types/lodash.mapvalues": "^4.6.7",
"@types/lodash.omit": "^4.5.7",
"@types/node": "^18.17.14",
Expand All @@ -84,6 +86,7 @@
"@vanilla-extract/vite-plugin": "^3.9.0",
"@vanilla-extract/webpack-plugin": "2.2.0",
"@vitest/coverage-v8": "^0.34.6",
"@vitest/ui": "^0.34.6",
"babel-plugin-module-resolver": "^5.0.0",
"chromatic": "6.20.0",
"copyfiles": "2.4.1",
Expand Down
1 change: 0 additions & 1 deletion packages/libs/react-ui/src/components/Button/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { SystemIcon } from '@components/Icon';
import cn from 'classnames';
import type { ButtonHTMLAttributes, FC, ReactNode } from 'react';
import React from 'react';
import type { PressEvent } from 'react-aria';
import type { colorVariants, typeVariants } from './Button.css';
import {
activeClass,
Expand Down
53 changes: 53 additions & 0 deletions packages/libs/react-ui/src/components/Dialog/Dialog.css.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { sprinkles } from '@theme/sprinkles.css';
import { responsiveStyle } from '@theme/themeUtils';
import { vars } from '@theme/vars.css';
import { style } from '@vanilla-extract/css';

export const openModal = style([
{
height: '100vh',
overflowY: 'hidden',
},
]);

export const overlayClass = style([
sprinkles({
position: 'relative',
pointerEvents: 'initial',
overflow: 'auto',
width: '100%',
}),
responsiveStyle({
xs: {
maxHeight: '100svh',
maxWidth: '100vw',
inset: 0,
},
md: {
maxWidth: vars.sizes.$maxContent,
maxHeight: '75vh',
},
}),
]);

export const closeButtonClass = style([
sprinkles({
position: 'absolute',
top: '$md',
right: '$md',
display: 'flex',
alignItems: 'center',
background: 'none',
border: 'none',
padding: '$sm',
cursor: 'pointer',
color: 'inherit',
}),
]);

export const titleWrapperClass = style([
sprinkles({
marginBottom: '$4',
marginRight: '$20',
}),
]);
65 changes: 65 additions & 0 deletions packages/libs/react-ui/src/components/Dialog/Dialog.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import type { Meta, StoryObj } from '@storybook/react';
import React, { useState } from 'react';
import { Button } from '../Button';
import type { IModalProps } from '../Modal';
import { Text } from '../Typography';
import { Dialog } from './Dialog';

const meta: Meta<{ title?: string } & IModalProps> = {
title: 'Overlays/Dialog',
parameters: {
docs: {
description: {
component: `
A Dialog is a type of modal that is used to display information or prompt the user for input. It is a blocking modal, which means it will trap focus within itself and will not allow the user to interact with the rest of the page until it is closed. It is also dismissable, which means it can be closed by clicking on the close button or pressing the escape key. Dialogs are used for important information that requires the user to take action before continuing.
`,
},
},
},
};

export default meta;
type Story = StoryObj<IModalProps>;

export const DialogStory: Story = {
name: 'Dialog',
render: () => {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<Button onClick={() => setIsOpen(true)}>Modal Trigger</Button>
<Dialog
isOpen={isOpen}
onOpenChange={(isOpen) => setIsOpen(isOpen)}
title={
<>
<h2>Dialog Title</h2>
<p>Dialog description</p>
</>
}
>
{(state) => (
<>
<Text>
Dessert gummies pie biscuit chocolate bar cheesecake. Toffee
chocolate bar ice cream cake jujubes pudding fruitcake marzipan.
Donut sweet oat cake dragée candy cupcake biscuit. Carrot cake
sesame snaps marzipan gummies marshmallow topping cake apple pie
pudding. Toffee sweet halvah cake liquorice chupa chups sugar
plum. Tootsie roll marshmallow gummi bears apple pie cake
jujubes pudding. Halvah apple pie tiramisu bear claw caramels
cookie dessert cotton candy. Jelly-o sweet sugar plum topping
topping jujubes powder shortbread lemon drops. Chupa chups
muffin oat cake chupa chups cookie liquorice oat cake tootsie
roll. Gingerbread dessert donut pastry muffin powder sugar plum.
Chupa chups bonbon topping jelly beans pastry. Soufflé chupa
chups wafer fruitcake lollipop apple pie bonbon tart bonbon.
</Text>
<Button onClick={state.close}>Close Button</Button>
</>
)}
</Dialog>
</>
);
},
};
60 changes: 60 additions & 0 deletions packages/libs/react-ui/src/components/Dialog/Dialog.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { describe, expect, it } from 'vitest';

import { Dialog } from './Dialog';

describe('Modal', () => {
it('should render the provided children', () => {
render(
<Dialog isOpen>
<div>Hello, world!</div>
</Dialog>,
);

expect(screen.getByText('Hello, world!')).toBeInTheDocument();
});

it('should render the provided title', () => {
render(
<Dialog isOpen title="Title">
<div>Hello, world!</div>
</Dialog>,
);

expect(screen.getByText('Hello, world!')).toBeInTheDocument();
expect(screen.getByLabelText('Title')).toHaveAttribute('role', 'dialog');
});

it('should use custom aria-label correctly', () => {
render(
<Dialog isOpen aria-label="my own label" title="only visual title">
<div>Hello, world!</div>
</Dialog>,
);
expect(screen.getByLabelText('my own label')).toHaveAttribute(
'role',
'dialog',
);
});

it('should render the dialog when defaultOpen is true', () => {
render(
<Dialog defaultOpen>
<div>Hello, world!</div>
</Dialog>,
);
expect(screen.getByText('Hello, world!')).toBeInTheDocument();
});
it('should dismiss the dialog when the escape key is pressed', async () => {
render(
<Dialog defaultOpen>
<div>Hello, world!</div>
</Dialog>,
);

await userEvent.type(document.body, '{esc}');
expect(screen.queryByText('Hello, world!')).not.toBeInTheDocument();
});
});
118 changes: 118 additions & 0 deletions packages/libs/react-ui/src/components/Dialog/Dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { useObjectRef } from '@react-aria/utils';
import classNames from 'classnames';
import type { FC, ReactNode } from 'react';
import React from 'react';
import type { AriaDialogProps } from 'react-aria';
import { mergeProps, useDialog } from 'react-aria';
import type { OverlayTriggerState } from 'react-stately';
import { useOverlayTriggerState } from 'react-stately';
import { containerClass as cardContainerClass } from '../Card/Card.css';
import { SystemIcon } from '../Icon';
import type { IModalProps } from '../Modal/Modal';
import { Modal } from '../Modal/Modal';
import { Heading } from '../Typography';
import {
closeButtonClass,
overlayClass,
titleWrapperClass,
} from './Dialog.css';

interface IBaseDialogProps
extends Omit<IModalProps, 'children'>,
AriaDialogProps {
className?: string;
title?: ReactNode;
children?: ((state: OverlayTriggerState) => ReactNode) | ReactNode;
}

const BaseDialog = React.forwardRef<HTMLDivElement, IBaseDialogProps>(
(props, ref) => {
const {
title,
className,
children,
isDismissable = true,
state,
...rest
} = props;
const dialogRef = useObjectRef<HTMLDivElement | null>(ref);
const { dialogProps, titleProps } = useDialog(
{
role: props.role ?? 'dialog',
...rest,
},
dialogRef,
);

return (
<div
ref={dialogRef}
className={classNames(cardContainerClass, overlayClass, className)}
{...mergeProps(rest, dialogProps)}
>
{isDismissable && (
<button
className={closeButtonClass}
onClick={state.close}
aria-label="Close Modal"
title="Close Modal"
>
<SystemIcon.Close />
</button>
)}

{title && (
<div className={titleWrapperClass} {...titleProps}>
{typeof title === 'string' ? (
<Heading as="h3">{title}</Heading>
) : (
title
)}
</div>
)}
{typeof children === 'function' ? children(state) : children}
</div>
);
},
);

BaseDialog.displayName = 'BaseDialog';

export interface IDialogProps extends Omit<IBaseDialogProps, 'state'> {
children?: ((state: OverlayTriggerState) => ReactNode) | ReactNode;
isOpen?: boolean;
defaultOpen?: boolean;
onOpenChange?: (isOpen: boolean) => void;
}

export const Dialog: FC<IDialogProps> = ({
title,
className,
children,
isDismissable = true,
isKeyboardDismissDisabled,
isOpen,
defaultOpen,
onOpenChange,
...props
}) => {
const state = useOverlayTriggerState({
isOpen,
defaultOpen,
onOpenChange,
});

return (
<Modal isKeyboardDismissDisabled={isKeyboardDismissDisabled} state={state}>
<BaseDialog
state={state}
title={title}
className={className}
isDismissable={isDismissable}
{...props}
>
{children}
</BaseDialog>
</Modal>
);
};
1 change: 1 addition & 0 deletions packages/libs/react-ui/src/components/Dialog/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { Dialog, type IDialogProps } from './Dialog';
54 changes: 0 additions & 54 deletions packages/libs/react-ui/src/components/Modal/Dialog.tsx

This file was deleted.

Loading

0 comments on commit 4b076bc

Please sign in to comment.