-
Notifications
You must be signed in to change notification settings - Fork 585
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #216 from storybookjs/forms
Input styling changes, Form component addition
- Loading branch information
Showing
9 changed files
with
686 additions
and
293 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,106 @@ | ||
import React, { useState } from 'react'; | ||
import styled from 'styled-components'; | ||
import { action, actions as makeActions } from '@storybook/addon-actions'; | ||
|
||
import { | ||
FormErrorState, | ||
PureFormErrorState, | ||
FormErrorStateProps, | ||
PureFormErrorStateProps, | ||
FormErrorStateChildrenArgs, | ||
GetErrorArgs, | ||
} from './FormErrorState'; | ||
// @ts-ignore | ||
import { Button } from './Button'; | ||
// @ts-ignore | ||
import { Input as UnstyledInput } from './Input'; | ||
|
||
export default { | ||
title: 'Design System/forms/FormErrorState', | ||
component: FormErrorState, | ||
}; | ||
|
||
const FormWrapper = styled.div` | ||
padding: 3em 12em; | ||
`; | ||
|
||
const Input = styled(UnstyledInput)` | ||
padding-bottom: 1em; | ||
`; | ||
|
||
const onSubmit = (e: React.FormEvent<HTMLFormElement>) => { | ||
e.preventDefault(); | ||
action('onClick')(e); | ||
}; | ||
|
||
const Children = ({ | ||
onSubmit: handleSubmit, | ||
getFormErrorFieldProps, | ||
}: FormErrorStateChildrenArgs) => { | ||
// Use whatever form state management you need. This is just an example. | ||
const [input1Value, setInput1Value] = useState(''); | ||
const [input2Value, setInput2Value] = useState(''); | ||
return ( | ||
// @ts-ignore | ||
<form onSubmit={handleSubmit}> | ||
<Input | ||
id="input-1" | ||
{...getFormErrorFieldProps({ | ||
id: 'input-1', | ||
validate: (value: string) => | ||
!value && `There is an error with this field with value: "${value}"`, | ||
})} | ||
value={input1Value} | ||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => { | ||
action('change')(e.target.value); | ||
setInput1Value(e.target.value); | ||
}} | ||
startFocused | ||
appearance="secondary" | ||
label="Label" | ||
hideLabel | ||
/> | ||
<Input | ||
id="input-2" | ||
{...getFormErrorFieldProps({ | ||
id: 'input-2', | ||
validate: (value: string) => `There is an error with this field with value: "${value}"`, | ||
})} | ||
value={input2Value} | ||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => { | ||
action('change')(e.target.value); | ||
setInput2Value(e.target.value); | ||
}} | ||
appearance="secondary" | ||
label="Label" | ||
hideLabel | ||
/> | ||
<Button appearance="secondary" type="submit"> | ||
Submit | ||
</Button> | ||
</form> | ||
); | ||
}; | ||
|
||
const decorators = [(storyFn: any) => <FormWrapper>{storyFn()}</FormWrapper>]; | ||
|
||
export const Default = (args: FormErrorStateProps) => <FormErrorState {...args} />; | ||
Default.decorators = decorators; | ||
Default.args = { | ||
onSubmit, | ||
children: Children, | ||
}; | ||
|
||
const pureActions = makeActions('onFocus', 'onBlur', 'onMouseEnter', 'onMouseLeave', 'trackErrors'); | ||
|
||
export const PureMultipleErrors = (args: PureFormErrorStateProps) => ( | ||
<PureFormErrorState {...args} /> | ||
); | ||
PureMultipleErrors.decorators = decorators; | ||
PureMultipleErrors.args = { | ||
...pureActions, | ||
getError: (args: GetErrorArgs) => `There is an error with this field with value: "${args.value}"`, | ||
primaryFieldId: 'input-2', | ||
blurredFieldIds: new Set(['input-1', 'input-2']), | ||
children: Children, | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,172 @@ | ||
/* eslint-disable no-unused-expressions */ | ||
import React, { useEffect, useCallback, useState } from 'react'; | ||
|
||
interface FormErrorFieldProps { | ||
onMouseEnter: () => void; | ||
onMouseLeave: () => void; | ||
onFocus: () => void; | ||
onBlur: () => void; | ||
error: (value: string) => string | void; | ||
suppressErrorMessage: boolean; | ||
} | ||
|
||
interface GetFormErrorFieldPropsArgs { | ||
id: string; | ||
validate: (value: string) => string | void; | ||
} | ||
|
||
export interface GetErrorArgs extends GetFormErrorFieldPropsArgs { | ||
value: string; | ||
} | ||
|
||
export interface FormErrorStateChildrenArgs { | ||
onSubmit: Function; | ||
getFormErrorFieldProps: (args: GetFormErrorFieldPropsArgs) => FormErrorFieldProps; | ||
} | ||
|
||
export interface FormErrorStateProps { | ||
onSubmit: Function; | ||
children: (args: FormErrorStateChildrenArgs) => JSX.Element; | ||
} | ||
|
||
export interface PureFormErrorStateProps extends FormErrorStateProps { | ||
primaryFieldId: string; | ||
onMouseEnter: (id: string) => void; | ||
onMouseLeave: (id: string) => void; | ||
onFocus: (id: string) => void; | ||
onBlur: (id: string) => void; | ||
getError: (args: GetErrorArgs) => void; | ||
} | ||
|
||
export const PureFormErrorState = ({ | ||
children, | ||
onSubmit, | ||
onMouseEnter, | ||
onMouseLeave, | ||
onBlur, | ||
onFocus, | ||
getError, | ||
primaryFieldId, | ||
}: PureFormErrorStateProps) => { | ||
const getFormErrorFieldProps = ({ id, validate }: GetFormErrorFieldPropsArgs) => ({ | ||
onMouseEnter: () => onMouseEnter(id), | ||
onMouseLeave: () => onMouseLeave(id), | ||
onFocus: () => onFocus(id), | ||
onBlur: () => onBlur(id), | ||
error: (value: string) => getError({ id, value, validate }), | ||
suppressErrorMessage: !primaryFieldId || primaryFieldId !== id, | ||
}); | ||
|
||
return children({ getFormErrorFieldProps, onSubmit }); | ||
}; | ||
|
||
export const FormErrorState: React.FunctionComponent<FormErrorStateProps> = ({ | ||
onSubmit, | ||
...rest | ||
}) => { | ||
const [focusedFieldId, setFocusedFieldId] = useState(undefined); | ||
// The lastInteractionFieldId is used to control error messaging for fields that | ||
// are not active, but had an error after a recent focus. | ||
const [lastInteractionFieldId, setLastInteractionFieldId] = useState(undefined); | ||
const [hoveredFieldId, setHoveredFieldId] = useState(undefined); | ||
// The primary field is the field that's visual cues take precedence over any | ||
// others given the entire form's focused & hover states. | ||
// Use this to control things like error messaging priority. | ||
const [primaryFieldId, setPrimaryFieldId] = useState(undefined); | ||
const [blurredFieldIds, setBlurredFieldIds] = useState(new Set()); | ||
const [erroredFieldIds, setErroredFieldIds] = useState(new Set()); | ||
const [didAttemptSubmission, setDidAttemptSubmission] = useState(false); | ||
|
||
useEffect(() => { | ||
if (hoveredFieldId) setPrimaryFieldId(hoveredFieldId); | ||
else if (focusedFieldId && erroredFieldIds.has(focusedFieldId)) | ||
setPrimaryFieldId(focusedFieldId); | ||
else if (erroredFieldIds.size > 0) setPrimaryFieldId(lastInteractionFieldId); | ||
else if (focusedFieldId) setPrimaryFieldId(focusedFieldId); | ||
else setPrimaryFieldId(undefined); | ||
}, [focusedFieldId, hoveredFieldId, lastInteractionFieldId, erroredFieldIds]); | ||
|
||
// Wrap the submit handler to control form error state once it has been submitted | ||
const handleSubmit = useCallback( | ||
(...args: any[]) => { | ||
setDidAttemptSubmission(true); | ||
onSubmit(...args); | ||
}, | ||
[onSubmit] | ||
); | ||
|
||
// There are a lot of pieces of state that can affect the callbacks. Rather | ||
// than list each one in every callback which could lead to one being left | ||
// out easily, just regenerate the callbacks when any of them change. | ||
const callbackRegenValues = [ | ||
focusedFieldId, | ||
lastInteractionFieldId, | ||
hoveredFieldId, | ||
primaryFieldId, | ||
blurredFieldIds, | ||
erroredFieldIds, | ||
didAttemptSubmission, | ||
]; | ||
|
||
const trackErrorsAndValidate = useCallback(({ id, validate, value }) => { | ||
const error = validate(value); | ||
if (error) { | ||
!erroredFieldIds.has(id) && setErroredFieldIds(new Set(erroredFieldIds.add(id))); | ||
} else { | ||
erroredFieldIds.delete(id) && setErroredFieldIds(new Set(erroredFieldIds)); | ||
} | ||
return error; | ||
}, callbackRegenValues); | ||
|
||
const wasFieldTouched = useCallback( | ||
(id: string) => blurredFieldIds.has(id) || didAttemptSubmission, | ||
callbackRegenValues | ||
); | ||
|
||
const isErrorVisible = useCallback( | ||
(id: string) => wasFieldTouched(id) && erroredFieldIds.has(id), | ||
callbackRegenValues | ||
); | ||
|
||
const onFocus = useCallback((id: string) => setFocusedFieldId(id), callbackRegenValues); | ||
|
||
const onBlur = useCallback((id: string) => { | ||
!blurredFieldIds.has(id) && setBlurredFieldIds(blurredFieldIds.add(id)); | ||
setLastInteractionFieldId(focusedFieldId); | ||
setFocusedFieldId(undefined); | ||
}, callbackRegenValues); | ||
|
||
// We only care about the hover state of previously blurred fields. | ||
// We don't want to show error tooltips for fields that haven't been | ||
// visited yet. In the case that the form has already had an attempted | ||
// submission, all errors will be visible. | ||
const onMouseEnter = useCallback((id: string) => { | ||
if (isErrorVisible(id)) setHoveredFieldId(id); | ||
}, callbackRegenValues); | ||
|
||
const onMouseLeave = useCallback((id: string) => { | ||
if (isErrorVisible(id)) { | ||
setLastInteractionFieldId(hoveredFieldId); | ||
setHoveredFieldId(undefined); | ||
} | ||
}, callbackRegenValues); | ||
|
||
const getError = useCallback(({ id, value, validate }: GetErrorArgs) => { | ||
return wasFieldTouched(id) && trackErrorsAndValidate({ id, validate, value }); | ||
}, callbackRegenValues); | ||
|
||
return ( | ||
<PureFormErrorState | ||
{...rest} | ||
{...{ | ||
primaryFieldId, | ||
onSubmit: handleSubmit, | ||
onFocus, | ||
onBlur, | ||
onMouseEnter, | ||
onMouseLeave, | ||
getError, | ||
}} | ||
/> | ||
); | ||
}; |
Oops, something went wrong.