From 70d3e47b50044b10aa024e92e756ed98d752f76f Mon Sep 17 00:00:00 2001 From: Corban Riley Date: Sun, 5 May 2024 14:36:36 -0400 Subject: [PATCH] Adding PINCodeInput --- .../PINCodeInput/PINCodeInput.stories.tsx | 28 ++++ src/components/PINCodeInput/PINCodeInput.tsx | 144 ++++++++++++++++++ src/components/PINCodeInput/index.tsx | 1 + src/components/PINCodeInput/styles.css.ts | 64 ++++++++ src/components/index.ts | 1 + 5 files changed, 238 insertions(+) create mode 100644 src/components/PINCodeInput/PINCodeInput.stories.tsx create mode 100644 src/components/PINCodeInput/PINCodeInput.tsx create mode 100644 src/components/PINCodeInput/index.tsx create mode 100644 src/components/PINCodeInput/styles.css.ts diff --git a/src/components/PINCodeInput/PINCodeInput.stories.tsx b/src/components/PINCodeInput/PINCodeInput.stories.tsx new file mode 100644 index 000000000..08d8a4914 --- /dev/null +++ b/src/components/PINCodeInput/PINCodeInput.stories.tsx @@ -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 + +type Story = StoryObj + +const StoryWrapper: StoryFn = args => { + const [pinCode, setPINCode] = useState([]) + + return ( + <> + + + ) +} + +export const Default: Story = { + render: StoryWrapper, + args: { + digits: 6, + }, +} diff --git a/src/components/PINCodeInput/PINCodeInput.tsx b/src/components/PINCodeInput/PINCodeInput.tsx new file mode 100644 index 000000000..54619ce39 --- /dev/null +++ b/src/components/PINCodeInput/PINCodeInput.tsx @@ -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()) + }, [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 + ) => { + 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 + ) => { + 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 ( + + {range(0, digits).map(idx => ( + + {!!group && idx > 0 && idx % group === 0 && } + ev.target.select()} + onPaste={ev => handlePaste(idx, ev)} + onChange={ev => handleChange(idx, ev.target.value)} + onKeyDown={ev => { + handleKeyDown(idx, ev) + }} + /> + + ))} + + ) +} + +export const range = (start: number, end: number) => + Array.from({ length: end - start }, (v, k) => k + start) diff --git a/src/components/PINCodeInput/index.tsx b/src/components/PINCodeInput/index.tsx new file mode 100644 index 000000000..52b687da5 --- /dev/null +++ b/src/components/PINCodeInput/index.tsx @@ -0,0 +1 @@ +export { PINCodeInput } from './PINCodeInput' diff --git a/src/components/PINCodeInput/styles.css.ts b/src/components/PINCodeInput/styles.css.ts new file mode 100644 index 000000000..0b223017a --- /dev/null +++ b/src/components/PINCodeInput/styles.css.ts @@ -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`, + }, + }, + }, +]) diff --git a/src/components/index.ts b/src/components/index.ts index 462deaeaf..83d83241a 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -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'