Open12

React Form Hook の iPhone対応

Daisuke SaitoDaisuke Saito

背景

  • React Hook Formにてバリデーションエラーを検出すると、エラー元のinput要素にfocusが当たる。input要素が見えていないときは画面スクロールされる。よくあるやり方として、inputの近くにバリデーションメッセージを表示さると、ユーザーは再入力しやすくてUXがいい感じになる。

課題

対処

  • inputのラッパーコンポーネントとして<Input>コンポーネントを作成し、対象要素にfocusイベントが発生したとき かつ iPhoneの場合は、scrollIntoViewを実行して画面スクロールさせるようにして対処した
Daisuke SaitoDaisuke Saito

こういうのを作った

const Input = React.forwardRef<HTMLInputElement, InputProps>((props, ref) => {
  const internalRef = React.useRef<HTMLInputElement>();
  useEffect(() => {
    // iPhoneでradio/checkboxのinputにfocusが当たらない問題のワークアラウンド
    if (props.type !== "radio" && props.type !== "checkbox") {
      return;
    }
    if (!/iPad|iPhone/.test(navigator.userAgent)) {
      return;
    }

    internalRef.current?.addEventListener("focus", (e) => {
      (e.target as HTMLInputElement).scrollIntoView({
        block: "center",
        behavior: "smooth",
      });
    });
  }, []);
  return <input ref={useMergeRefs(internalRef, ref)} {...props} />;
});
Daisuke SaitoDaisuke Saito

こういう感じで使う

type FormValues = {
  likeLanguage: string;
};

const Home: NextPage = () => {
  const onSubmit = (data: FormValues) => console.log(data);

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

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <label>
        <Input
          type="radio"
          value="C"
          {...register("likeLanguage", { required: true })}
        />
        C
      </label>
      <label>
        <Input
          type="radio"
          value="TypeScript"
          {...register("likeLanguage", { required: true })}
        />
        TypeScript
      </label>
      <label>
        <Input
          type="radio"
          value="PHP"
          {...register("likeLanguage", { required: true })}
        />
        PHP
      </label>
      {errors?.likeLanguage && <p>好きな言語が選択されていません</p>}
      <Input type="submit" />
    </form>
  );
};
Daisuke SaitoDaisuke Saito

useMergeRefsはChakraUIから拝借した

【参考】
https://github.com/chakra-ui/chakra-ui/blob/main/packages/hooks/src/use-merge-refs.ts

/* eslint-disable react-hooks/exhaustive-deps */
import * as React from "react";

type ReactRef<T> = React.Ref<T> | React.MutableRefObject<T>;

export function assignRef<T = any>(ref: ReactRef<T> | undefined, value: T) {
  if (ref == null) return;

  if (typeof ref === "function") {
    ref(value);
    return;
  }

  try {
    // @ts-ignore
    ref.current = value;
  } catch (error) {
    throw new Error(`Cannot assign value '${value}' to ref '${ref}'`);
  }
}

/**
 * React hook that merges react refs into a single memoized function
 *
 * @example
 * import React from "react";
 * import { useMergeRefs } from `@chakra-ui/hooks`;
 *
 * const Component = React.forwardRef((props, ref) => {
 *   const internalRef = React.useRef();
 *   return <div {...props} ref={useMergeRefs(internalRef, ref)} />;
 * });
 */
export function useMergeRefs<T>(...refs: (ReactRef<T> | undefined)[]) {
  return React.useMemo(() => {
    if (refs.every((ref) => ref == null)) {
      return null;
    }
    return (node: T) => {
      refs.forEach((ref) => {
        if (ref) assignRef(ref, node);
      });
    };
  }, refs);
}