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) => ( +