Skip to content

Commit

Permalink
feat(input-otp): support hold-to copy+paste+cut Android & iOS
Browse files Browse the repository at this point in the history
  • Loading branch information
guilhermerodz committed Feb 20, 2024
1 parent 5e57847 commit 298fe7e
Show file tree
Hide file tree
Showing 6 changed files with 138 additions and 41 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "input-otp",
"version": "0.2.4",
"version": "0.3.0-beta.1",
"description": "One-time password input component for React.",
"main": "index.js",
"module": "./dist/index.mjs",
Expand Down
164 changes: 127 additions & 37 deletions src/input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import * as React from 'react'

import { syncTimeouts } from './sync-timeouts'
import { OTPInputProps, SelectionType } from './types'
import { Metadata, OTPInputProps, SelectionType } from './types'
import { REGEXP_ONLY_DIGITS } from './regexp'

export const OTPInput = React.forwardRef<HTMLInputElement, OTPInputProps>(
Expand Down Expand Up @@ -74,6 +74,13 @@ export const OTPInput = React.forwardRef<HTMLInputElement, OTPInputProps>(
React.useEffect(() => {
const el = mutateInputRefAndReturn()

const styleEl = document.createElement('style')
document.head.appendChild(styleEl)
const styleSheet = styleEl.sheet
styleSheet.insertRule(
'[data-input-otp]::selection { background: transparent !important; }',
)

const updateRootHeight = () => {
if (el) {
el.style.setProperty('--root-height', `${el.clientHeight}px`)
Expand All @@ -85,11 +92,12 @@ export const OTPInput = React.forwardRef<HTMLInputElement, OTPInputProps>(

return () => {
resizeObserver.disconnect()
document.head.removeChild(styleEl)
}
}, [])

/** Mirrors for UI rendering purpose only */
const [isHoveringContainer, setIsHoveringContainer] = React.useState(false)
const [isHoveringInput, setIsHoveringInput] = React.useState(false)
const [isFocused, setIsFocused] = React.useState(false)
const [mirrorSelectionStart, setMirrorSelectionStart] = React.useState<
number | null
Expand Down Expand Up @@ -165,6 +173,32 @@ export const OTPInput = React.forwardRef<HTMLInputElement, OTPInputProps>(
onChange(newValue)
}

// Fix iOS pasting
function _pasteListener(e: React.ClipboardEvent<HTMLInputElement>) {
const content = e.clipboardData.getData('text/plain')
e.preventDefault()

const start = inputRef.current?.selectionStart
const end = inputRef.current?.selectionEnd

const isReplacing = start !== end

const newValueUncapped = isReplacing
? value.slice(0, start) + content + value.slice(end) // Replacing
: value.slice(0, start) + content + value.slice(start) // Inserting
const newValue = newValueUncapped.slice(0, maxLength)

if (newValue.length > 0 && regexp && !regexp.test(newValue)) {
return
}

onChange(newValue)

const _start = Math.min(newValue.length, maxLength - 1)
const _end = newValue.length
inputRef.current?.setSelectionRange(_start, _end)
setMirrorSelectionStart(_start)
setMirrorSelectionEnd(_end)
}

function _keyDownListener(e: React.KeyboardEvent<HTMLInputElement>) {
Expand Down Expand Up @@ -239,32 +273,89 @@ export const OTPInput = React.forwardRef<HTMLInputElement, OTPInputProps>(
}
}

function onContainerClick(e: React.MouseEvent<HTMLInputElement>) {
e.preventDefault()
if (!inputRef.current) {
return
}
inputRef.current.focus()
}

/** Rendering */
// TODO: memoize
const renderedInput = (
<input
autoComplete={props.autoComplete || 'one-time-code'}
{...props}
data-input-otp
inputMode={inputMode}
pattern={regexp?.source}
style={inputStyle}
maxLength={maxLength}
value={value}
ref={inputRef}
onChange={_changeListener}
onSelect={_selectListener}
// onSelectionChange={_selectListener}
// onSelectStart={_selectListener}
// onBeforeXrSelect={_selectListener}
onSelect={e => {
_selectListener()
props.onSelect?.(e)
}}
onMouseOver={(e: any) => {
setIsHoveringInput(true)
props.onMouseOver?.(e)
}}
onMouseLeave={(e: any) => {
setIsHoveringInput(false)
props.onMouseLeave?.(e)
}}
onPaste={e => {
_pasteListener(e)
props.onPaste?.(e)
}}
// onTouchStart={e => {
// const isFocusing = document.activeElement === e.currentTarget
// if (isFocusing) {
// // e.preventDefault()
// }

// props.onTouchStart?.(e)
// }}
onTouchEnd={e => {
const isFocusing = document.activeElement === e.currentTarget
if (isFocusing) {
setTimeout(() => {
_selectListener()
}, 50)
}

props.onTouchEnd?.(e)
}}
onTouchMove={e => {
const isFocusing = document.activeElement === e.currentTarget
if (isFocusing) {
setTimeout(() => {
_selectListener()
}, 50)
}

props.onTouchMove?.(e)
}}
onClick={e => {
inputRef.current.__metadata__ = Object.assign(
{},
inputRef.current?.__metadata__,
{ lastClickTimestamp: Date.now() },
)

props.onClick?.(e)
}}
onDoubleClick={e => {
const lastClickTimestamp =
inputRef.current?.__metadata__?.lastClickTimestamp

const isFocusing = document.activeElement === e.currentTarget
if (
lastClickTimestamp !== undefined &&
isFocusing &&
Date.now() - lastClickTimestamp <= 300 // Fast enough click
) {
e.currentTarget.setSelectionRange(0, e.currentTarget.value.length)
syncTimeouts(_selectListener)
}

props.onDoubleClick?.(e)
}}
onInput={e => {
syncTimeouts(_selectListener)

Expand Down Expand Up @@ -321,14 +412,14 @@ export const OTPInput = React.forwardRef<HTMLInputElement, OTPInputProps>(
}
}),
isFocused,
isHovering: !props.disabled && isHoveringContainer,
isHovering: !props.disabled && isHoveringInput,
})
}, [
render,
maxLength,
isFocused,
props.disabled,
isHoveringContainer,
isHoveringInput,
value,
mirrorSelectionStart,
mirrorSelectionEnd,
Expand All @@ -340,20 +431,6 @@ export const OTPInput = React.forwardRef<HTMLInputElement, OTPInputProps>(
className={containerClassName}
{...props}
ref={ref}
onMouseOver={(e: any) => {
setIsHoveringContainer(true)
props.onMouseOver?.(e)
}}
onMouseLeave={(e: any) => {
setIsHoveringContainer(false)
props.onMouseLeave?.(e)
}}
onMouseDown={(e: any) => {
if (!props.disabled) {
onContainerClick(e)
}
props.onMouseDown?.(e)
}}
>
{renderedChildren}
{renderedInput}
Expand All @@ -374,16 +451,29 @@ const rootStyle = (params: { disabled?: boolean }) =>
const inputStyle = {
position: 'absolute',
inset: 0,
opacity: 0,
pointerEvents: 'none',
outline: 'none !important',
opacity: '1', // Mandatory for iOS hold-paste

color: 'transparent',
pointerEvents: 'all',
background: 'transparent',
caretColor: 'transparent',
border: '0 solid transparent',
outline: '0 solid transparent',
lineHeight: '1',
letterSpacing: '-.5em',
fontSize: 'var(--root-height)',
// letterSpacing: '-1em',
// transform: 'scale(1.5)',
// paddingRight: '100%',
// paddingBottom: '100%',
// debugging purposes
// color: 'black',
// background: 'white',
// opacity: '1',
// pointerEvents: 'all',
// inset: undefined,
// position: undefined,
// color: 'black',
// background: 'white',
// opacity: '.5',
// caretColor: 'black',
// padding: '0',
} satisfies React.CSSProperties

function usePrevious<T>(value: T) {
Expand Down
3 changes: 3 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,6 @@ export enum SelectionType {
CHAR = 1,
MULTI = 2,
}
export type Metadata = {
lastClickTimestamp: number
}
6 changes: 5 additions & 1 deletion test/src/tests/base.render.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@ test.describe('Base tests - Render', () => {

await expect(renderer).not.toHaveAttribute('data-test-render-is-hovering')

await renderer.hover()
const _rect = await renderer.boundingBox({ timeout: 2_000 })
expect(_rect).not.toBeNull()
const rect = _rect!
await page.mouse.move(rect.x + rect.width / 2, rect.y + rect.height / 2)

await expect(renderer).toHaveAttribute('data-test-render-is-hovering', 'true')
})
})
2 changes: 1 addition & 1 deletion website/src/app/(local-pages)/example-playground/code.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ function Slot(props: { char: string | null; isActive: boolean }) {
'border-border border-y border-r first:border-l first:rounded-l-md last:rounded-r-md',
'group-hover:border-accent-foreground/20 group-focus-within:border-accent-foreground/20',
'outline outline-0 outline-accent-foreground/20',
{ 'outline-4 outline-accent-foreground z-10': props.isActive },
{ 'outline-4 outline-accent-foreground': props.isActive },
)}
>
{props.char !== null && <div>{props.char}</div>}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ function Slot(props: { char: string | null; isActive: boolean }) {
'border-border border-y border-r first:border-l first:rounded-l-md last:rounded-r-md',
'group-hover:border-accent-foreground/20 group-focus-within:border-accent-foreground/20',
'outline outline-0 outline-accent-foreground/20',
{ 'outline-4 outline-accent-foreground z-10': props.isActive },
{ 'outline-4 outline-accent-foreground': props.isActive },
)}
>
{props.char !== null && <div>{props.char}</div>}
Expand Down

0 comments on commit 298fe7e

Please sign in to comment.