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