Skip to content

Instantly share code, notes, and snippets.

@DouglasdeMoura
Last active February 15, 2022 11:31
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save DouglasdeMoura/4562feb9fc5f0baaae5741ab3fbe6acf to your computer and use it in GitHub Desktop.
Save DouglasdeMoura/4562feb9fc5f0baaae5741ab3fbe6acf to your computer and use it in GitHub Desktop.
Simple React Input validation
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Input } from '.'
describe('<Input />', () => {
it('should render the component', () => {
render(<Input label="mock_label" />)
expect(screen.getByLabelText('mock_label')).toBeInTheDocument()
})
it('should validate a required input', () => {
const handleOnBlur = jest.fn()
render(
<Input
label="mock_label"
error="This input is required"
onBlur={handleOnBlur}
required
/>,
)
userEvent.tab()
userEvent.tab()
expect(handleOnBlur).toHaveBeenCalledTimes(1)
expect(screen.getByText('This input is required')).toBeInTheDocument()
})
it('should validate an input with a defined minLength', () => {
const handleOnBlur = jest.fn()
render(
<Input
label="mock_label"
error="The minLength is 5"
onBlur={handleOnBlur}
minLength={5}
/>,
)
userEvent.type(screen.getByLabelText('mock_label'), '123')
userEvent.tab()
expect(handleOnBlur).toHaveBeenCalledTimes(1)
expect(screen.getByText('The minLength is 5')).toBeInTheDocument()
})
it('should validate an input with a defined maxLength', () => {
const handleOnBlur = jest.fn()
render(
<Input
label="mock_label"
error="The maxLength is 5"
defaultValue="1234567"
onBlur={handleOnBlur}
maxLength={5}
/>,
)
const input = screen.getByLabelText('mock_label') as HTMLInputElement
userEvent.type(input, '{backspace}')
userEvent.tab()
expect(handleOnBlur).toHaveBeenCalledTimes(1)
expect(screen.getByText('The maxLength is 5')).toBeInTheDocument()
})
it('should validate a pattern', () => {
const handleOnBlur = jest.fn()
render(
<Input
label="mock_label"
error="You must enter three numbers"
onBlur={handleOnBlur}
pattern={'[0-9]{3}'}
/>,
)
const input = screen.getByLabelText('mock_label') as HTMLInputElement
userEvent.type(input, '1234567')
userEvent.tab()
expect(handleOnBlur).toHaveBeenCalledTimes(1)
expect(screen.getByText('You must enter three numbers')).toBeInTheDocument()
})
it('should validate a min and max range', () => {
const handleOnBlur = jest.fn()
render(
<Input
label="mock_label"
error="You must enter a number between 3 and 5"
onBlur={handleOnBlur}
min={3}
max={5}
/>,
)
userEvent.type(screen.getByLabelText('mock_label'), '1')
userEvent.tab()
expect(handleOnBlur).toHaveBeenCalledTimes(1)
expect(
screen.getByText('You must enter a number between 3 and 5'),
).toBeInTheDocument()
userEvent.type(screen.getByLabelText('mock_label'), '5')
userEvent.tab()
expect(
screen.getByText('You must enter a number between 3 and 5'),
).toBeInTheDocument()
})
it('should call onError when input is invalid and onValidate when the input is valid', () => {
const onValidate = jest.fn()
const onError = jest.fn()
render(
<Input
label="mock_label"
error="You must enter an email"
onValidate={onValidate}
onError={onError}
type="email"
/>,
)
userEvent.type(screen.getByLabelText('mock_label'), 'test@')
userEvent.tab()
expect(onError).toHaveBeenCalledWith('test@')
userEvent.type(screen.getByLabelText('mock_label'), 'example.com')
userEvent.tab()
expect(onValidate).toHaveBeenCalledWith('test@example.com')
})
it('should display custom error messages', () => {
render(
<Input
label="mock_label"
error={{
min: '>3',
max: '<5',
}}
min={3}
max={5}
/>,
)
userEvent.type(screen.getByLabelText('mock_label'), '1')
userEvent.tab()
expect(screen.getByText('>3')).toBeInTheDocument()
userEvent.type(screen.getByLabelText('mock_label'), '14')
userEvent.tab()
expect(screen.getByText('<5')).toBeInTheDocument()
})
it('should display the search icon', () => {
render(<Input label="mock_label" type="search" />)
expect(screen.getByTestId('button-search-icon')).toBeInTheDocument()
})
it('should display the eye icon', async () => {
render(<Input label="mock_label" type="password" />)
expect(screen.getByTestId('eye-off-icon')).toBeInTheDocument()
userEvent.click(screen.getByTestId('button-eye-icon'))
expect(screen.getByLabelText('mock_label')).toHaveAttribute('type', 'text')
expect(screen.getByTestId('eye-empty-icon')).toBeInTheDocument()
})
})
import { ComponentMeta, ComponentStory } from '@storybook/react'
import { Input } from '.'
export default {
title: 'UI/Input',
component: Input,
args: {
label: 'Name',
placeholder: 'Enter your name',
},
} as ComponentMeta<typeof Input>
const Template: ComponentStory<typeof Input> = (args) => <Input {...args} />
export const Default = Template.bind({})
export const Invalid = Template.bind({})
Invalid.args = {
label: 'E-mail',
placeholder: 'Enter your e-mail',
defaultValue: '1234',
type: 'email',
error: 'Invalid e-mail',
}
export const Search = Template.bind({})
Search.args = {
label: 'Search',
placeholder: 'Type your search',
type: 'search',
}
export const Password = Template.bind({})
Password.args = {
label: 'Password',
placeholder: 'Enter your password',
type: 'password',
defaultValue: 'Pa$sw0rd',
}
export const Disabled = Template.bind({})
Disabled.args = {
label: 'Disabled',
placeholder: 'Type your search',
disabled: true,
}
import { styled } from '~/stitches.config'
export const Wrapper = styled('div', {
display: 'flex',
flexDirection: 'column',
gap: '$4',
fontFamily: '$sans',
lineHeight: '1',
position: 'relative',
paddingBottom: '$16',
marginBottom: '$16',
width: '100%',
})
export const Label = styled('label', {
color: '$gray900',
fontSize: '$14',
fontWeight: '$bold',
'&[data-disabled="true"]': {
color: '$gray400',
cursor: 'not-allowed',
},
'&[data-invalid="true"]': {
color: '$red500',
},
})
export const InputContainer = styled('div', {
position: 'relative',
boxSizing: 'border-box',
})
export const Input = styled('input', {
borderTop: 'none',
borderLeft: 'none',
borderRight: 'none',
borderBottom: '1px solid $gray900',
fontFamily: '$sans',
fontWeight: '$normal',
fontSize: '$16',
padding: '$14 0',
width: '100%',
boxSizing: 'border-box',
transition: 'border-color $easeInOut',
'&:focus-visible': {
outline: 'none',
borderColor: '$purple400',
},
'&[data-invalid="true"]': {
borderColor: '$red500',
},
'&[type="password"], &[type="search"]': {
paddingRight: '$16',
},
'&:disabled': {
borderColor: '$gray400',
color: '$gray400',
cursor: 'not-allowed',
},
})
export const Icon = styled('button', {
position: 'absolute',
top: '50%',
right: '0',
transform: 'translateY(-50%)',
background: 'none',
border: 'none',
outline: 'none',
cursor: 'pointer',
padding: '0',
height: '47px',
'&:focus-visible': {
outline: 'none',
},
})
export const Error = styled('p', {
fontSize: '$12',
margin: '0',
color: '$red500',
fontWeight: '$normal',
position: 'absolute',
bottom: '0',
})
import {
FocusEvent,
forwardRef,
useState,
useRef,
ChangeEvent,
RefObject,
useEffect,
} from 'react'
import mergeRefs from 'react-merge-refs'
import { EyeEmpty, EyeOff, Search } from 'iconoir-react'
import { kebabCase } from 'lodash'
import * as S from './input.styles'
// Fix typing error in Stitches (it should be unecessary on the next release).
type StyledComponentProps<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
C extends keyof JSX.IntrinsicElements | React.JSXElementConstructor<any>,
> = Omit<React.ComponentProps<C>, 'css'>
type ErrorCategories = {
type?: string
minLength?: string
maxLength?: string
min?: string
max?: string
pattern?: string
required?: string
custom?: string
}
type Error = boolean | string | ErrorCategories
type InputProps = {
label: string
error?: Error
onError?: (value?: string) => void
onValidate?: (value?: string) => void
} & StyledComponentProps<typeof S.Input>
function validate(
props: Pick<
InputProps,
'required' | 'minLength' | 'maxLength' | 'pattern' | 'type' | 'min' | 'max'
>,
element: RefObject<HTMLInputElement>,
): { valid: boolean; error?: keyof ErrorCategories } {
if (element.current?.validity.valueMissing) {
return {
valid: false,
error: 'required',
}
}
if (
element.current?.validity.tooShort ||
(props?.minLength && element!.current!.value.length < props.minLength)
) {
return {
valid: false,
error: 'minLength',
}
}
if (
element.current?.validity.tooLong ||
(props?.maxLength && element!.current!.value.length > props.maxLength)
) {
return {
valid: false,
error: 'maxLength',
}
}
if (element.current?.validity.patternMismatch) {
return {
valid: false,
error: 'pattern',
}
}
if (element.current?.validity.typeMismatch) {
return {
valid: false,
error: 'type',
}
}
if (
element.current?.validity.rangeUnderflow ||
(props?.min && Number(element!.current!.value) < props.min)
) {
return {
valid: false,
error: 'min',
}
}
if (
element.current?.validity.rangeOverflow ||
(props?.max && Number(element!.current!.value) > props.max)
) {
return {
valid: false,
error: 'max',
}
}
return {
valid: true,
error: undefined,
}
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
(
{ label, error, disabled, onBlur, onChange, onError, onValidate, ...props },
ref,
) => {
const inputRef = useRef<HTMLInputElement>(null)
const [showPassword, setShowPassword] = useState(false)
const [hasError, setHasError] = useState(false)
const [bailedAt, setBailedAt] = useState<
keyof ErrorCategories | undefined
>()
const id = props.id || kebabCase(label)
const name = props.name || id
const type = showPassword ? 'text' : props.type || 'text'
const handleFocusEvent = (e: FocusEvent<HTMLInputElement>) => {
onBlur?.(e)
const validation = validate(props, inputRef)
if (!validation.valid) {
setHasError(true)
setBailedAt(validation.error)
return
}
if (!onValidate?.(inputRef.current?.value)) {
setHasError(true)
setBailedAt('custom')
}
}
const handleOnChange = (e: ChangeEvent<HTMLInputElement>) => {
onChange?.(e)
if (hasError) {
setHasError(false)
setBailedAt(undefined)
}
}
useEffect(() => {
if (hasError && typeof onError === 'function') {
onError?.(inputRef.current?.value)
}
}, [hasError, onError])
return (
<S.Wrapper data-disabled={disabled}>
<S.Label htmlFor={id} data-disabled={disabled} data-invalid={hasError}>
{label}
</S.Label>
<S.InputContainer>
<S.Input
{...props}
data-invalid={hasError}
disabled={disabled}
id={id}
name={name}
type={type}
onBlur={handleFocusEvent}
onChange={handleOnChange}
ref={mergeRefs([inputRef, ref])}
/>
{(type === 'password' || showPassword) && (
<S.Icon
data-testid="button-eye-icon"
type="button"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<EyeEmpty data-testid="eye-empty-icon" />
) : (
<EyeOff data-testid="eye-off-icon" />
)}
</S.Icon>
)}
{type === 'search' && (
<S.Icon data-testid="button-search-icon">
<Search />
</S.Icon>
)}
</S.InputContainer>
{hasError && error && (
<S.Error>
{bailedAt && typeof error === 'object' ? error[bailedAt] : error}
</S.Error>
)}
</S.Wrapper>
)
},
)
Input.displayName = 'Input'
import { createStitches } from '@stitches/react'
const {
styled,
css,
globalCss,
keyframes,
getCssText,
theme,
createTheme,
config,
} = createStitches({
media: {
mobile: '(min-width: 576px)',
tablet: '(min-width: 768px)',
desktop: '(min-width: 1024px)',
widescreen: '(min-width: 1280px)',
fullHd: '(min-width: 1440px)',
},
theme: {
colors: {
slate50: '#F8FAFC',
slate100: '#F1F5F9',
slate200: '#E2E8F0',
slate300: '#CBD5E1',
slate400: '#94A3B8',
slate500: '#64748B',
slate600: '#475569',
slate700: '#334155',
slate800: '#1E293B',
slate900: '#0F172A',
gray50: '#F9FAFB',
gray100: '#F3F4F6',
gray200: '#E5E7EB',
gray300: '#D1D5DB',
gray400: '#9CA3AF',
gray500: '#6B7280',
gray600: '#4B5563',
gray700: '#374151',
gray800: '#1F2937',
gray900: '#111827',
zinc50: '#FAFAFA',
zinc100: '#F4F4F5',
zinc200: '#E4E4E7',
zinc300: '#D4D4D8',
zinc400: '#A1A1AA',
zinc500: '#71717A',
zinc600: '#52525B',
zinc700: '#3F3F46',
zinc800: '#27272A',
zinc900: '#18181B',
neutral50: '#FAFAFA',
neutral100: '#F5F5F5',
neutral200: '#E5E5E5',
neutral300: '#D4D4D4',
neutral400: '#A3A3A3',
neutral500: '#737373',
neutral600: '#525252',
neutral700: '#404040',
neutral800: '#262626',
neutral900: '#171717',
stone50: '#FAFAF9',
stone100: '#F5F5F4',
stone200: '#E7E5E4',
stone300: '#D6D3D1',
stone400: '#A8A29E',
stone500: '#78716C',
stone600: '#57534E',
stone700: '#44403C',
stone800: '#292524',
stone900: '#1C1917',
red50: '#FEF2F2',
red100: '#FEE2E2',
red200: '#FECACA',
red300: '#FCA5A5',
red400: '#F87171',
red500: '#EF4444',
red600: '#DC2626',
red700: '#B91C1C',
red800: '#991B1B',
red900: '#7F1D1D',
orange50: '#FFF7ED',
orange100: '#FFEDD5',
orange200: '#FED7AA',
orange300: '#FDBA74',
orange400: '#FB923C',
orange500: '#F97316',
orange600: '#EA580C',
orange700: '#C2410C',
orange800: '#9A3412',
orange900: '#7C2D12',
amber50: '#FFFBEB',
amber100: '#FEF3C7',
amber200: '#FDE68A',
amber300: '#FCD34D',
amber400: '#FBBF24',
amber500: '#F59E0B',
amber600: '#D97706',
amber700: '#B45309',
amber800: '#92400E',
amber900: '#78350F',
yellow50: '#FEFCE8',
yellow100: '#FEF9C3',
yellow200: '#FEF08A',
yellow300: '#FDE047',
yellow400: '#FACC15',
yellow500: '#EAB308',
yellow600: '#CA8A04',
yellow700: '#A16207',
yellow800: '#854D0E',
yellow900: '#713F12',
lime50: '#F7FEE7',
lime100: '#ECFCCB',
lime200: '#D9F99D',
lime300: '#BEF264',
lime400: '#A3E635',
lime500: '#84CC16',
lime600: '#65A30D',
lime700: '#4D7C0F',
lime800: '#3F6212',
lime900: '#365314',
green50: '#F0FDF4',
green100: '#DCFCE7',
green200: '#BBF7D0',
green300: '#86EFAC',
green400: '#4ADE80',
green500: '#22C55E',
green600: '#16A34A',
green700: '#15803D',
green800: '#166534',
green900: '#14532D',
emerald50: '#ECFDF5',
emerald100: '#D1FAE5',
emerald200: '#A7F3D0',
emerald300: '#6EE7B7',
emerald400: '#34D399',
emerald500: '#10B981',
emerald600: '#059669',
emerald700: '#047857',
emerald800: '#065F46',
emerald900: '#064E3B',
teal50: '#F0FDFA',
teal100: '#CCFBF1',
teal200: '#99F6E4',
teal300: '#5EEAD4',
teal400: '#2DD4BF',
teal500: '#14B8A6',
teal600: '#0D9488',
teal700: '#0F766E',
teal800: '#115E59',
teal900: '#134E4A',
cyan50: '#ECFEFF',
cyan100: '#CFFAFE',
cyan200: '#A5F3FC',
cyan300: '#67E8F9',
cyan400: '#22D3EE',
cyan500: '#06B6D4',
cyan600: '#0891B2',
cyan700: '#0E7490',
cyan800: '#155E75',
cyan900: '#164E63',
sky50: '#F0F9FF',
sky100: '#E0F2FE',
sky200: '#BAE6FD',
sky300: '#7DD3FC',
sky400: '#38BDF8',
sky500: '#0EA5E9',
sky600: '#0284C7',
sky700: '#0369A1',
sky800: '#075985',
sky900: '#0C4A6E',
blue50: '#EFF6FF',
blue100: '#DBEAFE',
blue200: '#BFDBFE',
blue300: '#93C5FD',
blue400: '#60A5FA',
blue500: '#3B82F6',
blue600: '#2563EB',
blue700: '#1D4ED8',
blue800: '#1E40AF',
blue900: '#1E3A8A',
indigo50: '#EEF2FF',
indigo100: '#E0E7FF',
indigo200: '#C7D2FE',
indigo300: '#A5B4FC',
indigo400: '#818CF8',
indigo500: '#6366F1',
indigo600: '#4F46E5',
indigo700: '#4338CA',
indigo800: '#3730A3',
indigo900: '#312E81',
violet50: '#F5F3FF',
violet100: '#EDE9FE',
violet200: '#DDD6FE',
violet300: '#C4B5FD',
violet400: '#A78BFA',
violet500: '#8B5CF6',
violet600: '#7C3AED',
violet700: '#6D28D9',
violet800: '#5B21B6',
violet900: '#4C1D95',
purple50: '#FAF5FF',
purple100: '#F3E8FF',
purple200: '#E9D5FF',
purple300: '#D8B4FE',
purple400: '#C084FC',
purple500: '#A855F7',
purple600: '#9333EA',
purple700: '#7E22CE',
purple800: '#6B21A8',
purple900: '#581C87',
fuchsia50: '#FDF4FF',
fuchsia100: '#FAE8FF',
fuchsia200: '#F5D0FE',
fuchsia300: '#F0ABFC',
fuchsia400: '#E879F9',
fuchsia500: '#D946EF',
fuchsia600: '#C026D3',
fuchsia700: '#A21CAF',
fuchsia800: '#86198F',
fuchsia900: '#701A75',
pink50: '#FDF2F8',
pink100: '#FCE7F3',
pink200: '#FBCFE8',
pink300: '#F9A8D4',
pink400: '#F472B6',
pink500: '#EC4899',
pink600: '#DB2777',
pink700: '#BE185D',
pink800: '#9D174D',
pink900: '#831843',
rose50: '#FFF1F2',
rose100: '#FFE4E6',
rose200: '#FECDD3',
rose300: '#FDA4AF',
rose400: '#FB7185',
rose500: '#F43F5E',
rose600: '#E11D48',
rose700: '#BE123C',
rose800: '#9F1239',
rose900: '#881337',
},
fonts: {
sans: '"IBM Plex Sans", system-ui, sans-serif',
},
fontWeights: {
normal: 400,
bold: 600,
},
fontSizes: {
12: '0.75rem',
14: '0.875rem',
16: '1rem',
18: '1.125rem',
20: '1.25rem',
24: '1.5rem',
28: '1.75rem',
},
transitions: {
easeInOut: '0.2s ease-in-out',
},
space: {
0: '0',
4: '0.25rem',
8: '0.5rem',
12: '0.75rem',
14: '0.875rem',
16: '1rem',
18: '1.25rem',
},
radii: {
4: '0.25rem',
8: '0.5rem',
},
},
})
const globalStyles = globalCss({
'*, *::before, *::after': {
boxSizing: 'border-box',
},
'*': {
margin: 0,
},
html: {
fontSize: '100%',
},
body: {
lineHeight: 1.5,
'-webkit-font-smoothing': 'antialiased',
fontFamily: '$sans',
},
'img, picture, video, canvas, svg': {
display: 'block',
maxWidth: '100%',
},
'input, button, textarea, select': {
font: 'inherit',
},
'p, h1, h2, h3, h4, h5, h6': {
overflowWrap: 'break-word',
},
'#root, #__next': {
isolation: 'isolate',
},
'#root': {
height: '100vh',
},
})
export {
styled,
css,
globalCss,
keyframes,
getCssText,
theme,
createTheme,
config,
globalStyles,
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment