pin-input.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454
  1. import * as React from 'react'
  2. import { cn } from '@/lib/utils'
  3. interface PinInputProps {
  4. children:
  5. | React.ReactElement<typeof PinInputField>
  6. | React.ReactElement<typeof PinInputField>[]
  7. /**
  8. * className for the input container
  9. */
  10. className?: string
  11. /**
  12. * `aria-label` for the input fields
  13. */
  14. ariaLabel?: string
  15. /**
  16. * If set, the pin input receives focus on mount, `false` by default
  17. */
  18. autoFocus?: boolean
  19. /**
  20. * Called when value changes
  21. */
  22. onChange?: (value: string) => void
  23. /**
  24. * Called when all inputs have valid value
  25. */
  26. onComplete?: (value: string) => void
  27. /**
  28. * Called when any input doesn't have value
  29. */
  30. onIncomplete?: (value: string) => void
  31. /**
  32. * `name` attribute for input fields
  33. */
  34. name?: string
  35. /**
  36. * `form` attribute for hidden input
  37. */
  38. form?: string
  39. /**
  40. * If set, the input's value will be masked just like password input. This field is `false` by default
  41. */
  42. mask?: boolean
  43. /**
  44. * If set, the pin input component signals to its fields that they should
  45. * use `autocomplete="one-time-code"`. This field is `false` by default
  46. */
  47. otp?: boolean
  48. /**
  49. * Uncontrolled pin input default value.
  50. */
  51. defaultValue?: string
  52. /**
  53. * Controlled pin input value.
  54. */
  55. value?: string
  56. /**
  57. * The type of value pin input should allow, `alphanumeric` by default
  58. */
  59. type?: 'numeric' | 'alphanumeric'
  60. /**
  61. * Placeholder for input fields, `○` by default
  62. */
  63. placeholder?: string
  64. /**
  65. * If set, the user cannot set the value, `false` by default
  66. */
  67. readOnly?: boolean
  68. /**
  69. * If set, the input fields are disabled, `false` by default
  70. */
  71. disabled?: boolean
  72. ref?: React.Ref<HTMLDivElement>
  73. }
  74. const PinInputContext = React.createContext<boolean>(false)
  75. const PinInput = ({ className, children, ref, ...props }: PinInputProps) => {
  76. const {
  77. defaultValue,
  78. value,
  79. onChange,
  80. onComplete,
  81. onIncomplete,
  82. placeholder = '○',
  83. type = 'alphanumeric',
  84. name,
  85. form,
  86. otp = false,
  87. mask = false,
  88. disabled = false,
  89. readOnly = false,
  90. autoFocus = false,
  91. ariaLabel = '',
  92. ...rest
  93. } = props
  94. const validChildren = getValidChildren(children)
  95. const length = getInputFieldCount(children)
  96. // pins, pinValue, refMap, ...handlers
  97. const { pins, pinValue, refMap, ...handlers } = usePinInput({
  98. value,
  99. defaultValue,
  100. placeholder,
  101. type,
  102. length,
  103. readOnly,
  104. })
  105. /* call onChange func if pinValue changes */
  106. React.useEffect(() => {
  107. if (!onChange) return
  108. onChange(pinValue)
  109. }, [onChange, pinValue])
  110. /* call onComplete/onIncomplete func if pinValue is valid and completed/incompleted */
  111. const completeRef = React.useRef(pinValue.length === length)
  112. React.useEffect(() => {
  113. if (pinValue.length === length && completeRef.current === false) {
  114. completeRef.current = true
  115. if (onComplete) onComplete(pinValue)
  116. }
  117. if (pinValue.length !== length && completeRef.current === true) {
  118. completeRef.current = false
  119. if (onIncomplete) onIncomplete(pinValue)
  120. }
  121. }, [length, onComplete, onIncomplete, pinValue, pins, value])
  122. /* focus on first input field if autoFocus is set */
  123. React.useEffect(() => {
  124. if (!autoFocus) return
  125. const node = refMap?.get(0)
  126. if (node) {
  127. node.focus()
  128. }
  129. }, [autoFocus, refMap])
  130. const skipRef = React.useRef(0)
  131. let counter = 0
  132. const clones = validChildren.map((child) => {
  133. if (child.type === PinInputField) {
  134. const pinIndex = counter
  135. counter = counter + 1
  136. return React.cloneElement(child, {
  137. name,
  138. inputKey: `input-${pinIndex}`,
  139. value: length > pinIndex ? pins[pinIndex] : '',
  140. onChange: (e: React.ChangeEvent<HTMLInputElement>) =>
  141. handlers.handleChange(e, pinIndex),
  142. onFocus: (e: React.FocusEvent<HTMLInputElement>) =>
  143. handlers.handleFocus(e, pinIndex),
  144. onBlur: () => handlers.handleBlur(pinIndex),
  145. onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) =>
  146. handlers.handleKeyDown(e, pinIndex),
  147. onPaste: (e: React.ClipboardEvent<HTMLInputElement>) =>
  148. handlers.handlePaste(e),
  149. placeholder: placeholder,
  150. type: type,
  151. mask: mask,
  152. autoComplete: otp ? 'one-time-code' : 'off',
  153. disabled: disabled,
  154. readOnly: readOnly,
  155. 'aria-label': ariaLabel
  156. ? ariaLabel
  157. : `Pin input ${counter} of ${length}`,
  158. ref: (node: HTMLInputElement | null) => {
  159. if (node) {
  160. refMap?.set(pinIndex, node)
  161. } else {
  162. refMap?.delete(pinIndex)
  163. }
  164. },
  165. })
  166. }
  167. skipRef.current = skipRef.current + 1
  168. return child
  169. })
  170. return (
  171. <PinInputContext.Provider value={true}>
  172. <div ref={ref} aria-label='Pin Input' className={className} {...rest}>
  173. {clones}
  174. <input type='hidden' name={name} form={form} value={pinValue} />
  175. </div>
  176. </PinInputContext.Provider>
  177. )
  178. }
  179. PinInput.displayName = 'PinInput'
  180. /* ========== PinInputField ========== */
  181. interface _PinInputFieldProps {
  182. mask: boolean
  183. inputKey: string
  184. type: 'numeric' | 'alphanumeric'
  185. }
  186. interface PinInputFieldProps<T>
  187. extends Omit<
  188. React.ComponentPropsWithoutRef<'input'>,
  189. keyof _PinInputFieldProps
  190. > {
  191. component?: T
  192. }
  193. const PinInputField = <T extends React.ElementType = 'input'>({
  194. className,
  195. component,
  196. ...props
  197. }: PinInputFieldProps<T> &
  198. (React.ComponentType<T> extends undefined
  199. ? never
  200. : React.ComponentProps<T>)) => {
  201. const { mask, type, inputKey, ...rest } = props as _PinInputFieldProps &
  202. React.ComponentProps<T>
  203. // Check if PinInputField is used within PinInput
  204. const isInsidePinInput = React.useContext(PinInputContext)
  205. if (!isInsidePinInput) {
  206. throw new Error(
  207. `PinInputField must be used within ${PinInput.displayName}.`
  208. )
  209. }
  210. const Element = component || 'input'
  211. return (
  212. <Element
  213. key={inputKey}
  214. type={mask ? 'password' : type === 'numeric' ? 'tel' : 'text'}
  215. inputMode={type === 'numeric' ? 'numeric' : 'text'}
  216. className={cn('size-10 text-center', className)}
  217. {...rest}
  218. />
  219. )
  220. }
  221. /* ========== usePinInput custom hook ========== */
  222. interface UsePinInputProps {
  223. value: string | undefined
  224. defaultValue: string | undefined
  225. placeholder: string
  226. type: 'numeric' | 'alphanumeric'
  227. length: number
  228. readOnly: boolean
  229. }
  230. const usePinInput = ({
  231. value,
  232. defaultValue,
  233. placeholder,
  234. type,
  235. length,
  236. readOnly,
  237. }: UsePinInputProps) => {
  238. const pinInputs = React.useMemo(
  239. () =>
  240. Array.from({ length }, (_, index) =>
  241. defaultValue
  242. ? defaultValue.charAt(index)
  243. : value
  244. ? value.charAt(index)
  245. : ''
  246. ),
  247. [defaultValue, length, value]
  248. )
  249. const [pins, setPins] = React.useState(pinInputs)
  250. const pinValue = pins.join('').trim()
  251. /**
  252. * Update pins when values change programmatically.
  253. * This syncs the pins if the `defaultValue` or `value` prop is updated.
  254. */
  255. React.useEffect(() => {
  256. setPins(pinInputs)
  257. }, [pinInputs])
  258. const itemsRef = React.useRef<Map<number, HTMLInputElement> | null>(null)
  259. function getMap() {
  260. if (!itemsRef.current) {
  261. // Initialize the Map on first usage.
  262. itemsRef.current = new Map()
  263. }
  264. return itemsRef.current
  265. }
  266. function getNode(index: number) {
  267. const map = getMap()
  268. const node = map?.get(index)
  269. return node
  270. }
  271. function focusInput(itemId: number) {
  272. const node = getNode(itemId)
  273. if (node) {
  274. node.focus()
  275. node.placeholder = ''
  276. }
  277. }
  278. function handleFocus(
  279. event: React.FocusEvent<HTMLInputElement>,
  280. index: number
  281. ) {
  282. event.target.select()
  283. focusInput(index)
  284. }
  285. function handleBlur(index: number) {
  286. const node = getNode(index)
  287. if (node) {
  288. node.placeholder = placeholder
  289. }
  290. }
  291. function updateInputField(val: string, index: number) {
  292. const node = getNode(index)
  293. if (node) {
  294. node.value = val
  295. }
  296. setPins((prev) =>
  297. prev.map((p, i) => {
  298. if (i === index) {
  299. return val
  300. } else {
  301. return p
  302. }
  303. })
  304. )
  305. }
  306. function validate(value: string) {
  307. const NUMERIC_REGEX = /^[0-9]+$/
  308. const ALPHA_NUMERIC_REGEX = /^[a-zA-Z0-9]+$/i
  309. const regex = type === 'alphanumeric' ? ALPHA_NUMERIC_REGEX : NUMERIC_REGEX
  310. return regex.test(value)
  311. }
  312. const pastedVal = React.useRef<null | string>(null)
  313. function handleChange(e: React.ChangeEvent<HTMLInputElement>, index: number) {
  314. const inputValue = e.target.value
  315. const pastedValue = pastedVal.current
  316. const inputChar =
  317. pastedValue && pastedValue.length === length
  318. ? pastedValue.charAt(length - 1)
  319. : inputValue.slice(-1)
  320. if (validate(inputChar)) {
  321. updateInputField(inputChar, index)
  322. pastedVal.current = null
  323. if (inputValue.length > 0) {
  324. focusInput(index + 1)
  325. }
  326. }
  327. }
  328. function handlePaste(event: React.ClipboardEvent<HTMLInputElement>) {
  329. event.preventDefault()
  330. const copyValue = event.clipboardData
  331. .getData('text/plain')
  332. .replace(/[\n\r\s]+/g, '')
  333. const copyArr = copyValue.split('').slice(0, length)
  334. const isValid = copyArr.every((c) => validate(c))
  335. if (!isValid) return
  336. for (let i = 0; i < length; i++) {
  337. if (i < copyArr.length) {
  338. updateInputField(copyArr[i], i)
  339. }
  340. }
  341. pastedVal.current = copyValue
  342. focusInput(copyArr.length < length ? copyArr.length : length - 1)
  343. }
  344. function handleKeyDown(
  345. event: React.KeyboardEvent<HTMLInputElement>,
  346. index: number
  347. ) {
  348. const { ctrlKey, key, shiftKey, metaKey } = event
  349. if (type === 'numeric') {
  350. const canTypeSign =
  351. key === 'Backspace' ||
  352. key === 'Tab' ||
  353. key === 'Control' ||
  354. key === 'Delete' ||
  355. (ctrlKey && key === 'v') ||
  356. (metaKey && key === 'v')
  357. ? true
  358. : !Number.isNaN(Number(key))
  359. if (!canTypeSign || readOnly) {
  360. event.preventDefault()
  361. }
  362. }
  363. if (key === 'ArrowLeft' || (shiftKey && key === 'Tab')) {
  364. event.preventDefault()
  365. focusInput(index - 1)
  366. } else if (key === 'ArrowRight' || key === 'Tab' || key === ' ') {
  367. event.preventDefault()
  368. focusInput(index + 1)
  369. } else if (key === 'Delete') {
  370. event.preventDefault()
  371. } else if (key === 'Backspace') {
  372. event.preventDefault()
  373. updateInputField('', index)
  374. if ((event.target as HTMLInputElement).value === '') {
  375. focusInput(index - 1)
  376. }
  377. }
  378. }
  379. return {
  380. pins,
  381. pinValue,
  382. refMap: getMap(),
  383. handleFocus,
  384. handleBlur,
  385. handleChange,
  386. handlePaste,
  387. handleKeyDown,
  388. }
  389. }
  390. /* ========== Util Func ========== */
  391. const getValidChildren = (children: React.ReactNode) =>
  392. React.Children.toArray(children).filter((child) => {
  393. if (React.isValidElement(child)) {
  394. return React.isValidElement(child)
  395. }
  396. throw new Error(`${PinInput.displayName} contains invalid children.`)
  397. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  398. }) as React.ReactElement<any>[]
  399. const getInputFieldCount = (children: React.ReactNode) =>
  400. React.Children.toArray(children).filter((child) => {
  401. if (React.isValidElement(child) && child.type === PinInputField) {
  402. return React.isValidElement(child)
  403. }
  404. }).length
  405. export { PinInput, PinInputField }