Skip to content

Commit

Permalink
Merge pull request #216 from storybookjs/forms
Browse files Browse the repository at this point in the history
Input styling changes, Form component addition
  • Loading branch information
kylesuss authored Nov 20, 2020
2 parents fc14ef7 + dc8f4e7 commit db4dadc
Show file tree
Hide file tree
Showing 9 changed files with 686 additions and 293 deletions.
106 changes: 106 additions & 0 deletions src/components/FormErrorState.stories.tsx
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,
};
172 changes: 172 additions & 0 deletions src/components/FormErrorState.tsx
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,
}}
/>
);
};
Loading

0 comments on commit db4dadc

Please sign in to comment.