diff --git a/src/components/error/error-boundary.tsx b/src/components/error/error-boundary.tsx new file mode 100644 index 0000000..7d13d8b --- /dev/null +++ b/src/components/error/error-boundary.tsx @@ -0,0 +1,24 @@ +import React from 'react'; + +import IonIcon from '../icons/icons'; +import { ErrorBoundaryStyled } from './styled'; + +export interface ErrorBoundaryProps { + msg: string; +} +const sizeIcon = 16; +const iconType = 'info'; + +const ErrorBoundary = ({ msg }: ErrorBoundaryProps) => { + return ( + + +
+ + {msg} +
+
+ ); +}; + +export default ErrorBoundary; diff --git a/src/components/error/styled.ts b/src/components/error/styled.ts new file mode 100644 index 0000000..98096fe --- /dev/null +++ b/src/components/error/styled.ts @@ -0,0 +1,45 @@ +import stitches from '../../stitches.config'; + +const { styled } = stitches; + +export const ErrorBoundaryStyled = styled('div', { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + padding: '8px 16px 8px 12px', + gap: 8, + + width: 'max-content', + height: 24, + + backgroundColor: '$warning1', + + borderWidth: '1px 1px 1px 8px', + borderStyle: 'solid', + borderColor: '$warning7', + borderRadius: '8px', + + fontSize: 14, + color: '$neutral8', + + svg: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + gap: 8, + + backgroundColor: '$warning7', + fill: '$warning1', + + borderRadius: 8, + }, + + div: { + display: 'flex', + gap: 4, + }, + + label: { + fontWeight: 'bold', + }, +}); diff --git a/src/components/index.ts b/src/components/index.ts index b99ee7c..01c2035 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,3 +1,4 @@ export { default as Button } from './button'; export { default as Icon } from './icons'; export { default as IonChip } from './chip'; +export { default as IonTag } from './tag'; diff --git a/src/components/tag/index.ts b/src/components/tag/index.ts new file mode 100644 index 0000000..4a514b3 --- /dev/null +++ b/src/components/tag/index.ts @@ -0,0 +1 @@ +export { default } from './tag'; diff --git a/src/components/tag/styles.ts b/src/components/tag/styles.ts new file mode 100644 index 0000000..b4b3f15 --- /dev/null +++ b/src/components/tag/styles.ts @@ -0,0 +1,59 @@ +import stitches from '../../stitches.config'; + +const { styled } = stitches; + +const setColors = (bgColor: string, color: string) => ({ + backgroundColor: bgColor, + color: color, + svg: { + fill: color, + }, +}); + +export const TagStyle = styled('div', { + display: 'flex', + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + padding: '2px 8px', + gap: '6px', + + width: 'max-content', + height: 'max-content', + minHeight: '20px', + + backgroundColor: '$whitetransparence90', + color: '$neutral7', + borderRadius: '50px', + + span: { + fontSize: '12px', + fontWeight: '400', + lineHeight: '16px', + }, + + variants: { + status: { + success: { + ...setColors('$positive1', '$positive7'), + }, + info: { + ...setColors('$info1', '$info7'), + }, + warning: { + ...setColors('$warning1', '$warning7'), + }, + negative: { + ...setColors('$negative1', '$negative7'), + }, + neutral: { + ...setColors('$neutral2', '$neutral7'), + }, + }, + outline: { + true: { + border: '1px solid', + }, + }, + }, +}); diff --git a/src/components/tag/tag.test.tsx b/src/components/tag/tag.test.tsx new file mode 100644 index 0000000..3bfa52d --- /dev/null +++ b/src/components/tag/tag.test.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; + +import IonTag, { IonTagProps } from './tag'; +import { StatusType } from '../../core/types/status'; + +const defaultTag: IonTagProps = { + label: 'tag label', +}; + +const sut = (props = defaultTag) => render(); +const tagId = 'ion-tag'; +const getTag = () => screen.getByTestId(tagId); + +const customColor = '#AADD00'; + +describe('IonTag', () => { + it('should render default tag ', () => { + sut(); + expect(getTag()).toBeTruthy(); + }); + + it('should render tag with label "example tag"', async () => { + const customLabel = 'example tag'; + await sut({ label: customLabel }); + expect(screen.findByText(customLabel)).toBeTruthy(); + }); + + it.each([ + 'success', + 'info', + 'warning', + 'negative', + 'neutral', + ] as StatusType[])('should render tag with status: %s', (status) => { + sut({ ...defaultTag, status: status }); + expect(getTag().className).toContain(`status-${status}`); + }); + + it('should not render outline in tag', async () => { + await sut({ ...defaultTag, outline: false }); + expect(getTag().className).not.toContain('outline-false'); + }); + + it('should render outline in tag', async () => { + await sut({ ...defaultTag, outline: true }); + expect(getTag().className).toContain('outline-true'); + }); + + it('should render tag with icon check', async () => { + const iconType = 'check'; + await sut({ ...defaultTag, icon: iconType }); + const icon = screen.getByTestId(`ion-icon-${iconType}`); + expect(icon).toBeTruthy(); + }); + + it('should render ErrorBoundary component when not exist label', async () => { + sut({ label: '' }); + const msgError = 'Label cannot be empty'; + const errorBoundary = screen.getByTestId('ion-error-boundary'); + expect(errorBoundary).toBeTruthy(); + expect(await screen.findByText(msgError)).toBeTruthy(); + }); + + it('should render tag with custom color', async () => { + await sut({ ...defaultTag, color: customColor }); + expect(getTag().className).not.toContain('status'); + }); + + it('should render the tag the same as it has a custom color', async () => { + const statusInfo = 'status-info'; + await sut({ ...defaultTag, status: 'info', color: customColor }); + expect(getTag().className).toContain(statusInfo); + }); +}); diff --git a/src/components/tag/tag.tsx b/src/components/tag/tag.tsx new file mode 100644 index 0000000..51f3daf --- /dev/null +++ b/src/components/tag/tag.tsx @@ -0,0 +1,69 @@ +import React from 'react'; + +import { TagStyle } from './styles'; +import IonIcon from '../icons/icons'; + +import { validateHexColor } from '../utils/validateHexColor'; +import ErrorBoundary from '../error/error-boundary'; +import { TagStatus } from '../../core/types/status'; +import { iconType } from '../icons/svgs/icons'; + +export interface IonTagProps { + outline?: boolean; + status?: TagStatus; + color?: string; + label: string; + icon?: iconType; +} + +const iconSize = 12; +const defaultColor = '#505566'; +const lighteningFactor = '1A'; + +const isValidLabel = (label: string) => label && !(String(label).trim() === ''); + +const newColor = (color: string) => ({ + backgroundColor: color + lighteningFactor, + color: color, + fill: color, +}); + +const getColorObject = (status?: TagStatus, color?: string) => { + if (status) { + return {}; + } + + if (!color || !validateHexColor(color)) { + return { ...newColor(defaultColor) }; + } + + return { + ...newColor(color), + }; +}; + +const IonTag = ({ + label, + color, + icon, + status, + outline = true, +}: IonTagProps) => { + if (!isValidLabel(label)) { + return ; + } + + return ( + + {icon && } + {label} + + ); +}; + +export default IonTag; diff --git a/src/components/utils/validateHexColor.ts b/src/components/utils/validateHexColor.ts new file mode 100644 index 0000000..59020d3 --- /dev/null +++ b/src/components/utils/validateHexColor.ts @@ -0,0 +1,3 @@ +export const validateHexColor = (color: string): boolean => { + return /^#(?:[0-9a-fA-F]{3,4}){1,2}$/.test(color); +}; diff --git a/src/core/types/status.ts b/src/core/types/status.ts new file mode 100644 index 0000000..2ab0084 --- /dev/null +++ b/src/core/types/status.ts @@ -0,0 +1,2 @@ +export type StatusType = 'success' | 'info' | 'warning' | 'negative'; +export type TagStatus = 'success' | 'info' | 'warning' | 'negative' | 'neutral'; diff --git a/src/stories/tag/tag.stories.tsx b/src/stories/tag/tag.stories.tsx new file mode 100644 index 0000000..96b72f7 --- /dev/null +++ b/src/stories/tag/tag.stories.tsx @@ -0,0 +1,84 @@ +import { ComponentStory, ComponentMeta } from '@storybook/react'; + +import IonTag, { IonTagProps } from '../../components/tag/tag'; + +export default { + title: 'Ion/Data Display/Tag', + component: IonTag, +} as ComponentMeta; + +const Template: ComponentStory = (args: IonTagProps) => ( + +); + +export const tagDefault = Template.bind({}); +tagDefault.storyName = 'type: default'; +tagDefault.args = { + label: 'Exemple Message', +}; + +export const TagWithoutOutline = Template.bind({}); +TagWithoutOutline.storyName = 'type: without outline'; +TagWithoutOutline.args = { + label: 'Exemple Message', + outline: false, +}; + +export const TagWithStatusSuccess = Template.bind({}); +TagWithStatusSuccess.storyName = 'type: success'; +TagWithStatusSuccess.args = { + label: 'tag with status success', + status: 'success', +}; + +export const TagWithStatusWarning = Template.bind({}); +TagWithStatusWarning.storyName = 'type: warning'; +TagWithStatusWarning.args = { + label: 'Exemple Message', + status: 'warning', +}; + +export const TagWithStatusInfo = Template.bind({}); +TagWithStatusInfo.storyName = 'type: info'; +TagWithStatusInfo.args = { + label: 'Exemple Message', + status: 'info', +}; + +export const TagWithStatusNegative = Template.bind({}); +TagWithStatusNegative.storyName = 'type: negative'; +TagWithStatusNegative.args = { + label: 'Exemple Message', + status: 'negative', +}; + +export const TagWithStatusNeutral = Template.bind({}); +TagWithStatusNeutral.storyName = 'type: neutral'; +TagWithStatusNeutral.args = { + label: 'Exemple Message', + status: 'neutral', +}; + +export const TagCustomColor = Template.bind({}); +TagCustomColor.storyName = 'type: custom'; +TagCustomColor.args = { + label: 'Exemple Message', + color: '#7f0dff', +}; + +export const tagWithIcon = Template.bind({}); +tagWithIcon.storyName = 'type: with icon'; +tagWithIcon.args = { + label: 'Exemple Message', + icon: 'check', + status: 'success', + outline: true, +}; + +export const tagError = Template.bind({}); +tagError.storyName = 'type: Error'; +tagError.args = { + label: '', + icon: 'check', + status: 'success', +};