From c81b1a6997eab528f56f6ef54e565dc947d48ce5 Mon Sep 17 00:00:00 2001 From: Karolina Szarek <74671633+karolinaszarek@users.noreply.github.com> Date: Thu, 25 Apr 2024 10:35:57 +0200 Subject: [PATCH 1/4] feat: TET-863 add Status component (#134) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: TET-863 add Status component * feat: TET-863 add type to an import Co-authored-by: Mateusz Wlekliński <114148518+mwleklinskiVL@users.noreply.github.com> * feat: TET-863 review changes * feat: TET-863 review changes * feat: TET-863 fix styling * feat: TET-863 change required prop * feat: TET-863 move stylesBuilder into main component folder * feat: TET-863-fix-build * feat: TET-863 fix dot styles --------- Co-authored-by: Mateusz Wlekliński <114148518+mwleklinskiVL@users.noreply.github.com> --- src/components/Status/Status.props.ts | 10 + src/components/Status/Status.stories.tsx | 49 ++++ src/components/Status/Status.styles.ts | 214 ++++++++++++++++++ src/components/Status/Status.test.tsx | 117 ++++++++++ src/components/Status/Status.tsx | 26 +++ .../Status/StatusAppearance.type.ts | 1 + src/components/Status/StatusEmphasis.type.ts | 1 + src/components/Status/index.ts | 3 + src/components/Status/stylesBuilder.ts | 44 ++++ src/docs-components/StatusDocs.tsx | 62 +++++ 10 files changed, 527 insertions(+) create mode 100644 src/components/Status/Status.props.ts create mode 100644 src/components/Status/Status.stories.tsx create mode 100644 src/components/Status/Status.styles.ts create mode 100644 src/components/Status/Status.test.tsx create mode 100644 src/components/Status/Status.tsx create mode 100644 src/components/Status/StatusAppearance.type.ts create mode 100644 src/components/Status/StatusEmphasis.type.ts create mode 100644 src/components/Status/index.ts create mode 100644 src/components/Status/stylesBuilder.ts create mode 100644 src/docs-components/StatusDocs.tsx diff --git a/src/components/Status/Status.props.ts b/src/components/Status/Status.props.ts new file mode 100644 index 00000000..885e0a86 --- /dev/null +++ b/src/components/Status/Status.props.ts @@ -0,0 +1,10 @@ +import { StatusConfig } from './Status.styles'; +import type { StatusAppearance } from './StatusAppearance.type'; +import type { StatusEmphasis } from './StatusEmphasis.type'; + +export type StatusProps = { + appearance?: StatusAppearance; + custom?: StatusConfig; + emphasis?: StatusEmphasis; + label: string; +}; diff --git a/src/components/Status/Status.stories.tsx b/src/components/Status/Status.stories.tsx new file mode 100644 index 00000000..8b3dfc50 --- /dev/null +++ b/src/components/Status/Status.stories.tsx @@ -0,0 +1,49 @@ +import { Meta, StoryObj } from '@storybook/react'; + +import { Status } from './Status'; +import { StatusEmphasis } from './StatusEmphasis.type'; + +import { StatusDocs } from '@/docs-components/StatusDocs'; +import { TetDocs } from '@/docs-components/TetDocs'; + +const meta = { + title: 'Status', + component: Status, + tags: ['autodocs'], + argTypes: { + emphasis: { + control: { + type: 'select', + options: ['high', 'medium', 'low'] satisfies StatusEmphasis[], + }, + }, + }, + args: { + appearance: 'grey', + emphasis: 'high', + }, + parameters: { + docs: { + description: { + component: + 'An indicator that conveys the current state or condition of a process, item, or user. Status indicators often use colors, icons, or text to communicate information, such as online presence, approval, or completion.', + }, + page: () => ( + + + + ), + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + appearance: 'grey', + emphasis: 'high', + label: 'status', + }, +}; diff --git a/src/components/Status/Status.styles.ts b/src/components/Status/Status.styles.ts new file mode 100644 index 00000000..1f6927ad --- /dev/null +++ b/src/components/Status/Status.styles.ts @@ -0,0 +1,214 @@ +import type { StatusAppearance } from './StatusAppearance.type'; + +import { BaseProps } from '@/types'; + +export type StatusConfig = { + appearance?: Partial< + Record< + StatusAppearance, + { emphasis?: Partial> } + > + >; + dot: { + appearance: Partial< + Record< + StatusAppearance, + { emphasis?: Partial> } + > + >; + } & BaseProps<'appearance'>; + hasLabel?: BaseProps; + innerElements: { + label?: BaseProps; + }; +} & BaseProps<'appearance'>; + +export const defaultConfig = { + display: 'flex', + w: 'fit-content', + h: '$size-xSmall', + gap: '$space-component-gap-small', + alignItems: 'center', + text: '$typo-body-medium', + dot: { + w: '8px', + h: '8px', + borderRadius: '$border-radius-full', + appearance: { + grey: { + emphasis: { + high: { + backgroundColor: '$color-nonSemantic-white-content-primary', + }, + medium: { + backgroundColor: '$color-nonSemantic-grey-background-strong', + }, + low: { + backgroundColor: '$color-nonSemantic-grey-background-strong', + }, + }, + }, + blue: { + emphasis: { + high: { + backgroundColor: '$color-nonSemantic-white-content-primary', + }, + medium: { + backgroundColor: '$color-nonSemantic-blue-background-strong', + }, + low: { + backgroundColor: '$color-nonSemantic-blue-background-strong', + }, + }, + }, + green: { + emphasis: { + high: { + backgroundColor: '$color-nonSemantic-white-content-primary', + }, + medium: { + backgroundColor: '$color-nonSemantic-green-background-strong', + }, + low: { + backgroundColor: '$color-nonSemantic-green-background-strong', + }, + }, + }, + red: { + emphasis: { + high: { + backgroundColor: '$color-nonSemantic-white-content-primary', + }, + medium: { + backgroundColor: '$color-nonSemantic-red-background-strong', + }, + low: { + backgroundColor: '$color-nonSemantic-red-background-strong', + }, + }, + }, + orange: { + emphasis: { + high: { + backgroundColor: '$color-nonSemantic-grey-content-primary', + }, + medium: { + backgroundColor: '$color-nonSemantic-orange-background-strong', + }, + low: { + backgroundColor: '$color-nonSemantic-orange-background-strong', + }, + }, + }, + }, + }, + appearance: { + grey: { + emphasis: { + high: { + backgroundColor: '$color-nonSemantic-grey-background-strong', + color: '$color-nonSemantic-white-content-primary', + p: '$space-component-padding-null $space-component-padding-small', + borderRadius: '$border-radius-medium', + }, + medium: { + backgroundColor: '$color-nonSemantic-grey-background-muted', + color: '$color-nonSemantic-grey-content-primary', + p: '$space-component-padding-null $space-component-padding-small', + borderRadius: '$border-radius-medium', + }, + low: { + backgroundColor: '$color-transparent', + color: '$color-nonSemantic-grey-content-primary', + borderRadius: '$border-radius-medium', + }, + }, + }, + blue: { + emphasis: { + high: { + backgroundColor: '$color-nonSemantic-blue-background-strong', + color: '$color-nonSemantic-white-content-primary', + p: '$space-component-padding-null $space-component-padding-small', + borderRadius: '$border-radius-medium', + }, + medium: { + backgroundColor: '$color-nonSemantic-blue-background-muted', + color: '$color-nonSemantic-blue-content-primary', + p: '$space-component-padding-null $space-component-padding-small', + borderRadius: '$border-radius-medium', + }, + low: { + backgroundColor: '$color-transparent', + color: '$color-nonSemantic-blue-content-primary', + }, + }, + }, + green: { + emphasis: { + high: { + color: '$color-nonSemantic-white-content-primary', + backgroundColor: '$color-nonSemantic-green-background-strong', + p: '$space-component-padding-null $space-component-padding-small', + borderRadius: '$border-radius-medium', + }, + medium: { + color: '$color-nonSemantic-green-content-primary', + backgroundColor: '$color-nonSemantic-green-background-muted', + p: '$space-component-padding-null $space-component-padding-small', + borderRadius: '$border-radius-medium', + }, + low: { + color: '$color-nonSemantic-green-content-primary', + }, + }, + }, + red: { + emphasis: { + high: { + color: '$color-nonSemantic-white-content-primary', + backgroundColor: '$color-nonSemantic-red-background-strong', + p: '$space-component-padding-null $space-component-padding-small', + borderRadius: '$border-radius-medium', + }, + medium: { + color: '$color-nonSemantic-red-content-primary', + backgroundColor: '$color-nonSemantic-red-background-muted', + p: '$space-component-padding-null $space-component-padding-small', + borderRadius: '$border-radius-medium', + }, + low: { + color: '$color-nonSemantic-red-content-primary', + backgroundColor: '$color-transparent', + }, + }, + }, + orange: { + emphasis: { + high: { + backgroundColor: '$color-nonSemantic-orange-background-strong', + color: '$color-nonSemantic-grey-content-primary', + p: '$space-component-padding-null $space-component-padding-small', + borderRadius: '$border-radius-medium', + }, + medium: { + color: '$color-nonSemantic-orange-content-primary', + backgroundColor: '$color-nonSemantic-orange-background-muted', + p: '$space-component-padding-null $space-component-padding-small', + borderRadius: '$border-radius-medium', + }, + low: { + color: '$color-nonSemantic-orange-content-primary', + backgroundColor: '$color-transparent', + }, + }, + }, + }, + innerElements: { + label: {}, + }, +} satisfies StatusConfig; + +export const statusStyles = { + defaultConfig, +}; diff --git a/src/components/Status/Status.test.tsx b/src/components/Status/Status.test.tsx new file mode 100644 index 00000000..074e7b4d --- /dev/null +++ b/src/components/Status/Status.test.tsx @@ -0,0 +1,117 @@ +import { Status } from './Status'; +import { render } from '../../tests/render'; + +import { customPropTester } from '@/tests/customPropTester'; + +const getStatus = (jsx: JSX.Element) => { + const { getByTestId } = render(jsx); + return { + status: getByTestId('status'), + dot: getByTestId('status-dot'), + }; +}; + +describe('Status', () => { + it('should render the status', () => { + const { status } = getStatus(); + expect(status).toBeInTheDocument(); + }); + + it('should render a correct color (grey, high)', () => { + const { status, dot } = getStatus( + , + ); + expect(status).toHaveStyle( + 'background-color: rgb(85, 95, 109); color: rgb(255, 255, 255)', + ); + expect(dot).toHaveStyle('background-color: rgb(255, 255, 255)'); + }); + + it('should render a correct color (grey, medium)', () => { + const { status, dot } = getStatus( + , + ); + expect(status).toHaveStyle( + 'background-color: rgb(222, 227, 231); color: rgb(39, 46, 53)', + ); + expect(dot).toHaveStyle('background-color: rgb(85, 95, 109)'); + }); + + it('should render a correct color (grey, low)', () => { + const { status, dot } = getStatus( + , + ); + expect(status).toHaveStyle( + 'background-color: rgba(255, 255, 255, 0); color: rgb(39, 46, 53)', + ); + expect(dot).toHaveStyle('background-color: rgb(85, 95, 109)'); + }); + + it('should render a correct color (blue, high)', () => { + const { status, dot } = getStatus( + , + ); + expect(status).toHaveStyle( + 'background-color: rgb(48, 98, 212); color: rgb(255, 255, 255)', + ); + expect(dot).toHaveStyle('background-color: rgb(255, 255, 255)'); + }); + + it('should render a correct color (green, high)', () => { + const { status, dot } = getStatus( + , + ); + expect(status).toHaveStyle( + 'background-color: rgb(29, 124, 77); color: rgb(255, 255, 255)', + ); + expect(dot).toHaveStyle('background-color: rgb(255, 255, 255)'); + }); + + it('should render a correct color (red, high)', () => { + const { status, dot } = getStatus( + , + ); + expect(status).toHaveStyle( + 'background-color: rgb(197, 52, 52); color: rgb(255, 255, 255)', + ); + expect(dot).toHaveStyle('background-color: rgb(255, 255, 255)'); + }); + + it('should render a correct color (orange, high)', () => { + const { status, dot } = getStatus( + , + ); + expect(status).toHaveStyle( + 'background-color: rgb(245, 150, 56); color: rgb(39, 46, 53)', + ); + expect(dot).toHaveStyle('background-color: rgb(39, 46, 53)'); + }); + + it('should render a correct color (orange, medium)', () => { + const { status, dot } = getStatus( + , + ); + expect(status).toHaveStyle( + 'background-color: rgb(252, 222, 192); color: rgb(122, 69, 16)', + ); + expect(dot).toHaveStyle('background-color: rgb(245, 150, 56)'); + }); + + it('should render a correct color (orange, low)', () => { + const { status, dot } = getStatus( + , + ); + expect(status).toHaveStyle( + 'background-color: rgba(255, 255, 255, 0); color: rgb(122, 69, 16)', + ); + expect(dot).toHaveStyle('background-color: rgb(245, 150, 56)'); + }); + + customPropTester(, { + containerId: 'status', + props: { + appearance: ['grey', 'blue', 'green', 'red', 'orange'], + emphasis: ['high', 'medium', 'low'], + }, + }); +}); diff --git a/src/components/Status/Status.tsx b/src/components/Status/Status.tsx new file mode 100644 index 00000000..4673c850 --- /dev/null +++ b/src/components/Status/Status.tsx @@ -0,0 +1,26 @@ +import { FC, useMemo } from 'react'; + +import { StatusProps } from './Status.props'; +import { stylesBuilder } from './stylesBuilder'; + +import { tet } from '@/tetrisly'; + +export const Status: FC = ({ + appearance, + emphasis, + label, + custom, + ...restProps +}) => { + const styles = useMemo( + () => stylesBuilder(appearance, custom, emphasis), + [custom, emphasis, appearance], + ); + + return ( + + + {label} + + ); +}; diff --git a/src/components/Status/StatusAppearance.type.ts b/src/components/Status/StatusAppearance.type.ts new file mode 100644 index 00000000..c8b44b22 --- /dev/null +++ b/src/components/Status/StatusAppearance.type.ts @@ -0,0 +1 @@ +export type StatusAppearance = 'grey' | 'blue' | 'green' | 'red' | 'orange'; diff --git a/src/components/Status/StatusEmphasis.type.ts b/src/components/Status/StatusEmphasis.type.ts new file mode 100644 index 00000000..9a95eb87 --- /dev/null +++ b/src/components/Status/StatusEmphasis.type.ts @@ -0,0 +1 @@ +export type StatusEmphasis = 'high' | 'medium' | 'low'; diff --git a/src/components/Status/index.ts b/src/components/Status/index.ts new file mode 100644 index 00000000..9dd9a067 --- /dev/null +++ b/src/components/Status/index.ts @@ -0,0 +1,3 @@ +export { Status } from './Status'; +export type { StatusProps } from './Status.props'; +export { statusStyles } from './Status.styles'; diff --git a/src/components/Status/stylesBuilder.ts b/src/components/Status/stylesBuilder.ts new file mode 100644 index 00000000..b939d908 --- /dev/null +++ b/src/components/Status/stylesBuilder.ts @@ -0,0 +1,44 @@ +import { defaultConfig, StatusConfig } from './Status.styles'; +import type { StatusAppearance } from './StatusAppearance.type'; +import type { StatusEmphasis } from './StatusEmphasis.type'; + +import { mergeConfigWithCustom } from '@/services'; +import { BaseProps } from '@/types'; + +type StylesBuilderParams = { + container: BaseProps; + dot: BaseProps; +}; + +export const stylesBuilder = ( + appearance?: StatusAppearance, + custom?: StatusConfig, + emphasis?: StatusEmphasis, +): StylesBuilderParams => { + const { + innerElements, + appearance: containerAppearance, + dot, + ...container + } = mergeConfigWithCustom({ defaultConfig, custom }); + + const { appearance: dotAppearanceStyle, ...dotAppearance } = dot; + + const containerStyles = + appearance && + emphasis && + containerAppearance[appearance].emphasis[emphasis]; + const dotStyles = + appearance && emphasis && dot.appearance[appearance].emphasis[emphasis]; + + return { + container: { + ...container, + ...containerStyles, + }, + dot: { + ...dotAppearance, + ...dotStyles, + }, + }; +}; diff --git a/src/docs-components/StatusDocs.tsx b/src/docs-components/StatusDocs.tsx new file mode 100644 index 00000000..b2dcb865 --- /dev/null +++ b/src/docs-components/StatusDocs.tsx @@ -0,0 +1,62 @@ +import { capitalize } from 'lodash'; + +import { SectionHeader } from './common/SectionHeader'; +import { State } from './common/States'; + +import { Status } from '@/components/Status'; +import { tet } from '@/tetrisly'; + +const appearances = ['grey', 'blue', 'green', 'red', 'orange'] as const; +const emphases = ['high', 'medium', 'low'] as const; + +export const StatusDocs = () => ( + <> + {emphases.map((emphasis) => ( + + + {capitalize(emphasis)} Emphasis + + + + {appearances.map((appearance, i) => ( + + + + + + + ))} + + + + ))} + +); 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 2/4] 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) => ( +