From d916220f5d6f3e022ca8ec81e76ec69df2fc17e4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Jamro=C5=BCek?=
<167776055+mjamrozekvl@users.noreply.github.com>
Date: Tue, 7 May 2024 09:52:21 +0200
Subject: [PATCH] feat: NO-JIRA Corner Dialog component (#135)
* feat: NO-JIRA basic CornerDialog scaffold
* refactor: NO-JIRA styles builder and corner dialog docs
* refactor: NO-JIRA remove unnecessary stylesBuilder directory
* test: NO-JIRA add tests to CornerDialog
* docs: NO-JIRA add missing figma scenarios
* docs: NO-JIRA add custom content story example
* style: NO-JIRA sizes according to figma
* refactor: NO-JIRA export styles
* style: NO-JIRA set react event object to optional
* refactor: NO-JIRA exclude size and hasDropdownIndicator from action button props
* refactor: NO-JIRA replace color with appropriate variable
* refactor: NO-JIRA replace rest of wrongly defined variables
* fix: NO-JIRA introduce DistributiveOmit typescript utility type
reason described here: https://github.com/VirtusLab/tetrisly-react/pull/135#discussion_r1580900884
* style: NO-JIRA disable @typescript-eslint/no-explicit-any rule in DistributiveOmit util
* refactor: NO-JIRA remove unnecessary React. type prefix
* fix: NO-JIRA export CornerDialog and Status components in src/index.ts file
* fix: NO-JIRA use -500 instead of 40px unit
---
.../CornerDialog/CornerDialog.props.ts | 18 ++
.../CornerDialog/CornerDialog.stories.tsx | 170 ++++++++++++++++++
.../CornerDialog/CornerDialog.styles.ts | 84 +++++++++
.../CornerDialog/CornerDialog.test.tsx | 96 ++++++++++
src/components/CornerDialog/CornerDialog.tsx | 69 +++++++
src/components/CornerDialog/index.ts | 3 +
src/components/CornerDialog/stylesBuilder.ts | 39 ++++
src/docs-components/CornerDialogDocs.tsx | 59 ++++++
src/index.ts | 2 +
src/utility-types/DistributiveOmit.ts | 4 +
10 files changed, 544 insertions(+)
create mode 100644 src/components/CornerDialog/CornerDialog.props.ts
create mode 100644 src/components/CornerDialog/CornerDialog.stories.tsx
create mode 100644 src/components/CornerDialog/CornerDialog.styles.ts
create mode 100644 src/components/CornerDialog/CornerDialog.test.tsx
create mode 100644 src/components/CornerDialog/CornerDialog.tsx
create mode 100644 src/components/CornerDialog/index.ts
create mode 100644 src/components/CornerDialog/stylesBuilder.ts
create mode 100644 src/docs-components/CornerDialogDocs.tsx
create mode 100644 src/utility-types/DistributiveOmit.ts
diff --git a/src/components/CornerDialog/CornerDialog.props.ts b/src/components/CornerDialog/CornerDialog.props.ts
new file mode 100644
index 00000000..ff73464a
--- /dev/null
+++ b/src/components/CornerDialog/CornerDialog.props.ts
@@ -0,0 +1,18 @@
+import type { ReactNode, MouseEvent } from 'react';
+
+import { CornerDialogConfig } from './CornerDialog.styles';
+import { DefaultButtonProps } from '../Button/Button.props';
+
+import { DistributiveOmit } from '@/utility-types/DistributiveOmit';
+
+export type CornerDialogProps = {
+ custom?: CornerDialogConfig;
+ intent?: 'none' | 'warning' | 'negative';
+ title: string;
+ content: ReactNode;
+ actions?: DistributiveOmit<
+ DefaultButtonProps,
+ 'size' | 'hasDropdownIndicator'
+ >[];
+ onCloseClick?: (e?: MouseEvent) => void;
+};
diff --git a/src/components/CornerDialog/CornerDialog.stories.tsx b/src/components/CornerDialog/CornerDialog.stories.tsx
new file mode 100644
index 00000000..b0a9f2ed
--- /dev/null
+++ b/src/components/CornerDialog/CornerDialog.stories.tsx
@@ -0,0 +1,170 @@
+import { action } from '@storybook/addon-actions';
+import type { Meta, StoryObj } from '@storybook/react';
+
+import { CornerDialog } from './CornerDialog';
+
+import { CornerDialogDocs } from '@/docs-components/CornerDialogDocs';
+import { TetDocs } from '@/docs-components/TetDocs';
+import { tet } from '@/tetrisly';
+
+const meta = {
+ title: 'CornerDialog',
+ component: CornerDialog,
+ tags: ['autodocs'],
+ argTypes: {},
+ args: {
+ intent: 'none',
+ title: 'Corner Dialog',
+ content: 'Description',
+ actions: undefined,
+ onCloseClick: action('onCloseClick'),
+ },
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'A small, non-intrusive window that appears in the corner of the screen to convey contextual information or prompt user interaction. Often used for hints, tips, or non-essential notifications.',
+ },
+ page: () => (
+
+
+
+ ),
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ intent: 'none',
+ title: 'Corner Dialog',
+ content: 'Description',
+ actions: [
+ { label: 'Action', onClick: action('onClick') },
+ {
+ label: 'Primary Action',
+ onClick: action('onClick'),
+ appearance: 'primary',
+ },
+ ],
+ onCloseClick: action('onCloseClick'),
+ },
+};
+
+export const Decision: Story = {
+ args: {
+ intent: 'none',
+ title: 'Title',
+ content: 'Description',
+ actions: [
+ { label: 'Cancel', onClick: action('onCancelClick') },
+ {
+ label: 'Accept',
+ onClick: action('onAcceptClick'),
+ appearance: 'primary',
+ },
+ ],
+ onCloseClick: action('onCloseClick'),
+ },
+};
+
+export const Confirmation: Story = {
+ args: {
+ intent: 'none',
+ title: 'Title',
+ content: 'Description',
+ actions: [
+ {
+ label: 'Accept',
+ onClick: action('onAcceptClick'),
+ appearance: 'primary',
+ },
+ ],
+ onCloseClick: undefined,
+ },
+};
+
+export const NegativeWithDestructiveButton: Story = {
+ args: {
+ intent: 'negative',
+ title: 'Title',
+ content: 'Description',
+ actions: [
+ { label: 'Cancel', onClick: action('onCancelClick') },
+ {
+ label: 'Remove',
+ onClick: action('onRemoveClick'),
+ appearance: `primary`,
+ intent: 'destructive',
+ },
+ ],
+ onCloseClick: action('onCloseClick'),
+ },
+};
+
+export const WarningAndAdditionalAction: Story = {
+ args: {
+ intent: 'warning',
+ title: 'Title',
+ content: 'Description',
+ actions: [
+ {
+ label: 'Find out more',
+ onClick: action('onFindOutMoreClick'),
+ custom: {
+ default: {
+ position: 'absolute',
+ left: 0,
+ },
+ },
+ },
+ { label: 'Cancel', onClick: action('onCancelClick') },
+ {
+ label: 'Accept',
+ onClick: action('onAcceptClick'),
+ appearance: 'primary',
+ },
+ ],
+ onCloseClick: action('onCloseClick'),
+ custom: {
+ innerElements: {
+ footer: {
+ position: 'relative',
+ },
+ },
+ },
+ },
+};
+
+export const CustomContent: Story = {
+ args: {
+ intent: 'none',
+ title: 'Corner Dialog with custom content',
+ content: (
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit.{' '}
+
+ Morbi pellentesque elit ut sem accumsan, eget maximus erat eleifend.
+
+ Vestibulum ac tortor nunc.{' '}
+
+ Nam tincidunt nibh eget nulla aliquet, et auctor dui rhoncus. Donec
+ bibendum rhoncus lacus vel scelerisque.
+
+ Suspendisse feugiat ligula quis eros interdum varius. Ut nec ex est.
+
+ ),
+ actions: [
+ { label: 'Action', onClick: action('onClick') },
+ {
+ label: 'Primary Action',
+ onClick: action('onClick'),
+ appearance: 'primary',
+ },
+ ],
+ onCloseClick: action('onCloseClick'),
+ },
+};
diff --git a/src/components/CornerDialog/CornerDialog.styles.ts b/src/components/CornerDialog/CornerDialog.styles.ts
new file mode 100644
index 00000000..c43b69b7
--- /dev/null
+++ b/src/components/CornerDialog/CornerDialog.styles.ts
@@ -0,0 +1,84 @@
+import type { BaseProps } from '@/types/BaseProps';
+
+export type CornerDialogFooterConfig = {
+ actions?: BaseProps;
+} & BaseProps;
+
+export type CornerDialogConfig = BaseProps & {
+ innerElements?: {
+ intentIndicator?: BaseProps;
+ intentWarning?: BaseProps;
+ intentNegative?: BaseProps;
+ body?: BaseProps;
+ header?: BaseProps;
+ headerTitle?: BaseProps;
+ closeButton?: BaseProps;
+ content?: BaseProps;
+ footer?: CornerDialogFooterConfig;
+ };
+};
+
+export const defaultConfig = {
+ display: 'flex',
+ w: 'fit-content',
+ minWidth: '420px',
+ p: '$space-component-padding-2xLarge',
+ flexDirection: 'row',
+ alignItems: 'flex-start',
+ gap: '$space-component-padding-large',
+ borderRadius: '$border-radius-xLarge',
+ bg: '$color-interaction-background-modeless',
+ boxShadow: '$elevation-bottom-300',
+ borderWidth: '$border-width-small',
+ borderStyle: '$border-style-solid',
+ borderColor: '$color-border-defaultA',
+ overflow: 'hidden',
+ innerElements: {
+ intentIndicator: {
+ h: '$size-xSmall',
+ display: 'flex',
+ alignItems: 'flex-end',
+ },
+ intentWarning: {
+ color: '$color-content-warning-secondary',
+ },
+ intentNegative: {
+ color: '$color-content-negative-secondary',
+ },
+ body: {
+ display: 'flex',
+ flexGrow: 1,
+ flexDirection: 'column',
+ justifyContent: 'space-between',
+ gap: '$space-component-padding-large',
+ },
+ header: {
+ display: 'flex',
+ alignSelf: 'stretch',
+ alignItems: 'center',
+ },
+ headerTitle: {
+ display: 'flex',
+ flexGrow: 1,
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ color: '$color-content-primary',
+ text: '$typo-body-strong-large',
+ },
+ closeButton: {},
+ content: {
+ text: '$typo-body-medium',
+ color: '$color-content-secondary',
+ },
+ footer: {
+ display: 'flex',
+ alignSelf: 'stretch',
+ justifyContent: 'flex-end',
+ gap: '$space-component-padding-small',
+ },
+ },
+} as const satisfies CornerDialogConfig;
+
+export const cornerDialogStyles = {
+ defaultConfig,
+};
diff --git a/src/components/CornerDialog/CornerDialog.test.tsx b/src/components/CornerDialog/CornerDialog.test.tsx
new file mode 100644
index 00000000..c697d6a6
--- /dev/null
+++ b/src/components/CornerDialog/CornerDialog.test.tsx
@@ -0,0 +1,96 @@
+import { vi } from 'vitest';
+
+import { CornerDialog } from './CornerDialog';
+import { render, screen, fireEvent } from '../../tests/render';
+
+describe('CornerDialog', () => {
+ it('should render empty corner dialog', () => {
+ render();
+ const cornerDialog = screen.getByTestId('corner-dialog');
+ expect(cornerDialog).toBeInTheDocument();
+
+ const header = screen.getByTestId('corner-dialog-header');
+ expect(header).toBeInTheDocument();
+
+ const headerTitle = screen.getByTestId('corner-dialog-header-title');
+ expect(headerTitle).toBeInTheDocument();
+ expect(headerTitle).toHaveTextContent('Title');
+
+ const content = screen.getByTestId('corner-dialog-content');
+ expect(content).toBeInTheDocument();
+ expect(content).toHaveTextContent('Content');
+ });
+
+ it('should render warning corner dialog', () => {
+ render();
+ const warningIcon = screen.getByTestId('warning-icon');
+ expect(warningIcon).toBeInTheDocument();
+ });
+
+ it('should render negative corner dialog', () => {
+ render();
+ const negativeIcon = screen.getByTestId('negative-icon');
+ expect(negativeIcon).toBeInTheDocument();
+ });
+
+ it('should render close icon when onCloseClick handler is provided', () => {
+ render(
+ {}}
+ />,
+ );
+ const closeIcon = screen.getByTestId('close-icon');
+ expect(closeIcon).toBeInTheDocument();
+ });
+
+ it('should render footer if at least one action is provided', () => {
+ render(
+ ,
+ );
+ const footer = screen.getByTestId('corner-dialog-footer');
+ expect(footer).toBeInTheDocument();
+ });
+
+ it('should render footer with 2 actions', async () => {
+ render(
+ ,
+ );
+
+ const firstActionButton = await screen.findByText('First action');
+ expect(firstActionButton).toBeInTheDocument();
+
+ const secondActionButton = await screen.findByText('Second action');
+ expect(secondActionButton).toBeInTheDocument();
+ });
+
+ it('should call onCloseClick after click to close icon', () => {
+ const onCloseClickMock = vi.fn();
+
+ render(
+ ,
+ );
+
+ const closeIcon = screen.getByTestId('close-icon');
+ expect(closeIcon).toBeInTheDocument();
+ fireEvent.click(closeIcon);
+ expect(onCloseClickMock).toHaveBeenCalled();
+ });
+});
diff --git a/src/components/CornerDialog/CornerDialog.tsx b/src/components/CornerDialog/CornerDialog.tsx
new file mode 100644
index 00000000..770f0e24
--- /dev/null
+++ b/src/components/CornerDialog/CornerDialog.tsx
@@ -0,0 +1,69 @@
+import { Icon } from '@virtuslab/tetrisly-icons';
+import { FC } from 'react';
+
+import { CornerDialogProps } from './CornerDialog.props';
+import { stylesBuilder } from './stylesBuilder';
+
+import { Button } from '@/components/Button';
+import { IconButton } from '@/components/IconButton';
+import { tet } from '@/tetrisly';
+
+export const CornerDialog: FC = ({
+ custom,
+ intent = 'none',
+ title,
+ content,
+ actions,
+ onCloseClick,
+}) => {
+ const styles = stylesBuilder(custom);
+
+ return (
+
+ {intent === 'warning' && (
+
+
+
+ )}
+
+ {intent === 'negative' && (
+
+
+
+ )}
+
+
+
+
+ {title}
+ {!!onCloseClick && (
+
+ )}
+
+
+
+
+ {content}
+
+
+ {actions && (
+
+ {actions.map((action) => (
+
+ ))}
+
+ )}
+
+
+ );
+};
diff --git a/src/components/CornerDialog/index.ts b/src/components/CornerDialog/index.ts
new file mode 100644
index 00000000..593db68c
--- /dev/null
+++ b/src/components/CornerDialog/index.ts
@@ -0,0 +1,3 @@
+export * from './CornerDialog';
+export * from './CornerDialog.props';
+export { cornerDialogStyles } from './CornerDialog.styles';
diff --git a/src/components/CornerDialog/stylesBuilder.ts b/src/components/CornerDialog/stylesBuilder.ts
new file mode 100644
index 00000000..edec6fd1
--- /dev/null
+++ b/src/components/CornerDialog/stylesBuilder.ts
@@ -0,0 +1,39 @@
+import { CornerDialogConfig, defaultConfig } from './CornerDialog.styles';
+
+import { mergeConfigWithCustom } from '@/services/mergeConfigWithCustom/mergeConfigWithCutom';
+import { BaseProps } from '@/types/BaseProps';
+
+type CornerDialogStylesBuilder = {
+ container: BaseProps;
+ intentIndicator: BaseProps;
+ intentWarning: BaseProps;
+ intentNegative: BaseProps;
+ body: BaseProps;
+ header: BaseProps;
+ headerTitle: BaseProps;
+ closeButton: BaseProps<'appearance'>;
+ content: BaseProps;
+ footer: BaseProps;
+};
+
+export const stylesBuilder = (
+ custom?: CornerDialogConfig,
+): CornerDialogStylesBuilder => {
+ const { innerElements, ...container } = mergeConfigWithCustom({
+ defaultConfig,
+ custom,
+ });
+
+ return {
+ container,
+ intentIndicator: innerElements.intentIndicator,
+ intentWarning: innerElements.intentWarning,
+ intentNegative: innerElements.intentNegative,
+ body: innerElements.body,
+ header: innerElements.header,
+ headerTitle: innerElements.headerTitle,
+ closeButton: innerElements.closeButton,
+ content: innerElements.content,
+ footer: innerElements.footer,
+ };
+};
diff --git a/src/docs-components/CornerDialogDocs.tsx b/src/docs-components/CornerDialogDocs.tsx
new file mode 100644
index 00000000..337d0a77
--- /dev/null
+++ b/src/docs-components/CornerDialogDocs.tsx
@@ -0,0 +1,59 @@
+import { capitalize } from 'lodash';
+
+import { SectionHeader } from './common/SectionHeader';
+
+import { CornerDialog } from '@/components/CornerDialog';
+import { tet } from '@/tetrisly';
+
+const intents = ['none', 'warning', 'negative'] as const;
+
+export const CornerDialogDocs = () => (
+
+
+ Intent
+
+
+ {intents.map((intent) => (
+
+
+ {capitalize(intent)}
+
+
+
+ {}}
+ />
+
+
+ ))}
+
+
+);
diff --git a/src/index.ts b/src/index.ts
index f8d3dbfb..cfb53891 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -4,6 +4,7 @@ export * from './components/Badge';
export * from './components/Button';
export * from './components/Checkbox';
export * from './components/CheckboxGroup';
+export * from './components/CornerDialog';
export * from './components/Counter';
export * from './components/Divider';
export * from './components/HelperText';
@@ -20,6 +21,7 @@ export * from './components/RadioButtonGroup';
export * from './components/SearchInput';
export * from './components/Select';
export * from './components/SocialButton';
+export * from './components/Status';
export * from './components/StatusDot';
export * from './components/Tag';
export * from './components/TextInput';
diff --git a/src/utility-types/DistributiveOmit.ts b/src/utility-types/DistributiveOmit.ts
new file mode 100644
index 00000000..d5b0cc9e
--- /dev/null
+++ b/src/utility-types/DistributiveOmit.ts
@@ -0,0 +1,4 @@
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export type DistributiveOmit = T extends any
+ ? Omit
+ : never;