Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add focus field information #11024

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/src/app.tsx
Expand Up @@ -40,6 +40,7 @@ import SetValueAsyncStrictMode from './setValueStrictMode';
import { DelayError } from './delayError';
import './style.css';
import FormComponent from './form';
import FocusComponent from './focus';

const App: React.FC = () => {
return (
Expand Down Expand Up @@ -117,6 +118,7 @@ const App: React.FC = () => {
<Route path="/test" element={<Test />} />
<Route path="/" element={<Welcome />} />
<Route path="/form" element={<FormComponent />} />
<Route path="/focus/:mode" element={<FocusComponent />} />
</Routes>
</BrowserRouter>
);
Expand Down
142 changes: 142 additions & 0 deletions app/src/focus.tsx
@@ -0,0 +1,142 @@
import React, { PropsWithChildren } from 'react';
import {
useForm,
useFormContext,
useController,
FormProvider,
ValidationMode,
Controller,
useFormState,
} from 'react-hook-form';
import ReactSelect from 'react-select';

import { useParams } from 'react-router-dom';

let renderCount = 0;
let renderCount2 = 0;
let renderCount3 = 0;

type Form = {
firstName: string;
lastName: string;
work: {
name: string;
position: string;
};
};

const defaultValues: Form = {
firstName: '',
lastName: '',
work: {
name: '',
position: 'developer',
},
};

const PureReactSelect = React.memo(ReactSelect);

function Section({ children, focus }: PropsWithChildren<{ focus?: boolean }>) {
return (
<section
style={{
borderLeftStyle: 'solid',
borderLeftWidth: '2px',
borderLeftColor: focus ? 'grey' : 'transparent',
paddingLeft: '4px',
marginBottom: '4px',
}}
>
{children}
</section>
);
}

export function WorkSection() {
const { register, getFieldState } = useFormContext<Form>();
const { errors, focusField } = useFormState<Form>();

renderCount2++;
return (
<Section focus={getFieldState('work').isActive}>
<Section focus={getFieldState('work.name').isActive}>
<input
placeholder="work.name"
{...register('work.name', { required: true, minLength: 2 })}
/>
{errors.work?.name && <p>work.name error</p>}
</Section>

<Section focus={getFieldState('work.position').isActive}>
<input
placeholder="work.position"
{...register('work.position', { required: true, minLength: 2 })}
/>
{errors.work?.position && <p>work.position error</p>}
</Section>
{renderCount2}
</Section>
);
}

function LastName() {
const { field, fieldState } = useController({
name: 'lastName',
rules: { required: true, minLength: 2 },
});

renderCount3++;
return (
<Section focus={fieldState.isActive}>
<input {...field} placeholder={field.name} />{' '}
{fieldState.invalid && <p>{fieldState.error?.type}</p>}
{renderCount3}
</Section>
);
}

function FormStatus() {
const formState = useFormState();
return <p>focusField: {String(formState.focusField)}</p>;
}

export default function Focus() {
const { mode } = useParams();
const methods = useForm<Form>({
defaultValues,
mode: mode as keyof ValidationMode,
});

const { handleSubmit, formState, control } = methods;

renderCount++;

return (
<FormProvider {...methods}>
<form onSubmit={handleSubmit(() => {})}>
<Controller
name="firstName"
control={control}
rules={{ required: true, minLength: 2 }}
render={({ field, fieldState }) => (
<Section focus={fieldState.isActive}>
<input {...field} placeholder={field.name} />
{fieldState.invalid && <p>{fieldState.error?.type}</p>}
</Section>
)}
/>

<LastName />

<WorkSection />

<Section>
<button id="submit">submit</button>
</Section>

<p id="renderCount">{renderCount}</p>
<FormStatus />
</form>
</FormProvider>
);
}
11 changes: 8 additions & 3 deletions app/src/welcome/index.tsx
Expand Up @@ -210,19 +210,24 @@ const items: Item[] = [
},
{
title: 'WatchUseFieldArray',
description: 'should behaviour correctly when watching the field array',
description: 'Should behaviour correctly when watching the field array',
slugs: ['/watch-field-array/normal', '/watch-field-array/default'],
},
{
title: 'WatchUseFieldArrayNested',
description: 'should watch the correct nested field array',
description: 'Should watch the correct nested field array',
slugs: ['/watchUseFieldArrayNested'],
},
{
title: 'Form',
description: 'should validate form and submit the request',
description: 'Should validate form and submit the request',
slugs: ['/form'],
},
{
title: 'Focus',
description: 'Should handle focus',
slugs: ['/focus/onSubmit', '/focus/onBlur', '/focus/onChange'],
},
];

const Component: React.FC = () => {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Expand Up @@ -116,7 +116,7 @@
"files": [
{
"path": "./dist/index.cjs.js",
"maxSize": "9.8 kB"
"maxSize": "10.1 kB"
}
]
},
Expand Down
7 changes: 6 additions & 1 deletion reports/api-extractor.md
Expand Up @@ -90,6 +90,7 @@ export type ControllerFieldState = {
invalid: boolean;
isTouched: boolean;
isDirty: boolean;
isActive: boolean;
error?: FieldError;
};

Expand All @@ -106,6 +107,7 @@ export type ControllerProps<TFieldValues extends FieldValues = FieldValues, TNam
export type ControllerRenderProps<TFieldValues extends FieldValues = FieldValues, TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>> = {
onChange: (...event: any[]) => void;
onBlur: Noop;
onFocus: Noop;
value: FieldPathValue<TFieldValues, TName>;
disabled?: boolean;
name: TName;
Expand Down Expand Up @@ -299,6 +301,7 @@ export type FormState<TFieldValues extends FieldValues> = {
isValidating: boolean;
isValid: boolean;
disabled: boolean;
focusField?: FieldPath<TFieldValues>;
submitCount: number;
defaultValues?: undefined | Readonly<DeepPartial<TFieldValues>>;
dirtyFields: Partial<Readonly<FieldNamesMarkedBoolean<TFieldValues>>>;
Expand Down Expand Up @@ -656,6 +659,7 @@ export type UseFormGetFieldState<TFieldValues extends FieldValues> = <TFieldName
invalid: boolean;
isDirty: boolean;
isTouched: boolean;
isActive: boolean;
error?: FieldError;
};

Expand Down Expand Up @@ -693,6 +697,7 @@ export type UseFormRegister<TFieldValues extends FieldValues> = <TFieldName exte
// @public (undocumented)
export type UseFormRegisterReturn<TFieldName extends InternalFieldName = InternalFieldName> = {
onChange: ChangeHandler;
onFocus: ChangeHandler;
onBlur: ChangeHandler;
ref: RefCallBack;
name: TFieldName;
Expand Down Expand Up @@ -852,7 +857,7 @@ export type WatchObserver<TFieldValues extends FieldValues> = (value: DeepPartia

// Warnings were encountered during analysis:
//
// src/types/form.ts:431:3 - (ae-forgotten-export) The symbol "Subscription" needs to be exported by the entry point index.d.ts
// src/types/form.ts:434:3 - (ae-forgotten-export) The symbol "Subscription" needs to be exported by the entry point index.d.ts

// (No @packageDocumentation comment for this package)

Expand Down
2 changes: 2 additions & 0 deletions src/__typetest__/form.test-d.ts
Expand Up @@ -40,6 +40,7 @@ import { useForm } from '../useForm';
invalid: boolean;
isDirty: boolean;
isTouched: boolean;
isActive: boolean;
error?: FieldError;
}>(getFieldState('test'));
}
Expand All @@ -56,6 +57,7 @@ import { useForm } from '../useForm';
invalid: boolean;
isDirty: boolean;
isTouched: boolean;
isActive: boolean;
error?: FieldError;
}>(getFieldState('test', formState));
}
Expand Down
1 change: 1 addition & 0 deletions src/constants.ts
Expand Up @@ -2,6 +2,7 @@ export const EVENTS = {
BLUR: 'blur',
FOCUS_OUT: 'focusout',
CHANGE: 'change',
FOCUS: 'focus',
} as const;

export const VALIDATION_MODE = {
Expand Down
36 changes: 36 additions & 0 deletions src/logic/createFormControl.ts
Expand Up @@ -68,6 +68,7 @@ import unset from '../utils/unset';
import generateWatchOutput from './generateWatchOutput';
import getDirtyFields from './getDirtyFields';
import getEventValue from './getEventValue';
import getFieldIsActive from './getFieldIsActive';
import getFieldValue from './getFieldValue';
import getFieldValueAs from './getFieldValueAs';
import getResolverOptions from './getResolverOptions';
Expand Down Expand Up @@ -113,6 +114,7 @@ export function createFormControl<
dirtyFields: {},
errors: {},
disabled: false,
focusField: undefined,
};
let _fields: FieldRefs = {};
let _defaultValues =
Expand All @@ -135,6 +137,7 @@ export function createFormControl<
};
let delayErrorCallback: DelayCallback | null;
let timer = 0;
let _focusTimeout: ReturnType<typeof setTimeout> | undefined;
const _proxyFormState = {
isDirty: false,
dirtyFields: false,
Expand Down Expand Up @@ -275,6 +278,14 @@ export function createFormControl<
}
};

const updateActiveField = (name?: InternalFieldName) => {
if (name === _formState.focusField) {
return;
}

_subjects.state.next({ focusField: name as any });
};

const updateTouchAndDirty = (
name: InternalFieldName,
fieldValue: unknown,
Expand Down Expand Up @@ -655,6 +666,20 @@ export function createFormControl<
!_state.mount && flushRootRender();
};

const onFocus: ChangeHandler = async (event) => {
const target = event.target;
const name = target.name;
const field: Field = get(_fields, name);

if (!field) {
return;
}

clearTimeout(_focusTimeout);

updateActiveField(name);
};

const onChange: ChangeHandler = async (event) => {
const target = event.target;
let name = target.name;
Expand Down Expand Up @@ -688,6 +713,12 @@ export function createFormControl<
);
const watched = isWatched(name, _names, isBlurEvent);

if (isBlurEvent) {
_focusTimeout = setTimeout(() => {
updateActiveField();
});
}

set(_formValues, name, fieldValue);

if (isBlurEvent) {
Expand Down Expand Up @@ -866,6 +897,10 @@ export function createFormControl<
invalid: !!get((formState || _formState).errors, name),
isDirty: !!get((formState || _formState).dirtyFields, name),
isTouched: !!get((formState || _formState).touchedFields, name),
isActive: getFieldIsActive(
get(formState || _formState, 'focusField'),
name,
),
error: get((formState || _formState).errors, name),
});

Expand Down Expand Up @@ -1010,6 +1045,7 @@ export function createFormControl<
name,
onChange,
onBlur: onChange,
onFocus,
ref: (ref: HTMLInputElement | null): void => {
if (ref) {
register(name, options);
Expand Down
17 changes: 17 additions & 0 deletions src/logic/getFieldIsActive.ts
@@ -0,0 +1,17 @@
import { FieldPath, FieldValues } from '../types';

export default function <TFieldValues extends FieldValues>(
focusField: FieldPath<TFieldValues> | undefined,
name: FieldPath<TFieldValues>,
): boolean {
if (!focusField) {
return false;
}
if (name === focusField) {
return true;
}
if (focusField.length <= name.length) {
return false;
}
return focusField.substring(0, name.length + 1) === name + '.';
}
2 changes: 2 additions & 0 deletions src/types/controller.ts
Expand Up @@ -16,6 +16,7 @@ export type ControllerFieldState = {
invalid: boolean;
isTouched: boolean;
isDirty: boolean;
isActive: boolean;
error?: FieldError;
};

Expand All @@ -25,6 +26,7 @@ export type ControllerRenderProps<
> = {
onChange: (...event: any[]) => void;
onBlur: Noop;
onFocus: Noop;
value: FieldPathValue<TFieldValues, TName>;
disabled?: boolean;
name: TName;
Expand Down