| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454 |
- 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 }
|