Explorar el Código

fix: replace custom otp with input-otp component (#131)

Sat Naing hace 1 año
padre
commit
555b43b316

+ 1 - 0
package.json

@@ -41,6 +41,7 @@
     "clsx": "^2.1.1",
     "cmdk": "1.1.1",
     "date-fns": "^4.1.0",
+    "input-otp": "^1.4.2",
     "js-cookie": "^3.0.5",
     "lucide-react": "^0.488.0",
     "react": "^19.1.0",

+ 14 - 0
pnpm-lock.yaml

@@ -92,6 +92,9 @@ importers:
       date-fns:
         specifier: ^4.1.0
         version: 4.1.0
+      input-otp:
+        specifier: ^1.4.2
+        version: 1.4.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
       js-cookie:
         specifier: ^3.0.5
         version: 3.0.5
@@ -2013,6 +2016,12 @@ packages:
     resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
     engines: {node: '>=0.8.19'}
 
+  input-otp@1.4.2:
+    resolution: {integrity: sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==}
+    peerDependencies:
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
+
   internmap@2.0.3:
     resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
     engines: {node: '>=12'}
@@ -4472,6 +4481,11 @@ snapshots:
 
   imurmurhash@0.1.4: {}
 
+  input-otp@1.4.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
+    dependencies:
+      react: 19.1.0
+      react-dom: 19.1.0(react@19.1.0)
+
   internmap@2.0.3: {}
 
   is-binary-path@2.1.0:

+ 0 - 454
src/components/pin-input.tsx

@@ -1,454 +0,0 @@
-import * as React from 'react'
-import { cn } from '@/lib/utils'
-
-interface PinInputProps {
-  children:
-    | React.ReactElement<typeof PinInputField>
-    | React.ReactElement<typeof PinInputField>[]
-  /**
-   * className for the input container
-   */
-  className?: string
-  /**
-   * `aria-label` for the input fields
-   */
-  ariaLabel?: string
-  /**
-   * If set, the pin input receives focus on mount, `false` by default
-   */
-  autoFocus?: boolean
-  /**
-   * Called when value changes
-   */
-  onChange?: (value: string) => void
-  /**
-   * Called when all inputs have valid value
-   */
-  onComplete?: (value: string) => void
-  /**
-   * Called when any input doesn't have value
-   */
-  onIncomplete?: (value: string) => void
-  /**
-   * `name` attribute for input fields
-   */
-  name?: string
-  /**
-   * `form` attribute for hidden input
-   */
-  form?: string
-  /**
-   * If set, the input's value will be masked just like password input. This field is `false` by default
-   */
-  mask?: boolean
-  /**
-   * If set, the pin input component signals to its fields that they should
-   * use `autocomplete="one-time-code"`. This field is `false` by default
-   */
-  otp?: boolean
-  /**
-   * Uncontrolled pin input default value.
-   */
-  defaultValue?: string
-  /**
-   * Controlled pin input value.
-   */
-  value?: string
-  /**
-   * The type of value pin input should allow, `alphanumeric` by default
-   */
-  type?: 'numeric' | 'alphanumeric'
-  /**
-   * Placeholder for input fields, `○` by default
-   */
-  placeholder?: string
-  /**
-   * If set, the user cannot set the value, `false` by default
-   */
-  readOnly?: boolean
-  /**
-   * If set, the input fields are disabled, `false` by default
-   */
-  disabled?: boolean
-  ref?: React.Ref<HTMLDivElement>
-}
-
-const PinInputContext = React.createContext<boolean>(false)
-
-const PinInput = ({ className, children, ref, ...props }: PinInputProps) => {
-  const {
-    defaultValue,
-    value,
-    onChange,
-    onComplete,
-    onIncomplete,
-    placeholder = '○',
-    type = 'alphanumeric',
-    name,
-    form,
-    otp = false,
-    mask = false,
-    disabled = false,
-    readOnly = false,
-    autoFocus = false,
-    ariaLabel = '',
-    ...rest
-  } = props
-
-  const validChildren = getValidChildren(children)
-
-  const length = getInputFieldCount(children)
-
-  // pins, pinValue, refMap, ...handlers
-  const { pins, pinValue, refMap, ...handlers } = usePinInput({
-    value,
-    defaultValue,
-    placeholder,
-    type,
-    length,
-    readOnly,
-  })
-
-  /* call onChange func if pinValue changes */
-  React.useEffect(() => {
-    if (!onChange) return
-    onChange(pinValue)
-  }, [onChange, pinValue])
-
-  /* call onComplete/onIncomplete func if pinValue is valid and completed/incompleted */
-  const completeRef = React.useRef(pinValue.length === length)
-  React.useEffect(() => {
-    if (pinValue.length === length && completeRef.current === false) {
-      completeRef.current = true
-      if (onComplete) onComplete(pinValue)
-    }
-    if (pinValue.length !== length && completeRef.current === true) {
-      completeRef.current = false
-      if (onIncomplete) onIncomplete(pinValue)
-    }
-  }, [length, onComplete, onIncomplete, pinValue, pins, value])
-
-  /* focus on first input field if autoFocus is set */
-  React.useEffect(() => {
-    if (!autoFocus) return
-    const node = refMap?.get(0)
-    if (node) {
-      node.focus()
-    }
-  }, [autoFocus, refMap])
-
-  const skipRef = React.useRef(0)
-  let counter = 0
-  const clones = validChildren.map((child) => {
-    if (child.type === PinInputField) {
-      const pinIndex = counter
-      counter = counter + 1
-      return React.cloneElement(child, {
-        name,
-        inputKey: `input-${pinIndex}`,
-        value: length > pinIndex ? pins[pinIndex] : '',
-        onChange: (e: React.ChangeEvent<HTMLInputElement>) =>
-          handlers.handleChange(e, pinIndex),
-        onFocus: (e: React.FocusEvent<HTMLInputElement>) =>
-          handlers.handleFocus(e, pinIndex),
-        onBlur: () => handlers.handleBlur(pinIndex),
-        onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) =>
-          handlers.handleKeyDown(e, pinIndex),
-        onPaste: (e: React.ClipboardEvent<HTMLInputElement>) =>
-          handlers.handlePaste(e),
-        placeholder: placeholder,
-        type: type,
-        mask: mask,
-        autoComplete: otp ? 'one-time-code' : 'off',
-        disabled: disabled,
-        readOnly: readOnly,
-        'aria-label': ariaLabel
-          ? ariaLabel
-          : `Pin input ${counter} of ${length}`,
-        ref: (node: HTMLInputElement | null) => {
-          if (node) {
-            refMap?.set(pinIndex, node)
-          } else {
-            refMap?.delete(pinIndex)
-          }
-        },
-      })
-    }
-    skipRef.current = skipRef.current + 1
-    return child
-  })
-
-  return (
-    <PinInputContext.Provider value={true}>
-      <div ref={ref} aria-label='Pin Input' className={className} {...rest}>
-        {clones}
-        <input type='hidden' name={name} form={form} value={pinValue} />
-      </div>
-    </PinInputContext.Provider>
-  )
-}
-PinInput.displayName = 'PinInput'
-
-/* ========== PinInputField ========== */
-
-interface _PinInputFieldProps {
-  mask: boolean
-  inputKey: string
-  type: 'numeric' | 'alphanumeric'
-}
-
-interface PinInputFieldProps<T>
-  extends Omit<
-    React.ComponentPropsWithoutRef<'input'>,
-    keyof _PinInputFieldProps
-  > {
-  component?: T
-}
-
-const PinInputField = <T extends React.ElementType = 'input'>({
-  className,
-  component,
-  ...props
-}: PinInputFieldProps<T> &
-  (React.ComponentType<T> extends undefined
-    ? never
-    : React.ComponentProps<T>)) => {
-  const { mask, type, inputKey, ...rest } = props as _PinInputFieldProps &
-    React.ComponentProps<T>
-
-  // Check if PinInputField is used within PinInput
-  const isInsidePinInput = React.useContext(PinInputContext)
-  if (!isInsidePinInput) {
-    throw new Error(
-      `PinInputField must be used within ${PinInput.displayName}.`
-    )
-  }
-
-  const Element = component || 'input'
-
-  return (
-    <Element
-      key={inputKey}
-      type={mask ? 'password' : type === 'numeric' ? 'tel' : 'text'}
-      inputMode={type === 'numeric' ? 'numeric' : 'text'}
-      className={cn('size-10 text-center', className)}
-      {...rest}
-    />
-  )
-}
-
-/* ========== usePinInput custom hook ========== */
-
-interface UsePinInputProps {
-  value: string | undefined
-  defaultValue: string | undefined
-  placeholder: string
-  type: 'numeric' | 'alphanumeric'
-  length: number
-  readOnly: boolean
-}
-
-const usePinInput = ({
-  value,
-  defaultValue,
-  placeholder,
-  type,
-  length,
-  readOnly,
-}: UsePinInputProps) => {
-  const pinInputs = React.useMemo(
-    () =>
-      Array.from({ length }, (_, index) =>
-        defaultValue
-          ? defaultValue.charAt(index)
-          : value
-            ? value.charAt(index)
-            : ''
-      ),
-    [defaultValue, length, value]
-  )
-
-  const [pins, setPins] = React.useState(pinInputs)
-  const pinValue = pins.join('').trim()
-
-  /**
-   * Update pins when values change programmatically.
-   * This syncs the pins if the `defaultValue` or `value` prop is updated.
-   */
-  React.useEffect(() => {
-    setPins(pinInputs)
-  }, [pinInputs])
-
-  const itemsRef = React.useRef<Map<number, HTMLInputElement> | null>(null)
-
-  function getMap() {
-    if (!itemsRef.current) {
-      // Initialize the Map on first usage.
-      itemsRef.current = new Map()
-    }
-    return itemsRef.current
-  }
-
-  function getNode(index: number) {
-    const map = getMap()
-    const node = map?.get(index)
-    return node
-  }
-
-  function focusInput(itemId: number) {
-    const node = getNode(itemId)
-    if (node) {
-      node.focus()
-      node.placeholder = ''
-    }
-  }
-
-  function handleFocus(
-    event: React.FocusEvent<HTMLInputElement>,
-    index: number
-  ) {
-    event.target.select()
-    focusInput(index)
-  }
-
-  function handleBlur(index: number) {
-    const node = getNode(index)
-    if (node) {
-      node.placeholder = placeholder
-    }
-  }
-
-  function updateInputField(val: string, index: number) {
-    const node = getNode(index)
-
-    if (node) {
-      node.value = val
-    }
-
-    setPins((prev) =>
-      prev.map((p, i) => {
-        if (i === index) {
-          return val
-        } else {
-          return p
-        }
-      })
-    )
-  }
-
-  function validate(value: string) {
-    const NUMERIC_REGEX = /^[0-9]+$/
-    const ALPHA_NUMERIC_REGEX = /^[a-zA-Z0-9]+$/i
-    const regex = type === 'alphanumeric' ? ALPHA_NUMERIC_REGEX : NUMERIC_REGEX
-    return regex.test(value)
-  }
-
-  const pastedVal = React.useRef<null | string>(null)
-  function handleChange(e: React.ChangeEvent<HTMLInputElement>, index: number) {
-    const inputValue = e.target.value
-    const pastedValue = pastedVal.current
-    const inputChar =
-      pastedValue && pastedValue.length === length
-        ? pastedValue.charAt(length - 1)
-        : inputValue.slice(-1)
-
-    if (validate(inputChar)) {
-      updateInputField(inputChar, index)
-      pastedVal.current = null
-      if (inputValue.length > 0) {
-        focusInput(index + 1)
-      }
-    }
-  }
-
-  function handlePaste(event: React.ClipboardEvent<HTMLInputElement>) {
-    event.preventDefault()
-    const copyValue = event.clipboardData
-      .getData('text/plain')
-      .replace(/[\n\r\s]+/g, '')
-    const copyArr = copyValue.split('').slice(0, length)
-
-    const isValid = copyArr.every((c) => validate(c))
-
-    if (!isValid) return
-
-    for (let i = 0; i < length; i++) {
-      if (i < copyArr.length) {
-        updateInputField(copyArr[i], i)
-      }
-    }
-
-    pastedVal.current = copyValue
-    focusInput(copyArr.length < length ? copyArr.length : length - 1)
-  }
-
-  function handleKeyDown(
-    event: React.KeyboardEvent<HTMLInputElement>,
-    index: number
-  ) {
-    const { ctrlKey, key, shiftKey, metaKey } = event
-
-    if (type === 'numeric') {
-      const canTypeSign =
-        key === 'Backspace' ||
-        key === 'Tab' ||
-        key === 'Control' ||
-        key === 'Delete' ||
-        (ctrlKey && key === 'v') ||
-        (metaKey && key === 'v')
-          ? true
-          : !Number.isNaN(Number(key))
-
-      if (!canTypeSign || readOnly) {
-        event.preventDefault()
-      }
-    }
-
-    if (key === 'ArrowLeft' || (shiftKey && key === 'Tab')) {
-      event.preventDefault()
-      focusInput(index - 1)
-    } else if (key === 'ArrowRight' || key === 'Tab' || key === ' ') {
-      event.preventDefault()
-      focusInput(index + 1)
-    } else if (key === 'Delete') {
-      event.preventDefault()
-    } else if (key === 'Backspace') {
-      event.preventDefault()
-      updateInputField('', index)
-      if ((event.target as HTMLInputElement).value === '') {
-        focusInput(index - 1)
-      }
-    }
-  }
-
-  return {
-    pins,
-    pinValue,
-    refMap: getMap(),
-    handleFocus,
-    handleBlur,
-    handleChange,
-    handlePaste,
-    handleKeyDown,
-  }
-}
-
-/* ========== Util Func ========== */
-
-const getValidChildren = (children: React.ReactNode) =>
-  React.Children.toArray(children).filter((child) => {
-    if (React.isValidElement(child)) {
-      return React.isValidElement(child)
-    }
-    throw new Error(`${PinInput.displayName} contains invalid children.`)
-    // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  }) as React.ReactElement<any>[]
-
-const getInputFieldCount = (children: React.ReactNode) =>
-  React.Children.toArray(children).filter((child) => {
-    if (React.isValidElement(child) && child.type === PinInputField) {
-      return React.isValidElement(child)
-    }
-  }).length
-
-export { PinInput, PinInputField }

+ 74 - 0
src/components/ui/input-otp.tsx

@@ -0,0 +1,74 @@
+import * as React from 'react'
+import { OTPInput, OTPInputContext } from 'input-otp'
+import { MinusIcon } from 'lucide-react'
+import { cn } from '@/lib/utils'
+
+function InputOTP({
+  className,
+  containerClassName,
+  ...props
+}: React.ComponentProps<typeof OTPInput> & {
+  containerClassName?: string
+}) {
+  return (
+    <OTPInput
+      data-slot='input-otp'
+      containerClassName={cn(
+        'flex items-center gap-2 has-disabled:opacity-50',
+        containerClassName
+      )}
+      className={cn('disabled:cursor-not-allowed', className)}
+      {...props}
+    />
+  )
+}
+
+function InputOTPGroup({ className, ...props }: React.ComponentProps<'div'>) {
+  return (
+    <div
+      data-slot='input-otp-group'
+      className={cn('flex items-center', className)}
+      {...props}
+    />
+  )
+}
+
+function InputOTPSlot({
+  index,
+  className,
+  ...props
+}: React.ComponentProps<'div'> & {
+  index: number
+}) {
+  const inputOTPContext = React.useContext(OTPInputContext)
+  const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
+
+  return (
+    <div
+      data-slot='input-otp-slot'
+      data-active={isActive}
+      className={cn(
+        'data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]',
+        className
+      )}
+      {...props}
+    >
+      {char}
+      {hasFakeCaret && (
+        <div className='pointer-events-none absolute inset-0 flex items-center justify-center'>
+          <div className='animate-caret-blink bg-foreground h-4 w-px duration-1000' />
+        </div>
+      )}
+    </div>
+  )
+}
+
+function InputOTPSeparator({ ...props }: React.ComponentProps<'div'>) {
+  return (
+    <div data-slot='input-otp-separator' role='separator' {...props}>
+      <MinusIcon />
+    </div>
+  )
+}
+
+export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }

+ 30 - 22
src/features/auth/otp/components/otp-form.tsx

@@ -11,11 +11,15 @@ import {
   FormControl,
   FormField,
   FormItem,
+  FormLabel,
   FormMessage,
 } from '@/components/ui/form'
-import { Input } from '@/components/ui/input'
-import { Separator } from '@/components/ui/separator'
-import { PinInput, PinInputField } from '@/components/pin-input'
+import {
+  InputOTP,
+  InputOTPGroup,
+  InputOTPSlot,
+  InputOTPSeparator,
+} from '@/components/ui/input-otp'
 
 type OtpFormProps = HTMLAttributes<HTMLFormElement>
 
@@ -26,13 +30,14 @@ const formSchema = z.object({
 export function OtpForm({ className, ...props }: OtpFormProps) {
   const navigate = useNavigate()
   const [isLoading, setIsLoading] = useState(false)
-  const [disabledBtn, setDisabledBtn] = useState(true)
 
   const form = useForm<z.infer<typeof formSchema>>({
     resolver: zodResolver(formSchema),
     defaultValues: { otp: '' },
   })
 
+  const otp = form.watch('otp')
+
   function onSubmit(data: z.infer<typeof formSchema>) {
     setIsLoading(true)
     showSubmittedData(data)
@@ -54,32 +59,35 @@ export function OtpForm({ className, ...props }: OtpFormProps) {
           control={form.control}
           name='otp'
           render={({ field }) => (
-            <FormItem className='space-y-1'>
+            <FormItem>
+              <FormLabel className='sr-only'>One-Time Password</FormLabel>
               <FormControl>
-                <PinInput
+                <InputOTP
+                  maxLength={6}
                   {...field}
-                  className='flex h-10 justify-between'
-                  onComplete={() => setDisabledBtn(false)}
-                  onIncomplete={() => setDisabledBtn(true)}
+                  containerClassName='justify-between sm:[&>[data-slot="input-otp-group"]>div]:w-12'
                 >
-                  {Array.from({ length: 7 }, (_, i) => {
-                    if (i === 3)
-                      return <Separator key={i} orientation='vertical' />
-                    return (
-                      <PinInputField
-                        key={i}
-                        component={Input}
-                        className={`${form.getFieldState('otp').invalid ? 'border-red-500' : ''}`}
-                      />
-                    )
-                  })}
-                </PinInput>
+                  <InputOTPGroup>
+                    <InputOTPSlot index={0} />
+                    <InputOTPSlot index={1} />
+                  </InputOTPGroup>
+                  <InputOTPSeparator />
+                  <InputOTPGroup>
+                    <InputOTPSlot index={2} />
+                    <InputOTPSlot index={3} />
+                  </InputOTPGroup>
+                  <InputOTPSeparator />
+                  <InputOTPGroup>
+                    <InputOTPSlot index={4} />
+                    <InputOTPSlot index={5} />
+                  </InputOTPGroup>
+                </InputOTP>
               </FormControl>
               <FormMessage />
             </FormItem>
           )}
         />
-        <Button className='mt-2' disabled={disabledBtn || isLoading}>
+        <Button className='mt-2' disabled={otp.length < 6 || isLoading}>
           Verify
         </Button>
       </form>