From 7149ddb777ec746e93af4bba6e0fa0c118548821 Mon Sep 17 00:00:00 2001 From: Diego Gaspar Date: Wed, 24 Jul 2024 11:55:08 -0300 Subject: [PATCH] refactor: add variant outlined to TextInput component --- src/components/TextInput/index.tsx | 298 +++++++++++------- src/components/TextInput/styles.tsx | 194 ++++++++++-- .../TextInput/text-input.stories.tsx | 77 ++++- 3 files changed, 426 insertions(+), 143 deletions(-) diff --git a/src/components/TextInput/index.tsx b/src/components/TextInput/index.tsx index 7244ab1..bf579fe 100644 --- a/src/components/TextInput/index.tsx +++ b/src/components/TextInput/index.tsx @@ -4,78 +4,57 @@ import { FocusEvent, InputHTMLAttributes, MouseEvent, - Ref, - RefObject, useRef, + useState, } from 'react'; -import { IMaskMixin, ReactElementProps } from 'react-imask'; import Icons from '../Icons'; import { + Fieldset, IconWrapperLeft, IconWrapperRight, Input, + InputWrapper, + Label, Message, PlaceholderLabel, + StyledIMaskInput, Wrapper, } from './styles'; type IconsType = keyof typeof Icons; +type VariantProps = { variant?: 'default' | 'outlined' }; -type InputMaskProps = ReactElementProps & { - inputRef: Ref; - hasError?: boolean; - $hasIconLeft?: boolean; - $hasIconRight?: boolean; -}; - -const InputMask = IMaskMixin( - ({ - inputRef, - hasError, - $hasIconRight, - $hasIconLeft, - ...props - }: InputMaskProps) => ( - | undefined} - /> - ), -); - -export type TextInputType = InputHTMLAttributes & { - style?: any; - textInputStyle?: any; - maskOptions?: any; - label?: string; - message?: string; - error?: string; - name: string; - id: string; - maxLength?: number; - value: string; - autoFocus?: boolean; - iconRight?: IconsType; - iconLeft?: IconsType; - onClickIconRight?: (event: MouseEvent) => void; - onClickIconLeft?: (event: MouseEvent) => void; - onChange: (e: Partial>) => void; - onBlur?: ( - e: - | FocusEvent - | ChangeEvent, - ) => void; - onFocus?: - | (( - e: - | FocusEvent - | ChangeEvent, - ) => void) - | undefined; -}; +export type TextInputType = InputHTMLAttributes & + VariantProps & { + style?: any; + textInputStyle?: any; + maskOptions?: any; + label?: string; + message?: string; + error?: string; + name: string; + id: string; + maxLength?: number; + value: string; + autoFocus?: boolean; + iconRight?: IconsType; + iconLeft?: IconsType; + onClickIconRight?: (event: MouseEvent) => void; + onClickIconLeft?: (event: MouseEvent) => void; + onChange: (e: Partial>) => void; + onBlur?: ( + e: + | FocusEvent + | ChangeEvent, + ) => void; + onFocus?: + | (( + e: + | FocusEvent + | ChangeEvent, + ) => void) + | undefined; + }; const TextInput: FC = ({ message, @@ -96,16 +75,38 @@ const TextInput: FC = ({ maskOptions, iconRight, iconLeft, + variant = 'default', ...rest }) => { + const [isFocused, setIsFocused] = useState(false); + const hasValue = value?.length > 0; const hasError = error ? error.length > 0 : false; + const hasFocus = isFocused || hasValue; const RightIconComponent: any = iconRight && Icons[iconRight]; const LeftIconComponent: any = iconLeft && Icons[iconLeft]; + const ErrorIconComponent: any = Icons['ExclamationTriangleIcon']; + + const onAccept = (value: any) => { + if (onChange) { + onChange({ + target: { + name, + value, + }, + } as any); + } + }; + + const handleFocus = () => { + setIsFocused(true); + }; - const onAccept = (_, __, event?: InputEvent | undefined) => { - onChange(event as Partial>); + const handleBlur = (event: FocusEvent) => { + if (event.target.value === '') { + setIsFocused(false); + } }; const ref = useRef(null); @@ -113,74 +114,131 @@ const TextInput: FC = ({ return ( - {iconLeft && ( - - - - )} - {maskOptions ? ( - - ) : ( - - )} - {iconRight && ( - - - + {label} + )} - - {label} - + {iconLeft && ( + + + + )} + + {maskOptions ? ( + { + handleFocus(); + onFocus?.(event); + }} + onBlur={(event) => { + handleBlur(event); + onBlur?.(event); + }} + mask={hasFocus ? maskOptions?.mask : ''} + defaultValue={value} + $hasIconLeft={!!iconLeft} + $hasIconRight={!!iconRight} + $hasError={hasError} + $variant={variant} + {...rest} + /> + ) : ( + { + handleFocus(); + onFocus?.(event); + }} + onBlur={(event) => { + handleBlur(event); + onBlur?.(event); + }} + onChange={onChange} + maxLength={maxLength} + autoFocus={autoFocus} + $hasError={hasError} + $hasIconLeft={!!iconLeft} + $hasIconRight={!!iconRight} + $variant={variant} + {...rest} + /> + )} + + {iconRight && ( + + + + )} + + {variant === 'outlined' && ( +
+ + {label} + +
+ )} + + + {variant !== 'outlined' && ( + + {label} + + )} + {error || message ? ( - + + {variant === 'outlined' && } {error || message} ) : null} diff --git a/src/components/TextInput/styles.tsx b/src/components/TextInput/styles.tsx index 7244f96..7c91a2e 100644 --- a/src/components/TextInput/styles.tsx +++ b/src/components/TextInput/styles.tsx @@ -1,4 +1,5 @@ -import styled from 'styled-components'; +import { IMaskInput } from 'react-imask'; +import styled, { css } from 'styled-components'; import { getTheme, ifStyle, pxToRem } from '@platformbuilders/theme-toolkit'; type HasIcon = { @@ -11,16 +12,35 @@ type PlaceholderLabelProps = { $hasError: boolean; } & HasIcon; -type MessageProps = { +type VariantProps = { $variant?: 'default' | 'outlined' }; + +type MessageProps = VariantProps & { + $hasError: boolean; +}; + +type InputProps = VariantProps & { + $hasError: boolean; +} & HasIcon; + +type InputWrapperProps = VariantProps & { + $hasFocus: boolean; + $hasError: boolean; +}; + +type IconProps = { + $clickable?: boolean; $hasError: boolean; }; -type InputProps = { +type LabelProps = { + $hasFocus: boolean; $hasError: boolean; } & HasIcon; const primaryMain = getTheme('brand.primary.main'); const dangerMain = getTheme('danger.main'); +const fontSizeMin = getTheme('fontSizes.min'); +const fontSizeXxs = getTheme('fontSizes.xxs'); const fontSizeSm = getTheme('fontSizes.sm'); const fontSizeMd = getTheme('fontSizes.md'); const spacingSm = getTheme('spacing.sm'); @@ -31,20 +51,33 @@ const borderRadiusMd = getTheme('borderRadius.md'); const hasError = ifStyle('$hasError'); -export const PlaceholderLabel = styled.span` - position: absolute; - top: ${pxToRem(16)}; - left: ${pxToRem(14)}; - line-height: 147.6%; - transition: top 0.2s; - ${({ $hasIconLeft }) => !!$hasIconLeft && `left: ${pxToRem(36)};`} - ${(props) => - props.$hasValue && `top: 0; font-size: ${fontSizeMd}; margin-bottom: 40px;`} +const inputOutlinedStyles = ({ + $hasIconRight, + $hasIconLeft, + $hasError, +}: InputProps) => css` + border: none; + outline: none; + font-size: ${fontSizeSm}px; + width: 100%; + height: 30px; + padding: 0; + background: transparent; - color: ${(props) => hasError(dangerMain(props), primaryMain(props))(props)}; + ${$hasIconRight && `padding-right: ${pxToRem(36)};`} + ${$hasIconLeft && `padding-left: ${pxToRem(36)};`} + ${$hasError && `border-color: ${dangerMain}10`}; + + &::placeholder { + color: transparent; + } `; -export const Input = styled.input` +const inputDefaultStyles = ({ + $hasIconRight, + $hasIconLeft, + $hasError, +}: InputProps) => css` width: 100%; font-size: ${fontSizeMd}px; line-height: 147.6%; @@ -52,12 +85,12 @@ export const Input = styled.input` display: flex; height: ${pxToRem(44)}; padding: 0px ${spacingSm}px 0px ${spacingMd}px; - ${({ $hasIconRight }) => !!$hasIconRight && `padding-right: ${pxToRem(36)};`} - ${({ $hasIconLeft }) => !!$hasIconLeft && `padding-left: ${pxToRem(36)};`} - align-items: center; + ${$hasIconRight && `padding-right: ${pxToRem(36)};`} + ${$hasIconLeft && `padding-left: ${pxToRem(36)};`} + align-items: center; gap: ${pxToRem(12)}; background: ${(props) => - !!props.$hasError ? `${dangerMain(props)}10` : `${textMain(props)}10`}; + !!$hasError ? `${dangerMain(props)}10` : `${textMain(props)}10`}; border-radius: ${borderRadiusMd}px; border: none; font-size: ${fontSizeSm}px; @@ -71,11 +104,54 @@ export const Input = styled.input` } `; +export const PlaceholderLabel = styled.span` + position: absolute; + top: ${pxToRem(16)}; + left: ${pxToRem(14)}; + line-height: 147.6%; + transition: top 0.2s; + ${({ $hasIconLeft }) => !!$hasIconLeft && `left: ${pxToRem(36)};`} + ${(props) => + props.$hasValue && `top: 0; font-size: ${fontSizeMd}; margin-bottom: 40px;`} + + color: ${(props) => hasError(dangerMain(props), primaryMain(props))(props)}; +`; + +export const Input = styled.input` + ${({ $variant, ...props }) => + $variant === 'outlined' + ? inputOutlinedStyles(props) + : inputDefaultStyles(props)} +`; + +export const StyledIMaskInput = styled(IMaskInput)` + ${({ $variant, ...props }) => + $variant === 'outlined' + ? inputOutlinedStyles(props) + : inputDefaultStyles(props)} +`; + export const Message = styled.p` font-size: ${fontSizeMd}px; color: ${(props) => hasError(dangerMain(props), textMain(props))(props)}; letter-spacing: 0.0275rem; margin: ${spacingSm}px ${spacingMd}px; + + ${({ $variant }) => + $variant === 'outlined' && + css` + display: flex; + align-items: center; + gap: ${pxToRem(4)}; + font-size: ${fontSizeXxs}px; + margin: 0; + line-height: 1.3rem; + + svg { + width: ${pxToRem(12)}; + height: ${pxToRem(12)}; + } + `}; `; export const Wrapper = styled.div` @@ -83,18 +159,92 @@ export const Wrapper = styled.div` position: relative; `; -export const IconWrapperLeft = styled.div<{ clickable?: boolean }>` +export const IconWrapperLeft = styled.div` position: absolute; left: ${pxToRem(14)}; top: ${pxToRem(22)}; transform: translateY(-50%); - cursor: ${({ clickable }) => (!!clickable ? 'pointer' : 'default')}; + cursor: ${({ $clickable }) => (!!$clickable ? 'pointer' : 'default')}; + color: ${hasError(dangerMain, primaryMain)}; `; -export const IconWrapperRight = styled.div<{ clickable?: boolean }>` +export const IconWrapperRight = styled.div` position: absolute; right: ${pxToRem(14)}; top: ${pxToRem(22)}; transform: translateY(-50%); - cursor: ${({ clickable }) => (!!clickable ? 'pointer' : 'default')}; + cursor: ${({ $clickable }) => (!!$clickable ? 'pointer' : 'default')}; + color: ${hasError(dangerMain, primaryMain)}; +`; + +export const Label = styled.label` + position: absolute; + top: ${pxToRem(12)}; + left: ${pxToRem(16)}; + font-size: ${fontSizeSm}px; + padding: 0 ${pxToRem(4)}; + transition: all 0.2s ease; + color: ${hasError(dangerMain, '#10141633')}; + ${({ $hasIconRight }) => $hasIconRight && `padding-right: ${pxToRem(36)};`} + ${({ $hasIconLeft }) => $hasIconLeft && `padding-left: ${pxToRem(36)};`} + + ${({ $hasFocus }) => + $hasFocus && + css` + top: ${pxToRem(-8)}; + left: ${pxToRem(12)}; + font-size: ${fontSizeMin}px; + color: ${hasError(dangerMain, primaryMain)}; + padding: 0 ${pxToRem(4)}; + `} +`; + +export const InputWrapper = styled.div` + ${({ $variant, $hasFocus }) => + $variant === 'outlined' && + css` + position: relative; + border-radius: ${pxToRem(6)}; + padding: 0 ${pxToRem(12)}; + display: flex; + align-items: center; + justify-content: center; + height: 44px; + border: ${$hasFocus ? 'none' : `1px solid #10141633`}; + border-color: ${hasError(dangerMain, '')}; + + &:hover { + border-color: ${hasError(dangerMain, primaryMain)}; + } + `} +`; + +export const Fieldset = styled.fieldset<{ + $hasFocus: boolean; + $hasError: boolean; +}>` + position: absolute; + top: ${pxToRem(-5)}; + left: ${pxToRem(-1)}; + right: ${pxToRem(-1)}; + bottom: 0; + border: 2px solid; + border-color: ${hasError(dangerMain, primaryMain)}; + border-radius: inherit; + padding: 0 ${pxToRem(8)}; + pointer-events: none; + display: ${({ $hasFocus }) => !$hasFocus && 'none'}; + + legend { + width: auto; + padding: 0 ${pxToRem(5)}; + height: ${pxToRem(11)}; + font-size: ${pxToRem(12)}; + display: inline-block; + color: ${hasError(dangerMain, primaryMain)}; + + span { + visibility: ${(props) => props.$hasFocus && 'hidden'}; + } + } `; diff --git a/src/components/TextInput/text-input.stories.tsx b/src/components/TextInput/text-input.stories.tsx index 56745d8..ae4cb87 100644 --- a/src/components/TextInput/text-input.stories.tsx +++ b/src/components/TextInput/text-input.stories.tsx @@ -7,6 +7,9 @@ configure({ testIdAttribute: 'id' }); // Mocks const mockTextId = 'text-input-test-id'; +const mockInputOutlined = 'Diego Cruz'; +const mockInputCpfMask = '56728709029'; +const mockResultCpfMask = '567.287.090-29'; const events = { onChange: jest.fn(), @@ -31,6 +34,10 @@ const meta: Meta = { control: { type: null }, description: 'All Mask options in: https://imask.js.org', }, + variant: { + type: 'string', + defaultValue: 'default', + }, }, args: { id: mockTextId, @@ -40,6 +47,7 @@ const meta: Meta = { onBlur: events.onBlur, onFocus: events.onFocus, onClickIconLeft: events.onClickIconLeft, + variant: 'default', }, tags: ['autodocs'], }; @@ -68,6 +76,73 @@ export const Default: Story = { }, }; +export const Outlined: Story = { + args: { + label: 'Name', + variant: 'outlined', + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step('TextInput | Test Render', async () => { + expect(canvas.getByTestId(mockTextId)).toBeInTheDocument(); + }); + + await step('TextInput | Event Focus', async () => { + await userEvent.click(canvas.getByTestId(mockTextId)); + expect(events.onFocus).toHaveBeenCalled(); + }); + + await step('TextInput | Event Blur', async () => { + await userEvent.click(canvas.getByTestId(mockTextId)); + await userEvent.tab(); + expect(events.onBlur).toHaveBeenCalled(); + }); + + await step('TextInput | Change Value', async () => { + await userEvent.type(canvas.getByTestId(mockTextId), mockInputOutlined); + expect(events.onFocus).toHaveBeenCalled(); + expect(events.onChange).toHaveBeenCalled(); + expect(canvas.getByTestId(mockTextId)).toHaveValue(mockInputOutlined); + }); + }, +}; + +export const OutlinedMaskCPF: Story = { + args: { + label: 'CPF', + variant: 'outlined', + maskOptions: { + mask: '000.000.000-00', + }, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step('TextInput | Test Render', async () => { + expect(canvas.getByTestId(mockTextId)).toBeInTheDocument(); + }); + + await step('TextInput | Event Focus', async () => { + await userEvent.click(canvas.getByTestId(mockTextId)); + expect(events.onFocus).toHaveBeenCalled(); + }); + + await step('TextInput | Event Blur', async () => { + await userEvent.click(canvas.getByTestId(mockTextId)); + await userEvent.tab(); + expect(events.onBlur).toHaveBeenCalled(); + }); + + await step('TextInput | Change Value', async () => { + await userEvent.type(canvas.getByTestId(mockTextId), mockInputCpfMask); + expect(events.onFocus).toHaveBeenCalled(); + expect(events.onChange).toHaveBeenCalled(); + expect(canvas.getByTestId(mockTextId)).toHaveValue(mockResultCpfMask); + }); + }, +}; + export const MaskPhone: Story = { args: { message: 'Type your Phone', @@ -83,7 +158,7 @@ export const MaskPhone: Story = { }); await step('TextInput | Change Value', async () => { - await userEvent.type(canvas.getByTestId(mockTextId), 'Fluid React'); + await userEvent.type(canvas.getByTestId(mockTextId), '5587988888888'); expect(events.onChange).toHaveBeenCalled(); }); },