🐥

React Hook FormをしっかりSafari on iOS / Firefox対応する

2021/11/24に公開

React Hook FormsのバリデーションがSafari on iOS / Firefox上で意図通り動かなかったので対策しました。

やりたいこと:バリデーションエラー検出したら、エラー要因のinput要素を表示したい

バリデーションエラーの検出時に下記を行います。

  1. エラー要因のinput要素にfocusを当てる(画面外のときはスクロールして表示)
  2. 上記input要素近くにエラーメッセージ表示

React Hook Formsを使えば簡単に実現できる、ありがちな作りだと思います。

  • うまく動いている例

ご興味ある方はこちらから操作してみてください。
https://react-hook-form-sample.vercel.app/sample/iphone-validation-focus/ok

問題:Chromeは OK だが、Safari on iOS や Firefox は NG

Safari on iOS かつ radio/checkboxでエラー発生 のときは、画面スクロールが動きませんでした。

Input要素が画面外のときにエラーメッセージが見えず、「送信ボタン押してるのに効かない!理由もわからない!」と困ってしまいます。

再現環境を作りましたのでご興味ある方はお試しください。
https://react-hook-form-sample.vercel.app/sample/iphone-validation-focus/ng

Firefoxはスクロールしますが不十分でした。input要素の下部がかすかに見える程度でした。

2021/11/24現在、最新の環境で検証しました

  • react-hook-form: ^7.20.2
  • Safari(iPhone): Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Mobile/15E148 Safari/604.1

原因:HTMLInputElement.focus()の挙動がブラウザごとに異なる

react-hook-formのなかでエラー発生要因のInput要素のfocus()を呼んで実現しています。ただし、Safari(iPhone/iPad)のradio/checkboxはfocus()を呼んでも画面スクロールしないようです・・・。

参考:https://stackoverflow.com/questions/56137175/ios-safari-chrome-wont-scroll-up-to-show-validation-error-message-on-radio-inpu

(フォームが長すぎるから1画面に入るよう短くするのがいいかもね・・・)

対策: onFocusでscrollIntoView()を実行して画面スクロール

inputのラッパーコンポーネント <Input /> を作成し、対象要素にfocusイベントが発生したときにscrollIntoView()を実行して画面スクロールさせました。

inputのラッパーコンポーネント <Input /> を作成

Input.tsx
type InputProps = React.DetailedHTMLProps<
  React.InputHTMLAttributes<HTMLInputElement>,
  HTMLInputElement
>;

const Input = React.forwardRef<HTMLInputElement, InputProps>(
  (props, ref) => {
    const onFocus: FocusEventHandler<HTMLInputElement> = (event) => {
      // バリデーションエラー発生時にradio/checkboxのinputにしっかりスクロールしない問題のワークアラウンド
      // 【問題詳細】
      // Safari on iOS: 全くスクロールしない, FireFox,IE:スクロールが不十分でエラーメッセージが見えない,
      if (props.type !== "radio" && props.type !== "checkbox") {
        return;
      }

      try {
        const element = event.currentTarget;
        // radio/checkboxをタップしたときはスクロールしない
        if (isInviewAll(element)) return;

        // バリデーションエラー発生時のみスクロールする
        // 注意:本対応はIE対応できていない(scrollIntoViewはIE非対応)
        element.scrollIntoView({
          block: "center",
          behavior: "auto",
        });
      } catch (e) {
        console.error(e);
      }
    };

    return <input ref={ref} onFocus={onFocus} {...props} />;
  }
);

const isInviewAll = (element: HTMLInputElement) => {
  const rect = element.getBoundingClientRect();

  // 1 < rect.top は firefox対応
  return 1 < rect.top && rect.bottom < window.innerHeight;
};

Input.displayName = "Input";

export default Input;

react-hook-form の公式マニュアルを参考にしてラッパーコンポーネントを作りました
https://react-hook-form.com/ts#UseFormReturn

利用例:作成した <Input /> は <input /> と同じように使える

sample.tsx
  const {
    register,
    formState: { errors },
    handleSubmit,
  } = useForm();

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <p>Q1. 好きなプログラミング言語はなんですか?</p>
        {errors?.language && (
          <p className="text-red-500 py-2 px-2">
            <span>選択してください</span>
          </p>
        )}
        <Input
          type="radio"
          value="TypeScript"
          {...register("language", { required: true })}
        />
        <label>TypeScript</label>
        <button
          type="submit"
          className="text-white bg-blue-600 py-2 px-4 rounded-md"
        >
          送信
        </button>
      </div>
    </form>
  );

おわりに

今回作成したソースコードです。
https://github.com/daisuke85a/react-hook-form-sample

もっといい方法がありそうなので、コメントやMRを頂けたら嬉しいです!

Discussion