Skip to content

Commit

Permalink
Adding PINCodeInput
Browse files Browse the repository at this point in the history
  • Loading branch information
corbanbrook committed May 5, 2024
1 parent 453b644 commit 70d3e47
Show file tree
Hide file tree
Showing 5 changed files with 238 additions and 0 deletions.
28 changes: 28 additions & 0 deletions src/components/PINCodeInput/PINCodeInput.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { StoryObj, Meta, StoryFn } from '@storybook/react'
import { useState } from 'react'

import { PINCodeInput } from './PINCodeInput'

export default {
title: 'Forms/PINCodeInput',
component: PINCodeInput,
} as Meta<typeof PINCodeInput>

type Story = StoryObj<typeof PINCodeInput>

const StoryWrapper: StoryFn<typeof PINCodeInput> = args => {
const [pinCode, setPINCode] = useState([])

return (
<>
<PINCodeInput {...(args as any)} onChange={setPINCode} value={pinCode} />
</>
)
}

export const Default: Story = {
render: StoryWrapper,
args: {
digits: 6,
},
}
144 changes: 144 additions & 0 deletions src/components/PINCodeInput/PINCodeInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { createRef, Fragment, useEffect, useMemo } from 'react'

import { Box } from '../Box'

import * as styles from './styles.css'

interface PINCodeInputProps {
digits: number
group?: number
onChange: (code: string[]) => void
onConfirm?: () => void
disabled?: boolean
value: string[]
}

export const PINCodeInput = (props: PINCodeInputProps) => {
const {
value,
digits = 6,
group,
onChange,
onConfirm,
disabled = false,
} = props

const inputRefs = useMemo(() => {
return range(0, digits).map(() => createRef<HTMLInputElement>())
}, [digits])

useEffect(() => {
inputRefs[0]?.current?.focus()
}, [inputRefs])

const handleChange = (idx: number, character: string) => {
if (!/^\d$/.test(character)) {
character = ''
}

const curr = [...value]
curr[idx] = character

if (character !== '') {
inputRefs[idx + 1]?.current?.focus()
}

onChange(curr)
}

const isValid = () => value.join('').length === digits

const handleKeyDown = (
idx: number,
ev: React.KeyboardEvent<HTMLInputElement>
) => {
const currentRef = inputRefs[idx].current
const prevRef = inputRefs[idx - 1]?.current
const nextRef = inputRefs[idx + 1]?.current

switch (ev.key) {
case 'Backspace':
ev.preventDefault()

if (currentRef) {
currentRef.value = ''
handleChange(idx, '')
}

prevRef?.focus()
break

case 'ArrowLeft':
ev.preventDefault()
prevRef?.focus()
break

case 'ArrowRight':
ev.preventDefault()
nextRef?.focus()
break

case 'Enter':
ev.preventDefault()
if (isValid()) {
onConfirm?.()
}
break

default:
// Fire an onChange event even if the key pressed is the same as the current value
if (currentRef?.value === ev.key) {
ev.preventDefault()
handleChange(idx, ev.key)
}
}
}

const handlePaste = (
idx: number,
ev: React.ClipboardEvent<HTMLInputElement>
) => {
const pasted = ev.clipboardData.getData('text/plain')
const filtered = pasted.replace(/\D/g, '')
const re = new RegExp(`^\\d{${digits}}$`)

if (re.test(filtered)) {
inputRefs[0]?.current?.focus()

onChange(filtered.split(''))

setTimeout(() => {
inputRefs[inputRefs.length - 1]?.current?.focus()
})
}
}

return (
<Box gap="2">
{range(0, digits).map(idx => (
<Fragment key={idx}>
{!!group && idx > 0 && idx % group === 0 && <span />}
<Box
as="input"
className={styles.digitInput}
value={value[idx] || ''}
ref={inputRefs[idx]}
type="text"
inputMode="numeric"
maxLength={1}
disabled={disabled}
onFocus={ev => ev.target.select()}
onPaste={ev => handlePaste(idx, ev)}
onChange={ev => handleChange(idx, ev.target.value)}
onKeyDown={ev => {
handleKeyDown(idx, ev)
}}
/>
</Fragment>
))}
</Box>
)
}

export const range = (start: number, end: number) =>
Array.from({ length: end - start }, (v, k) => k + start)
1 change: 1 addition & 0 deletions src/components/PINCodeInput/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { PINCodeInput } from './PINCodeInput'
64 changes: 64 additions & 0 deletions src/components/PINCodeInput/styles.css.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { style } from '@vanilla-extract/css'

import { vars } from '~/css'

import { textVariants } from '../Text'

export const digit = style([
textVariants({ variant: 'large' }),
{
width: '40px',
height: '48px',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
padding: '10px',
background: vars.colors.backgroundSecondary,
borderRadius: vars.radii.sm,
color: vars.colors.text100,
},
])

export const digitInput = style([
textVariants({ variant: 'large' }),
{
height: '48px',
width: '40px',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
padding: '10px',
border: 'none',
borderRadius: vars.radii.sm,
color: vars.colors.text100,
background: 'transparent',
textAlign: 'center',
caretColor: 'transparent',

':disabled': {
cursor: 'default',
opacity: '0.5',
},

'::selection': {
background: 'transparent',
},

boxShadow: `0 0 0 ${vars.borderWidths.thin} ${vars.colors.borderNormal} inset`,

selectors: {
'&:hover:not(&:disabled)': {
borderColor: vars.colors.borderFocus,
},

'&:focus': {
outline: 'none',
},

'&:focus-visible': {
outline: 'none',
boxShadow: `0 0 0 ${vars.borderWidths.thick} ${vars.colors.borderFocus} inset`,
},
},
},
])
1 change: 1 addition & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export { IconButton } from './IconButton'
export { Image } from './Image'
export { ModalPrimitive, Modal } from './Modal'
export { NumericInput } from './NumericInput'
export { PINCodeInput } from './PINCodeInput'
export { Placeholder } from './Placeholder'
export { Progress } from './Progress'
export { ControlledRadioGroup, RadioGroup } from './RadioGroup'
Expand Down

0 comments on commit 70d3e47

Please sign in to comment.