Skip to content

Commit

Permalink
Merge branch 'main' into feat/fields-lg-size
Browse files Browse the repository at this point in the history
  • Loading branch information
Jdrazong authored Jul 25, 2023
2 parents 383271b + d05e857 commit 1da23ef
Show file tree
Hide file tree
Showing 8 changed files with 977 additions and 1 deletion.
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
slug: create-password-field
title: Create Password Field
links:
viewSource: components/create-password-field
showReportAnIssue: true
tabs:
- content: >-
`CreatePasswordField` is the preferred way to render a form field for
creating a new password. It is built on top of the `PasswordField`.
`CreatePasswordField` accepts the same props as `PasswordInput` but provides default values for some of them (e.g. `label` defaults to `"Create a password"` and `name` defaults to `password`).
`CreatePasswordField` introduces a `validate` prop. `validate` can be synchronous or asynchronous, it allows you to provide a custom function to handle password validation according to your specific requirements. The `defaultValidation` prop allows you to provide an initial validation status that matches the same structure that the validate function returns. This way, you can display immediate feedback to the user while the actual validation is being processed asynchronously.
To provide visual feedback on the validation rules, `CreatePasswordField` utilizes the `InlineMessage` component. The direction of these messages can be controlled using the `messageDirection` prop.
<CodeBlock live={true} preview={true} code={`<Form onSubmit={console.log}>
<CreatePasswordField
validate={(password) => ({
['7+ characters']: password.length >= 7,
['Is not common password']: !['1234', 'password'].includes(password)
})}
defaultValidation={{
['7+ characters']: false,
['Is not common password']: false,
}}
messageDirection="column"
css={{ width: 320 }}
/>
</Form>`} language={"tsx"} />
## API Reference
<ComponentProps component="CreatePasswordField" />
title: Main
parent: ru0Ovr_U82kdQX8m3WahL
uuid: MD1mmrf40ecDo4z16-bco
nestedSlug:
- components
- form
- fields
- create-password-field
---
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { axe } from 'jest-axe'
import * as React from 'react'

import { Button } from '../button'
import { Form } from '../form'
import { Tooltip } from '../tooltip'
import { CreatePasswordField } from '.'

const props: React.ComponentProps<typeof CreatePasswordField> = {
name: 'password',
label: 'Create a password',
defaultValidation: {
'7+ characters': false,
'Contains number': false,
'Contains symbol': false
},
validate: () =>
Promise.resolve({
'7+ characters': true,
'Contains number': false,
'Contains symbol': true
})
}

const customRender = () =>
render(
<Tooltip.Provider>
<Form onSubmit={jest.fn()}>
<CreatePasswordField {...props} />
<Button type="submit">Submit</Button>
</Form>
</Tooltip.Provider>
)

describe(`CreatePasswordField component`, () => {
it('renders', () => {
const { container } = customRender()

expect(container).toMatchSnapshot()
})

it('has no programmatically detectable a11y issues', async () => {
const { container } = customRender()

expect(await axe(container)).toHaveNoViolations()
})

it('displays validation messages when focusing on the input', () => {
customRender()

expect(screen.queryByText('7+ characters')).not.toBeInTheDocument()
expect(screen.queryByText('Contains number')).not.toBeInTheDocument()
expect(screen.queryByText('Contains symbol')).not.toBeInTheDocument()

userEvent.click(screen.getByLabelText('Create a password'))

expect(screen.getByText('7+ characters')).toBeVisible()
expect(screen.getByText('Contains number')).toBeVisible()
expect(screen.getByText('Contains symbol')).toBeVisible()
})

it('displays validation messages on blur after the form has been touched', () => {
customRender()

const passwordInput = screen.getByText('Create a password')
userEvent.type(passwordInput, 'password')
userEvent.tab()

expect(screen.getByText('7+ characters')).toBeVisible()
expect(screen.getByText('Contains number')).toBeVisible()
expect(screen.getByText('Contains symbol')).toBeVisible()
})

it('displays validation messages when trying to submit the form', async () => {
customRender()

userEvent.click(screen.getByRole('button', { name: 'Submit' }))

expect(await screen.findByText('7+ characters')).toBeVisible()
})

it('displays validation styling to the input when the value is not valid', () => {
const { container } = customRender()

userEvent.click(screen.getByRole('button', { name: 'Submit' }))

expect(container).toMatchSnapshot()
})
})
112 changes: 112 additions & 0 deletions lib/src/components/create-password-field/CreatePasswordField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import invariant from 'invariant'
import * as React from 'react'
import { useFormContext } from 'react-hook-form'
import { throttle } from 'throttle-debounce'

import type { CSS } from '~/stitches'
import { Override } from '~/utilities'

import { Box } from '../box'
import { FieldElementWrapperProps } from '../field-wrapper'
import { Flex } from '../flex'
import { InlineMessage } from '../inline-message'
import { PasswordField } from '../password-field'
import { PasswordInput } from '../password-input'

type ValidationResult = Record<string, boolean>

type CreatePasswordFieldProps = Override<
React.ComponentProps<typeof PasswordInput> & FieldElementWrapperProps,
{
label?: string
name?: string
validate: (
password: string
) => Promise<ValidationResult | undefined> | ValidationResult
defaultValidation: ValidationResult
messageDirection?: CSS['flexDirection']
}
>

export const CreatePasswordField = ({
validate,
defaultValidation,
messageDirection = 'row',
label = 'Create a password',
name = 'password',
css,
validation,
...remainingProps
}: CreatePasswordFieldProps) => {
const { formState } = useFormContext()
const [isFocused, setIsFocused] = React.useState<boolean>(false)
const [validationResult, setValidationResult] =
React.useState<ValidationResult>(defaultValidation)

const touched = formState.touched[name]
const error = formState.errors[name]?.type === 'validate'

const validatePassword = React.useCallback(
async (password: string) => {
const result = await validate(password)

if (result) {
invariant(
typeof result === 'object',
'The validate function must return an object'
)

setValidationResult(result)
return Object.values(result).every((isValid) => isValid)
}

return false
},
[setValidationResult]
)

const handleChange = React.useCallback(throttle(500, validatePassword), [
validatePassword
])

const getMessageTheme = (result: boolean, isFocused: boolean) => {
if (result) return 'success'

return isFocused ? 'neutral' : 'error'
}

return (
<Box css={css}>
<PasswordField
label={label}
name={name}
onChange={(e) => handleChange(e.target.value)}
onBlur={() => setIsFocused(false)}
onFocus={() => setIsFocused(true)}
validation={{ ...validation, validate: validatePassword }}
{...remainingProps}
/>
{(touched || isFocused || error) && (
<Flex
css={{
mt: '$2',
gap: '$2',
flexWrap: 'wrap',
flexDirection: messageDirection
}}
>
{Object.entries(validationResult).map(([message, result]) => (
<InlineMessage
key={message}
theme={getMessageTheme(result, isFocused)}
>
{message}
</InlineMessage>
))}
</Flex>
)}
</Box>
)
}

CreatePasswordField.displayName = 'CreatePasswordField'
Loading

0 comments on commit 1da23ef

Please sign in to comment.