react-hook-form / react-hook-form

📋 React Hooks for form state management and validation (Web + React Native)
https://react-hook-form.com
MIT License
41.39k stars 2.08k forks source link

Ability to scroll to the input with error #612

Closed GProst closed 4 years ago

GProst commented 4 years ago

It would be nice to have some scrollToError method which would scroll to the highest input found which name matches one of the errors keys.

Currently, there is a similar behavior that happens after the form validation but there are cases when errors are set manually for some fields using setError API, for example, after server's error response.

I believe I could implement such functionality outside RHF but I'd have to store input refs and names separately for this and it just feels like it would be much better if it was implemented in RHF.

Or perhaps, alternatively, if I could access all input refs mapped to fields names from RHF it would make it simpler to implement such scrolling functionality manually.

bluebill1049 commented 4 years ago

I actually used to expose ref for error inputs. Let me think about this, see if it's a good idea to embed in RHF or some alternative solution.

bluebill1049 commented 4 years ago

I think we can expose the ref in the errors object, and let user to do the scroll into the view or any other other stuff.

Tolsee commented 4 years ago

Is anyone already working on this? I would love to work on this if it's available.

bluebill1049 commented 4 years ago

no one is working on it just yet, i think the idea is to expose errors object with ref. so you can do a scrollIntoView and then focus().

errors.firstName.ref.scrollntoView();

// then errors.firstName.ref.focus();
bluebill1049 commented 4 years ago

you can almost build a custom hook for it.

import useScrollToError from 'useScrollToError';

const { scrollIntoError, regsiterErrorRef } = useScrollToError();

useEffect(() => {
  regsiterErrorRef(); // you can register other errors for ref which is not provided. eg server error which field you want to scroll into.
}, [regsiterErrorRef])

useEffect(() => {
  scrollIntoError(errors) 
}, [errors])
bluebill1049 commented 4 years ago

actually we already expose ref inside the error object: https://codesandbox.io/s/nrr4n9p8n4 just need to build that custom hook above ^

GProst commented 4 years ago

@bluebill1049 ref is empty object if an error was set manually via setError: https://codesandbox.io/s/react-hook-form-errors-53jjv

bluebill1049 commented 4 years ago

yea that's why this part:

useEffect(() => {
  regsiterErrorRef(); // you can register other errors for ref which is not provided. eg server error which field you want to scroll into.
}, [regsiterErrorRef])

if you set an error manually there is no ref

bluebill1049 commented 4 years ago

we can even support third argument as ref with setError

GProst commented 4 years ago

@bluebill1049 hmm, I assumed that if we set an error manually on the field name which has ref it should use this ref

bluebill1049 commented 4 years ago

oh good point, if there ref was registered.

bluebill1049 commented 4 years ago

@GProst so if we can fix setError to associated with error ref, we should be pretty good.

GProst commented 4 years ago

@bluebill1049 yeah, I believe this would solve it for me 👍

bluebill1049 commented 4 years ago

👍 should be a pretty easy PR, i will sort it out this weekend for ya

GProst commented 4 years ago

@bluebill1049 awesome, much appreciated ❤️

kancharla-sandeep commented 4 years ago

I'am using a controlled input and don't get input field in ref object. Can you let me know how to do it for controlled input.

bluebill1049 commented 4 years ago

@kancharla-sandeep take a look onFocus prop: https://react-hook-form.com/api#Controller

gendronb commented 4 years ago

Is the third argument to setError (ref) implemented? And documented somewhere?

bluebill1049 commented 4 years ago

Is the third argument to setError (ref) implemented? And documented somewhere?

We don't have a third argument as ref.

deepakpapola commented 3 years ago

can anyone share his workaround if it's done?

vensauro commented 3 years ago

can anyone share his workaround if it's done?

function useScrollToError(errors) {
  useEffect(() => {
    const errorsvalues = Object.values(errors)
    if (errorsvalues.length > 0) {
      errorsvalues[0].ref.scrollIntoView({ behavior: 'smooth' })
    }
  }, [errors])
}
bluebill1049 commented 3 years ago

can anyone share his workaround if it's done?

function useScrollToError(errors) {
  useEffect(() => {
    const errorsvalues = Object.values(errors)
    if (errorsvalues.length > 0) {
      errorsvalues[0].ref.scrollIntoView({ behavior: 'smooth' })
    }
  }, [errors])
}

Thanks @vensauro setFocus is coming, maybe it will be helpful?

jpodpro commented 3 years ago

am i missing something? errorsvalues[0].ref is not a DOM element. i've attached the ref attribute to the element verified that it is correct at this stage. am i supposed to manually manage all my refs? is this for version 7? so little detail...

EDIT: i have determined that i had to upgrade from v6 to v7 to make this work. this is a pretty huge task as v7 has all sorts of breaking changes. :(

vensauro commented 3 years ago

Yes, this is for v7, but can be done for v6,

and i used a enhanced version of this in v7:

function useScrollToError(errors) {
  useEffect(() => {
    for(const error of Object.values(errors)) {
      if(error?.ref) {
        error.ref.scrollIntoView({ behavior: 'smooth' })
        break
      }
    }
  }, [errors])
}

on v6, if using controller, you can use the focus like this:

 React.useEffect(() => {
    if (errors.firstName) errors.firstName.ref.focus();
  }, [errors]);

or you can customize the input, making a shared ref and put an onFocus function on the controller, that make the scroll for the ref, you can pick the ref from the render method, or from the useController return, if you use this ref, this ref will be the html element

ujwaldreamztech commented 3 years ago

Using version 6.13.1 along with yup Note:

  1. yup validations objects should be in the same order as form fields
  2. form elements must have ids which are the same as field names
const onError = (errors, e) => {
      let errorsArray = Object.keys(errors)
      if (errorsArray.length) {
         let temp = document.getElementById(errorsArray[0])
         if (temp) {
            //to focus on error -> window.$(`#${errorsArray[0]}`).focus()
            window.$('html, body').animate({
              scrollTop: window.$(`#${errorsArray[0]}`).offset().top
            }, 1000);
         }
      }
   }

<form onSubmit={handleSubmit(onSubmit, onError)}></form>
ShahriarKh commented 3 years ago

can anyone share his workaround if it's done?

function useScrollToError(errors) {
  useEffect(() => {
    const errorsvalues = Object.values(errors)
    if (errorsvalues.length > 0) {
      errorsvalues[0].ref.scrollIntoView({ behavior: 'smooth' })
    }
  }, [errors])
}

Thanks for sharing your code! Where & when should I call the function?

ShahriarKh commented 3 years ago

Here's a working version, which is a mix of above solutions:

if (errors) {
      const errorsvalues = Object.values(errors)
      if (errorsvalues.length > 0) {
         let firstErrorElement = document.getElementsByName(errorsvalues[0].ref.name)[0];
         firstErrorElement.scrollIntoView({ behavior: `smooth`, block: 'center'});
       }
   }
bluebill1049 commented 3 years ago

setError has a shouldFocus option: https://react-hook-form.com/api/useform/seterror

eberens-nydig commented 3 years ago

One thing I have noticed is errors are not in order they are registered but alphabetical. So the first ref that you scroll to may not be the top-most. I'm not sure how one would go about doing that.

function useScrollToError(errors) {
  useEffect(() => {
    for(const error of Object.values(errors)) {
      if(error?.ref) {
        error.ref.scrollIntoView({ behavior: 'smooth' })
        break
      }
    }
  }, [errors])
}
bluebill1049 commented 3 years ago

@eberens-nydig This is going to tricky especially with deeply nested error objects, perhaps you want to manage your own input order. more issues would be occurred with media query and breakpoints when layout shifts.

rkok commented 3 years ago

Here's one that scrolls to the top-most input:

const elements = Object.keys(errors).map(name => document.getElementsByName(name)[0]).filter(el => !!el);
elements.sort((a, b) => b.scrollHeight - a.scrollHeight);
elements[0]?.scrollIntoView({behavior: 'smooth', block: 'center'});
ShahriarKh commented 3 years ago

Here's one that scrolls to the top-most input:

const elements = Object.keys(errors).map(name => document.getElementsByName(name)[0]).filter(el => !!el);
elements.sort((a, b) => b.scrollHeight - a.scrollHeight);
elements[0]?.scrollIntoView({behavior: 'smooth', block: 'center'});

Thanks for sharing. I don't know why, but for me, a.scrollHeight - b.scrollHeight is the correct thing (not b-a). Maybe because my layout is RTL.

ShahriarKh commented 3 years ago

One thing I have noticed is errors are not in order they are registered but alphabetical. So the first ref that you scroll to may not be the top-most. I'm not sure how one would go about doing that.

There's also another problem: Uncontrolled inputs have higher priority than Controlled inputs and they are scrolled first.

ShahriarKh commented 3 years ago

Another annoying thing is when the user correct their input, the input no longer has an error and the form scrolls to the next one! For example, the name input should only have English characters and at least 3 characters long; when a user named "John" types J o h then the input no longer gives an error at it scrolls to the next one.

SunShinewyf commented 2 years ago

you can pass the "shouldFocusError" to the form, and then pass the ref to the "input" or "textArea", if the formItem has error, the page will scroll to it and it will be focused, it works to me image

https://codesandbox.io/s/react-hooks-form-scroll-to-the-first-error-76bbf for the example above, if you scroll the page to the submit button, and then click the button, the page will scroll to the error input("userName" input)

nandorojo commented 2 years ago

you can pass the "shouldFocusError" to the form, and then pass the ref to the "input" or "textArea", if the formItem has error, the page will scroll to it and it will be focused, it works to me image

https://codesandbox.io/s/react-hooks-form-scroll-to-the-first-error-76bbf for the example above, if you scroll the page to the submit button, and then click the button, the page will scroll to the error input("userName" input)

I'm thinking through how this could work with React Native, where you have to call scrollTo(name) using React Native Anchor.

@bluebill1049 right now, the handleSubmit's onInvalid returns this:

Screen Shot 2021-12-03 at 1 45 37 PM

Since I'm a bit new to the internals of react-hook-form, I'm trying to figure out how to parse this.

Should I just recurse the errors object until I find a ref.name?

Or does this exist already? it would be nice to do something like this for React Native:

handleSubmit(onSubmit, (errors) => {
  const names = getErrorNames(errors)

  scrollTo(names[0])
})

Does something like getErrorNames exist that I could use?

It seems like I want to clone the logic from focusFieldBy, but just to get the name of the field.

https://github.com/react-hook-form/react-hook-form/blob/fd3cb00e40842210d2b148f05788114f73d8e2ac/src/logic/createFormControl.ts#L998-L1005

Thanks!

bluebill1049 commented 2 years ago

@nandorojo that's an interesting project(https://github.com/nandorojo/react-native-anchors), right now react native does have focus management, however for scroll (animated) into the field then focus is something missing both in terms of web and react-native.

Should I just recurse the errors object until I find a ref.name?

That's pretty much what we have internally.

nandorojo commented 2 years ago

@bluebill1049 good to know, thanks. I have a Formik example on that project, but I'm migrating to RHF, so I'm trying to see how to apply similar logic.

Another question I have regarding errors: is there a way for me to "test" a given field to check if there is an error?

Something like:

const values = getValues() // { firstName: '' }
const rules = { required: true }

hasErrors(values, { firstName: rules })

Use case

I'm building a wrapper around RHF for multi-step forms that you can persist on the server. That way, if a user is on step 4 and refreshes, they return to the same place and the form state rehydrates.

The API looks like this:

Screen Shot 2021-12-03 at 4 57 23 PM

One issue I'm having: in order to set the initial step, I need to find the first step that has errored / invalid fields.

Currently, I'm just checking if any of the items in fields is undefined or an empty string.

However, I'd like to do something like this:

const values = getValues()
const firstInvalidStepIndex = steps.findIndex(step => {
  const doesStepHaveErrors = Object.entries(step.fields).some(([fieldPath, fieldRules]) => {
    const isError = hasErrors(values, { [fieldPath]: fieldRules }) // does hasErrors exist?
    return isError
  })
  return doesStepHaveErrors
})

setStepIndex(firstInvalidStepIndex)

Is there a function like hasErrors that I can use, which takes in the values and the rules for specific fields and tells me if there is an error?

Thanks again!

bluebill1049 commented 2 years ago

@nandorojo What about split your step into multiple form (each has its own useForm)? Would that be an easier solution than having a single useForm for the entire form application?

Another question I have regarding errors: is there a way for me to "test" a given field to check if there is an error?

nandorojo commented 2 years ago

What about split your step into multiple form (each has its own useForm)? Would that be an easier solution than having a single useForm for the entire form application?

This was my initial intuition too. However, it doesn't exactly fit my situation for 2 reasons:

  1. Dependent data: Step 2 (email) relies on Step 1 (firstName), since it shows a text like "Hey firstName, welcome to app".
  2. Final submission: The last step of the form creates the user object (post('/user')). During each step before that, we are dealing with an incomplete user. So splitting it into pieces wouldn't exactly work, since the backend needs to receive a complete user at the end. In the intermediate steps, I am storing a userDraft in the backend, which is simply a JSON blob of the current form state.

Hope that clears things up!

if you are using build in, you probably have to use validate function, so you can test aginst individual function.

I think this is exactly what I needed. However, I don't think validate is exported from react-hook-form. Where can I access it?

bluebill1049 commented 2 years ago
  1. Dependent data: Step 2 (email) relies on Step 1 (firstName), since it shows a text like "Hey firstName, welcome to app".

I would store valid data in a state management library (many out there), I think that's what they r good at. (good use case)

  1. Final submission

I would run a schema validation (possible to share with your server) to validate the entire payload. In fact, you can have an extra state to capture the step validation as well.

I think this is exactly what I needed. However, I don't think validate is exported from react-hook-form. Where can I access it?

// i was refer to expose those function into individual validate functions
const testFunction = (value) => {
  return !!value;
}

<input {...register({ validate: testFunction })} />
nandorojo commented 2 years ago

I would store valid data in a state management library (many out there), I think that's what they r good at. (good use case)

Yeah I agree. The thing is, then I can't take advantage of all the benefits of RHF, like render performance and validation for each step. I already have the multi-step form working really well with RHF. The only missing piece is validating fields outside of the form itself.

https://user-images.githubusercontent.com/13172299/144716942-c4fb4261-2294-4567-9616-ac5c1d9d8ab4.mp4

I would run a schema validation (possible to share with your server) to validate the entire payload. In fact, you can have an extra state to capture the step validation as well.

Yeah I'll probably do this at the last step. However, for intermediate steps, it would be helpful to use the rules API from Controller. Something like:

import { validateRules } from 'react-hook-form'

const rules = {
  firstName: {
    required: true
  }
}

const errors = validateRules(getValues(), rules)

I could use something like yup with the resolver too, but I prefer to use rules inside of Controller, since I want to partially validate at each step. That is to say, I only want to check for errors on fields that are rendered.

If you're at step 3, I don't want to check if step 4 has errors yet. If I use the yup resolver at the root useForm, then it validates the entire form, as opposed to only the form up to a current step.

I know it's a pretty unique use-case, and if there's nothing like validateRules, I can still make it work with a custom validate function, like you mentioned. But figured I'd pass along what I was trying out. Happy to share my code for multi-step forms too once I finish it up.

For what it's worth, I'm a bit new to React Hook Form, so I appreciate you walking me through the options.

Thanks again @bluebill1049!

bluebill1049 commented 2 years ago

@nandorojo this looks awesome!

Yeah I agree. The thing is, then I can't take advantage of all the benefits of RHF, like render performance and validation for each step. I already have the multi-step form working really well with RHF. The only missing piece is validating fields outside of the form itself.

by the way, I meant only submit valid data into your store, so really only on handleSubmit.

hanumanthraju commented 2 years ago
  const elements = Object.keys(errors).map(name => document.getElementsByName(name)[0]).filter(el => !!el);
  elements.sort((a, b) => b.scrollHeight - a.scrollHeight);
  elements[0]?.scrollIntoView({behavior: 'smooth', block: 'center'});

above doesn't;t works in case form as neasted fields

I have a huge form with nested fields in the form sample example of neted fields in form

address:{
   addressLine1:'some address',
    city:'some city'
    state:' some state'
}

for showing error message i have used span element eg: <span class="error-container" style="color: red;">This field is required</span> and used below function for scrolling to error field it works for nested field form

export const useScrollToError = (errors:any, errorEleClass:string) => {
    useEffect(() => {
        if (Object.keys(errors)?.length > 0) {
            const container = document.querySelector(`.${errorEleClass}`);
            container?.scrollIntoView({ behavior: 'smooth', block: 'center' });
        }
    }, [errors])
}
ruslan-bairamovi-axon commented 2 years ago

Can anyone tell me how to focus on error with Material UI and React Hook Form with Controller ?

          <Controller
            control={control}
            name="name"
            render={({ field, fieldState }) => (
              <TextField
                required
                label={t('main.performer_name')}
                {...field}
                id="name"
                disabled={translationFields !== undefined}
                fullWidth
                {...textFieldError(fieldState.error)}
              />
            )}
          />

react-hook-version : 7.34.2 and zodas a validation I have checked setFocus and error.ref.focus() or error.ref.scrollIntoView({}) but not helped

87Hz commented 2 years ago

Can anyone tell me how to focus on error with Material UI and React Hook Form with Controller ?

          <Controller
            control={control}
            name="name"
            render={({ field, fieldState }) => (
              <TextField
                required
                label={t('main.performer_name')}
                {...field}
                id="name"
                disabled={translationFields !== undefined}
                fullWidth
                {...textFieldError(fieldState.error)}
              />
            )}
          />

react-hook-version : 7.34.2 and zodas a validation I have checked setFocus and error.ref.focus() or error.ref.scrollIntoView({}) but not helped

@ruslan-bairamovi-axon

Can you try to pass the ref as inputRef. As MUI TextField will pass ref to FormControl which is the div wrapper rather than the lable or input, hence .focus() might not work there.

<Controller
  control={control}
  name="name"
  render={({ field: {ref, ...restField}, fieldState }) => (
    <TextField
      // Other props...
      {...restField}
      inputRef={ref}
      />
  )}
/>
ShahriarKh commented 1 year ago

Here's one that scrolls to the top-most input:

const elements = Object.keys(errors).map(name => document.getElementsByName(name)[0]).filter(el => !!el);
elements.sort((a, b) => b.scrollHeight - a.scrollHeight);
elements[0]?.scrollIntoView({behavior: 'smooth', block: 'center'});

For some reason, in my form, all inputs have the same x.scrollHeight, x.offsetHeight, x.scrollTop, x.clientHeight and x.clientTop; so I used x.getBoundingClientRect().top to sort them correctly.

ShahriarKh commented 1 year ago

Thanks to all insights from people participating in this issue, I was able to handle this for onSubmit mode. With this approach, every time the user hits the submit button, the form will scroll to the first element of elements with validation error and focus it. The next "scroll & focus" will happen after the user hits the submit button again. You can play with useForm modes and tweak the functions to achieve what you want.

// setting shouldFocusError to false so we can have our custom scroll function
const methods = useForm({ shouldFocusError: false })

// using a state here to make the "scroll & focus" happen once per submission
const [canFocus, setCanFocus] = useState(true)

const onError = () => {
  setCanFocus(true)
}

useEffect(() => {
  if (methods.formState.errors && canFocus) {
    // Sort inputs based on their position on the page. (the order will be based on validaton order otherwise)
    const elements = Object.keys(methods.formState.errors)
      .map((name) => document.getElementsByName(name)[0])
      .filter((el) => !!el);
    elements.sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top);

    if (elements.length > 0) {
      let errorElement = elements[0];
      errorElement.scrollIntoView({ behavior: "smooth", block: "center" }); // scrollIntoView options are not supported in Safari
      errorElement.focus({ preventScroll: true });
      setCanFocus(false) // so the form doesn't suddenly jump to the next input that has error.
    }
  }
}, [methods.formState, canFocus]);

return(
  <form submit={methods.handleSubmit(onSubmit, onError)}>
     ...
  </form>
)