Search code examples
htmlreactjsinputtouch

React Input Text Field - How to Focus on Touch?


I have the following input component that I'm reusing all over the place. It works absolutely great on desktop, but on mobile, I can't seem to get it to focus on press. I'm not sure what I'm doing wrong and everything I search is showing React Native which I'm not using. I saw some suggestions around adding a button, but that just seems weird to me. Should I add an onClick handler to the input?

import React, { useRef } from 'react'

const Input = ({
  label,
  type,
  name,
  placeholder,
  autoComplete,
  required,
  className,
  disabled,
  onChange,
}) => {
  const inputRef = useRef(null)

  const handleFocus = () => {
    if (inputRef.current) {
      inputRef.current.focus()
    }
  }

  return (
    <div className={`${!!label ? 'form-floating' : ''}`}>
      <input
        ref={inputRef}
        className={`form-control ${className ?? ''}`}
        type={type}
        name={name}
        id={name}
        placeholder={placeholder}
        required={required}
        autoComplete={autoComplete}
        disabled={disabled}
        onChange={(e) => onChange({ payload: e })}
        onClick={handleFocus}
      />
      {!!label && <label htmlFor={name}>{placeholder}</label>}     
    </div>
  )
}

export default Input

I expected the cursor to pop up and the keyboard of the device to popup (on mobile), but nothing happened. I know I can set autoFocus, but that doesn't help since it's a re-useable component and can't have autoFocus on multiple instances on a page.

EDIT: Updating to use onClick instead of onFocus. Still does not work. The Inputs are in a modal if that makes a difference. Here's the Login form code

import { useEffect, useState, useReducer } from 'react'
import { useLoginMutation, useGoogleLoginMutation } from './authApiSlice'
import usePersist from '../../hooks/usePersist'
import { useNavigate } from 'react-router-dom'
import { useGoogleLogin } from '@react-oauth/google'
import formReducer from '../../hooks/useReducer'
import Input from '../../components/Input'

const PWD_REGEX =
  /^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-]).{8,15}$/
const USER_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/

const Login = () => {
  // Login hook
  const [login, { isLoading }] = useLoginMutation()
  const [googleLogin, { isLoading: isGoogleLoading }] = useGoogleLoginMutation()

  const navigate = useNavigate()

  const [state, setState] = useReducer(formReducer, {})
  const [persist, setPersist] = usePersist()
  const [errMsg, setErrMsg] = useState('')

  useEffect(() => {
    setState({
      payload: {
        target: {
          name: 'validUsername',
          value: USER_REGEX.test(state.username),
        },
      },
    })
  }, [state.username])

  useEffect(() => {
    setState({
      payload: {
        target: {
          name: 'validPassword',
          value: PWD_REGEX.test(state.password),
        },
      },
    })
  }, [state.password])

  // Login function
  const onLoginClicked = async () => {
    if (!state.validUsername) {
      setState({ payload: { target: { name: 'validUClass', value: false } } })
    } else if (!state.validPassword) {
      setState({ payload: { target: { name: 'validPClass', value: false } } })
    } else {
      try {
        await login({ username: state.username, password: state.password })
        navigate('/')
      } catch (err) {
        if (!err.status) {
          setErrMsg('No Server Response')
        } else {
          setErrMsg(err.message)
        }
      }
    }
  }

  const onGoogleLoginClicked = useGoogleLogin({
    onSuccess: async ({ code }) => {
      try {
        await googleLogin({ code })
        navigate('/')
      } catch (err) {
        console.log(err.message)
      }
    },
    flow: 'auth-code',
  })

  const content = (
    <form>
      <div className='position-relative mb-3'>
        <Input
          type='email'
          className={`${
            state.validUClass && state.validUClass === false ? 'is-invalid' : ''
          }`}
          name='username'
          label={true}
          placeholder='Email Address'
          autoComplete='username'
          onChange={setState}
        />
        <div id='usernameFeedback' className='invalid-feedback'>
          Username must be a valid email address.
        </div>
      </div>
      <div className='input-group mb-3 has-validation'>
        <Input
          type={state.showPassword ? 'text' : 'password'}
          className={`${
            state.validPClass && state.validPClass === false ? 'is-invalid' : ''
          }`}
          name='password'
          placeholder='Password'
          label={true}
          autoComplete='password'
          onChange={setState}
          aria-describedby='eye-addon'
        />
        <div id='passwordFeedback' className='invalid-feedback'>
          Password must contain 1 uppercase, lowercase, numeric, and special
          character
        </div>
        <span
          className='input-group-text bi bi-eye>'
          id='eye-addon'
          onClick={() =>
            setState({
              payload: {
                target: { name: 'showPassword', value: !state.showPassword },
              },
            })
          }
        >
          <i
            className={state.showPassword ? 'bi bi-eye-slash' : 'bi bi-eye'}
          ></i>
        </span>
      </div>
      <div className='pb-2'>
        <input
          type='checkbox'
          className='form-check-input'
          id='persist'
          onChange={() => setPersist((prev) => !prev)}
          checked={persist}
        />
        <label htmlFor='persist' className='ps-2'>
          Trust This Device
        </label>
      </div>
      <button
        className='w-100 mb-2 btn btn-lg rounded-3 btn-primary'
        type='submit'
        disabled={state?.submitting}
        onClick={(e) => {
          e.preventDefault()
          onLoginClicked()
        }}
      >
        {(!isLoading || !isGoogleLoading) && !state.submitting
          ? 'Submit'
          : 'Submitting'}
      </button>
      {errMsg && <div className='register-error'>{errMsg}</div>}

      <div className='text-body-secondary text-center'>
        By clicking Submit, you agree to the terms of use.
      </div>
      <hr className='my-4' />
      <h2 className='fs-5 fw-bold mb-3 text-center'>Or use a third-party</h2>
      <button
        className='w-100 py-2 mb-2 btn btn-outline-primary rounded-3'
        type='button'
        onClick={onGoogleLoginClicked}
      >
        <i className='bi bi-google'></i> Login with Google
      </button>
      <button
        className='w-100 py-2 mb-2 btn btn-outline-primary rounded-3'
        type='button'
      >
        <i className='bi bi-facebook'></i> Login with Facebook
      </button>
      <button
        className='w-100 py-2 mb-2 btn btn-outline-primary rounded-3'
        type='button'
      >
        <i className='bi bi-twitch'></i> Login with Twitch
      </button>
    </form>
  )

  return content
}
export default Login

And the modal code

import { useEffect, useCallback } from 'react'
import { Outlet, useNavigate, useLocation } from 'react-router-dom'

const Modal = () => {
  const location = useLocation()

  const navigate = useNavigate()
  // Closes modal when Escape is pressed
  const handleEscKey = useCallback(
    (event) => {
      if (event.key === 'Escape') {
        navigate(-1)
      }
    },
    [navigate]
  )

  // Listens for Key to close modal
  useEffect(() => {
    document.addEventListener('keyup', handleEscKey, false)

    return () => {
      document.removeEventListener('keyup', handleEscKey, false)
    }
  }, [handleEscKey])

  const content = (
    // Modal background
    <div
      className='modal d-block bg-body-tertiary bg-opacity-50 py-md-5'
      tabIndex='-1'
      role='dialog'
      id='modal'
      onClick={() => navigate(-1)}
    >
      <div // Modal body
        className='modal-dialog modal-fullscreen-sm-down modal-dialog-centered'
        role='document'
        onClick={(e) => e.stopPropagation()}
      >
        <div className='modal-content rounded-4 shadow'>
          <div className='modal-header p-5 pb-4 border-bottom-0'>
            <h1 className='fw-bold mb-0 fs-2 ms-auto'>
              {location.state.context}
            </h1>
            <button
              type='button'
              className='btn-close'
              aria-label='Close'
              onClick={() => navigate(-1)}
            ></button>
          </div>
          <div className='modal-body p-5 pt-0'>
            <Outlet />
          </div>
        </div>
      </div>
    </div>
  )

  return content
}
export default Modal

Solution

  • To make the input field focusable on mobile devices, you can use the onClick event handler rather than onFocus. This will allow users to tap on the field to give focus which will automatically open the keyboard on the smartphones.

    Update:

    Added e.stopPropagation() to the onClick handler of the input field. This will prevent the modal from receiving the click event and allow the input field to receive focus instead.

    Here is the example:

    import React, { useRef } from 'react';
    
    const Input = ({
      label,
      type,
      name,
      placeholder,
      autoComplete,
      required,
      className,
      disabled,
      onChange,
    }) => {
      const inputRef = useRef(null);
    
      const handleFocus = () => {
        if (inputRef.current) {
          inputRef.current.focus();
        }
      };
    
      return (
        <div className={`${!!label ? 'form-floating' : ''}`}>
          <input
            ref={inputRef}
            className={`form-control ${className ?? ''}`}
            type={type}
            name={name}
            id={name}
            placeholder={placeholder}
            required={required}
            autoComplete={autoComplete}
            disabled={disabled}
            onChange={(e) => onChange({ payload: e })}
            onClick={(e) => {
              e.stopPropagation(); // Stop event propagation here
              handleFocus();
            }}
          />
          {!!label && <label htmlFor={name}>{placeholder}</label>}
        </div>
      );
    };
    
    export default Input;