From dee488fbe65ea25398a8b4bfc9f7435a7db27674 Mon Sep 17 00:00:00 2001 From: Guilherme Rodz <gui.rodz.dev@gmail.com> Date: Sat, 17 Feb 2024 22:59:51 -0300 Subject: [PATCH 1/8] test(playwright): add non-headless slow mode --- package.json | 3 +- playwright.config.ts | 12 +- .../(pages)/(home)/_components/showcase.tsx | 116 ++++++++---------- 3 files changed, 58 insertions(+), 73 deletions(-) diff --git a/package.json b/package.json index ebf99a7..0ac7560 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "dev:website": "turbo run dev --filter=website...", "dev:test": "turbo run dev --filter=test...", "format": "prettier --write .", - "test": "playwright test" + "test": "playwright test", + "test:slow": "WINDOWED_TESTS=true playwright test" }, "keywords": [ "react", diff --git a/playwright.config.ts b/playwright.config.ts index bd7631b..08919b6 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,4 +1,4 @@ -import { defineConfig, devices } from '@playwright/test'; +import { defineConfig, devices } from '@playwright/test' /** * Read environment variables from file. @@ -21,11 +21,11 @@ export default defineConfig({ timeout: 5000, }, /* Run tests in files in parallel */ - fullyParallel: true, + fullyParallel: process.env.WINDOWED_TESTS ? false : true, /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, /* Retry on CI only */ - retries: process.env.CI ? 2 : 0, + retries: process.env.CI ? 1 : 0, /* Opt out of parallel tests on CI. */ workers: process.env.CI ? 1 : undefined, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ @@ -34,6 +34,10 @@ export default defineConfig({ use: { trace: 'on-first-retry', baseURL: 'http://localhost:3039', + headless: process.env.WINDOWED_TESTS ? false : true, + launchOptions: { + slowMo: process.env.WINDOWED_TESTS ? 500 : 0, + }, }, webServer: { command: 'npm run dev', @@ -52,4 +56,4 @@ export default defineConfig({ // use: { ...devices['Pixel 5'] }, // }, // ], -}); +}) diff --git a/website/src/app/(pages)/(home)/_components/showcase.tsx b/website/src/app/(pages)/(home)/_components/showcase.tsx index 9c91f66..3c51437 100644 --- a/website/src/app/(pages)/(home)/_components/showcase.tsx +++ b/website/src/app/(pages)/(home)/_components/showcase.tsx @@ -1,46 +1,31 @@ 'use client' import React from 'react' -import { useForm, Controller } from 'react-hook-form' import { OTPInput } from 'otp-input' import { cn } from '@/lib/utils' -type FormValues = { - otp: string -} - export function Showcase({ className, ...props }: { className?: string }) { - const [formDisabled, setFormDisabled] = React.useState(false) - - const { - control, - handleSubmit, - setFocus, - reset, - setValue, - formState, - register, - } = useForm<FormValues>({ - defaultValues: { - otp: '12', - }, - disabled: formDisabled, - }) + const [value, setValue] = React.useState('') + const inputRef = React.useRef<HTMLInputElement>(null) React.useEffect(() => { setTimeout(() => { - setFocus('otp') + inputRef.current?.focus() }, 2000) - }, [setFocus]) + }, []) - function onSubmit(values: FormValues) { - if (values.otp !== '123 ') { - window.alert('Invalid OTP') - reset() + function onSubmit(e: React.FormEvent<HTMLFormElement>) { + e.preventDefault() + + if (value === '123456') { + // Easter egg... return } - window.alert('Valid OTP') + + const firstDemoInput = + document.querySelector<HTMLInputElement>('#first-demo-input') + firstDemoInput!.focus() } return ( @@ -49,49 +34,44 @@ export function Showcase({ className, ...props }: { className?: string }) { 'mx-auto flex max-w-[980px] justify-center pt-6 pb-4', className, )} - onSubmit={handleSubmit(onSubmit)} + onSubmit={onSubmit} > - <Controller - name="otp" - control={control} - render={({ field }) => ( - <OTPInput - {...field} - containerClassName={cn('group flex items-center')} - maxLength={6} - // regexp={null} // Allow everything - render={({ slots, isFocused }) => ( - <> - <div className="flex"> - {slots.slice(0, 3).map((slot, idx) => ( - <Slot - isFocused={isFocused} - key={idx} - slotChar={slot.char} - isSlotActive={slot.isActive} - animateIdx={idx} - /> - ))} - </div> + <OTPInput + value={value} + onChange={setValue} + containerClassName={cn('group flex items-center')} + maxLength={6} + // regexp={null} // Allow everything + render={({ slots, isFocused }) => ( + <> + <div className="flex"> + {slots.slice(0, 3).map((slot, idx) => ( + <Slot + isFocused={isFocused} + key={idx} + slotChar={slot.char} + isSlotActive={slot.isActive} + animateIdx={idx} + /> + ))} + </div> - {/* Layout inspired by Stripe */} - <div className="flex w-10 justify-center items-center"> - <div className="w-3 h-1 rounded-full bg-border"></div> - </div> + {/* Layout inspired by Stripe */} + <div className="flex w-10 justify-center items-center"> + <div className="w-3 h-1 rounded-full bg-border"></div> + </div> - <div className="flex"> - {slots.slice(3).map((slot, idx) => ( - <Slot - isFocused={isFocused} - key={idx} - slotChar={slot.char} - isSlotActive={slot.isActive} - /> - ))} - </div> - </> - )} - /> + <div className="flex"> + {slots.slice(3).map((slot, idx) => ( + <Slot + isFocused={isFocused} + key={idx} + slotChar={slot.char} + isSlotActive={slot.isActive} + /> + ))} + </div> + </> )} /> </form> From 413197a6ccfcfa7cb8dd002c836d725e9f54f2ba Mon Sep 17 00:00:00 2001 From: Guilherme Rodz <gui.rodz.dev@gmail.com> Date: Sun, 18 Feb 2024 19:28:11 -0300 Subject: [PATCH 2/8] feat(v3): create faster engine --- .prettierrc.json | 11 + package.json | 2 +- src/index.ts | 2 + src/index.tsx | 533 ------------------ src/input.tsx | 352 ++++++++++++ src/regexp.tsx | 3 + src/sync-timeouts.ts | 9 + src/types.ts | 31 + website/next.config.mjs | 4 +- .../(pages)/(home)/_components/showcase.tsx | 7 +- website/src/app/(pages)/(home)/page.tsx | 16 +- 11 files changed, 424 insertions(+), 546 deletions(-) create mode 100644 .prettierrc.json create mode 100644 src/index.ts delete mode 100644 src/index.tsx create mode 100644 src/input.tsx create mode 100644 src/regexp.tsx create mode 100644 src/sync-timeouts.ts create mode 100644 src/types.ts diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..fa1b7ff --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,11 @@ +{ + "resolveGlobalModules": true, + "tabWidth": 2, + "printWidth": 80, + "useTabs": false, + "semi": false, + "singleQuote": true, + "trailingComma": "all", + "arrowParens": "avoid", + "endOfLine": "lf" +} diff --git a/package.json b/package.json index 0ac7560..fe9a08e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "otp-input", - "version": "0.1.0", + "version": "0.2.0", "description": "One-time password input component for React.", "main": "index.js", "module": "./dist/index.mjs", diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..00f2393 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,2 @@ +export * from './input' +export * from './regexp' \ No newline at end of file diff --git a/src/index.tsx b/src/index.tsx deleted file mode 100644 index 12925f7..0000000 --- a/src/index.tsx +++ /dev/null @@ -1,533 +0,0 @@ -'use client' - -import * as React from 'react' - -const SPECIAL_KEYS = ['Meta', 'Alt', 'Control', 'Tab', 'Z'] - -interface OTPInputRenderProps { - slots: { isActive: boolean; char: string | null }[] - isFocused: boolean - isHovering: boolean -} -interface OTPInputProps { - id?: string - name?: string - onBlur?: (...args: any[]) => unknown - disabled?: boolean - - value: string - onChange: (value: string) => unknown - - maxLength: number - regexp?: RegExp | null - inputMode?: 'numeric' | 'text' - allowSpaces?: boolean - allowNavigation?: boolean - - autoFocus?: boolean - - onComplete?: (...args: any[]) => unknown - - render: (props: OTPInputRenderProps) => React.ReactElement - - containerClassName?: string -} -export const OTPInput = React.forwardRef<HTMLDivElement, OTPInputProps>( - ( - { - id, - name, - onBlur, - disabled, - - value: _value, - onChange, - - maxLength, - regexp = /^\d+$/, - inputMode = 'numeric', - allowSpaces = false, - allowNavigation = true, - - autoFocus = false, - - onComplete, - - render, - - containerClassName, - - ...props - }, - ref, - ) => { - // console.count('rerender') - - // TODO: refactor like https://github.com/emilkowalski/vaul/blob/main/src/index.tsx - /** Logic */ - const value = typeof _value === 'string' ? _value : '' - - const inputRef = React.useRef<HTMLInputElement>(null) - React.useImperativeHandle( - ref, - () => { - const el = inputRef.current as HTMLInputElement - - // TODO: support `otp` SMS transport - // if ('OTPCredential' in window) { - // const ac = new AbortController() - // navigator.credentials - // .get({ - // ...{ otp: { transport: ['sms'] } }, - // signal: ac.signal, - // }) - // .then(otp => { - // input.value = otp.code - // }) - // .catch(err => { - // console.log(err) - // }) - // } - - const _select = el.select.bind(el) - el.select = () => { - if (!allowNavigation) { - // Cannot select all chars as navigation is disabled - return - } - - _select() - // Proxy to update the caretData - setSelectionMirror([0, el.value.length]) - } - return el - }, - [], - ) - - const [isHovering, setIsHovering] = React.useState<boolean>(false) - const [isFocused, setIsFocused] = React.useState<boolean>(false) - - React.useEffect(() => { - if (!autoFocus || !inputRef.current) { - return - } - - setTimeout(() => { - const isAutoFocused = document.activeElement === inputRef.current - - if (isAutoFocused) { - setIsFocused(true) - onInputSelect({ - overrideStart: inputRef.current.selectionStart, - overrideEnd: inputRef.current.selectionEnd, - }) - } - }, 1_0) - }, [autoFocus]) - - React.useEffect(() => { - if (value.length === maxLength) { - onComplete?.() - } - }, [value, onComplete]) - - React.useEffect(() => { - if (disabled) { - onInputBlur() - } - }, [disabled]) - - const [selectionMirror, setSelectionMirror] = React.useState< - [number | null, number | null] - >([null, null]) - - // TODO: rename to `mutateInputSelectionAndSyncCaretData` - const mutateInputSelectionAndUpdateMirror = React.useCallback( - (start: number | null, end: number | null) => { - if (!inputRef.current) { - return - } - - if (start === null || end === null) { - setSelectionMirror([start, end]) - inputRef.current.setSelectionRange(start, end) - return - } - - const n = start === maxLength ? maxLength - 1 : start - - const _start = Math.min(n, maxLength - 1) - const _end = n + 1 - - // mutate input selection - inputRef.current.setSelectionRange(_start, _end) - // force UI update - setSelectionMirror([_start, _end]) - }, - [selectionMirror, maxLength], - ) - - const onInputSelect = React.useCallback( - (params: { - e?: React.SyntheticEvent<HTMLInputElement> - overrideStart?: number | null - overrideEnd?: number | null - }) => { - if (!inputRef.current) { - return - } - - if ( - !params.e && - params.overrideStart === undefined && - params.overrideEnd === undefined - ) { - return - } - - const start = - params.overrideStart === undefined - ? params.e!.currentTarget.selectionStart - : params.overrideStart - const end = - params.overrideEnd === undefined - ? params.e!.currentTarget.selectionEnd - : params.overrideEnd - - // Check if there is no selection range - if (start === end && start !== null) { - mutateInputSelectionAndUpdateMirror(start, end) - return - } - - if (selectionMirror[0] === start && selectionMirror[1] === end) { - return - } - setSelectionMirror([start, end]) - }, - [selectionMirror, mutateInputSelectionAndUpdateMirror], - ) - - // Workaround to track the input's selection even if Meta key is pressed - // This was necessary due to the input `onSelect` only being called either 1. before Meta key is pressed or 2. after Meta key is released - // TODO: track `Meta` and `Tab` - const [isSpecialPressed, setIsSpecialPressed] = - React.useState<boolean>(false) - - function syncTimeout() { - return [5, 10, 20, 50].map(delayMs => - setTimeout(() => { - if (!inputRef.current) { - return - } - - onInputSelect({ - overrideStart: inputRef.current.selectionStart, - overrideEnd: inputRef.current.selectionEnd, - }) - }, delayMs), - ) - } - - function onInputKeyDown(e: React.KeyboardEvent<HTMLInputElement>) { - if (SPECIAL_KEYS.indexOf(e.key) !== -1) { - setIsSpecialPressed(true) - - syncTimeout() - } - - // Sync to update UI - if (isSpecialPressed) { - syncTimeout() - } - - if (e.metaKey && e.key.toLowerCase() === 'a' && !allowNavigation) { - e.preventDefault() - return - } - - if ( - !allowNavigation && - (e.key === 'ArrowLeft' || - e.key === 'ArrowRight' || - e.key === 'ArrowUp' || - e.key === 'ArrowDown') - ) { - e.preventDefault() - mutateInputSelectionAndUpdateMirror( - selectionMirror[0], - selectionMirror[1], - ) - } - - if (!inputRef.current) { - return - } - - if (selectionMirror[0] === null) { - return - } - - if (e.key === 'Backspace' && (e.metaKey || e.altKey)) { - // Check if there is a range selection - if ( - inputRef.current.selectionStart !== null && - inputRef.current.selectionEnd !== null && - inputRef.current.selectionStart !== inputRef.current.selectionEnd - ) { - e.preventDefault() - - const valueAfterDeletion = value.slice( - inputRef.current.selectionEnd, - value.length, - ) - - onChange(valueAfterDeletion) - } - } - - if ( - e.key === 'ArrowLeft' && - allowNavigation && - !e.shiftKey && - !e.ctrlKey && - !e.metaKey && - !e.altKey - ) { - e.preventDefault() - - if (selectionMirror[0] !== null && selectionMirror[1] !== null) { - const start = Math.max(0, selectionMirror[0] - 1) - const end = start + 1 - - mutateInputSelectionAndUpdateMirror(start, end) - } - } - if ( - e.key === 'ArrowRight' && - allowNavigation && - !e.shiftKey && - !e.ctrlKey && - !e.metaKey && - !e.altKey - ) { - e.preventDefault() - - if (selectionMirror[0] !== null && selectionMirror[1] !== null) { - const start = Math.min( - selectionMirror[1], - Math.min(value.length, maxLength - 1), - ) - const end = Math.min(maxLength, start + 1) - - mutateInputSelectionAndUpdateMirror(start, end) - } - } - } - - function onInputFocus(e: React.SyntheticEvent<HTMLInputElement>) { - if (!inputRef.current) { - return - } - - setIsFocused(true) - - // Default to the last slot or insert position - const end = Math.min(maxLength, value.length + 1) - const start = Math.max(0, end - 1) - - mutateInputSelectionAndUpdateMirror(start, end) - } - - function onInputChange(e: React.ChangeEvent<HTMLInputElement>) { - const prevValue = e.currentTarget.value - const newValue = e.target.value - - e.preventDefault() - - const valueToTest = allowSpaces - ? newValue.replace(/ /g, '').trim() - : newValue - if ( - regexp !== null && - valueToTest.length > 0 && - !regexp.test(valueToTest) - ) { - return - } - - onChange(newValue.slice(0, maxLength)) - if (newValue.length === maxLength) { - syncTimeout() - } - } - - function onInputKeyUp(e: React.KeyboardEvent<HTMLInputElement>) { - if (SPECIAL_KEYS.indexOf(e.key) !== -1) { - setIsSpecialPressed(false) - - syncTimeout() - } - - if (!inputRef.current) { - return - } - - const start = inputRef.current.selectionStart - const end = inputRef.current.selectionEnd - - if ( - (e.key === 'Meta' || e.key === 'Alt' || e.key === 'Control') && - start !== null && - end !== null && - start === end - ) { - if (value.length === 0) { - // Do nothing - } else if (start === 0) { - mutateInputSelectionAndUpdateMirror(0, 1) - } else if (start === maxLength) { - mutateInputSelectionAndUpdateMirror(maxLength - 1, maxLength) - } else if (start === value.length) { - mutateInputSelectionAndUpdateMirror(value.length, value.length) - } - } - } - - function onInputBlur() { - setIsFocused(false) - setSelectionMirror([null, null]) - - setIsSpecialPressed(false) - - onBlur?.() - } - - function onInputBeforeInput(e: React.FormEvent<HTMLInputElement>) { - if (e.currentTarget.selectionStart === e.currentTarget.selectionEnd) { - if (value.length === maxLength) { - e.nativeEvent.preventDefault() - return - } - } - } - - function onContainerClick(e: React.MouseEvent<HTMLInputElement>) { - e.preventDefault() - - if (!inputRef.current) { - return - } - - if (document.activeElement === inputRef.current) { - return - } - - inputRef.current.focus() - } - - const isSelected = React.useCallback( - (slotIdx: number) => { - return ( - selectionMirror[0] !== null && - selectionMirror[1] !== null && - slotIdx >= selectionMirror[0] && - slotIdx < selectionMirror[1] - ) - }, - [selectionMirror], - ) - - const isCurrent = React.useCallback( - (slotIdx: number) => { - if (selectionMirror[0] === null || selectionMirror[1] === null) { - return false - } - - return slotIdx >= selectionMirror[0] && slotIdx < selectionMirror[1] - }, - [selectionMirror], - ) - - /** JSX */ - const renderedChildren = React.useMemo<ReturnType<typeof render>>(() => { - return render({ - slots: Array.from({ length: maxLength }).map((_, slotIdx) => ({ - char: value[slotIdx] !== undefined ? value[slotIdx] : null, - isActive: isFocused && (isCurrent(slotIdx) || isSelected(slotIdx)), - })), - isFocused, - isHovering, - }) - }, [ - disabled, - isCurrent, - isHovering, - isFocused, - isSelected, - maxLength, - render, - value, - ]) - - // TODO: allow for custom container - return ( - <div - ref={ref} - style={{ - position: 'relative', - cursor: disabled ? 'default' : 'text', - userSelect: 'none', - WebkitUserSelect: 'none', - }} - onMouseDown={disabled ? undefined : onContainerClick} - onMouseOver={() => setIsHovering(true)} - onMouseLeave={() => setIsHovering(false)} - className={containerClassName} - {...props} - > - {renderedChildren} - - <input - inputMode={inputMode} - pattern={regexp ? regexp.source : undefined} - style={{ - position: 'absolute', - inset: 0, - opacity: 0, - pointerEvents: 'none', - outline: 'none !important', - - // debug purposes - // color: 'black', - // background: 'white', - // opacity: '1', - // pointerEvents: 'all', - // inset: undefined, - // position: undefined, - }} - // autoComplete="" // TODO: add support - autoComplete="one-time-code" - autoFocus={autoFocus} - name={name} - id={id} - disabled={disabled} - ref={inputRef} - maxLength={maxLength} - value={value} - onFocus={onInputFocus} - onChange={onInputChange} - onSelect={e => onInputSelect({ e })} - onKeyDown={onInputKeyDown} - onKeyUp={onInputKeyUp} - onBlur={onInputBlur} - onBeforeInput={onInputBeforeInput} - /> - - {/* {JSON.stringify({ caretData: selectionMirror })} */} - </div> - ) - }, -) -OTPInput.displayName = 'OTPInput' diff --git a/src/input.tsx b/src/input.tsx new file mode 100644 index 0000000..a87d3bf --- /dev/null +++ b/src/input.tsx @@ -0,0 +1,352 @@ +import * as React from 'react' + +import { syncTimeouts } from './sync-timeouts' +import { OTPInputProps, SelectionType } from './types' +import { REGEXP_ONLY_DIGITS } from './regexp' + +export const OTPInput = React.forwardRef<HTMLInputElement, OTPInputProps>( + ( + { + value: uncheckedValue, + onChange, + + maxLength, + pattern = REGEXP_ONLY_DIGITS, + inputMode = 'numeric', + allowNavigation = true, + + autoFocus = false, + + onComplete, + + render, + + containerClassName, + + ...props + }, + ref, + ) => { + // Workarounds + const value = typeof uncheckedValue === 'string' ? uncheckedValue : '' + const regexp = pattern + ? typeof pattern === 'string' + ? new RegExp(pattern) + : pattern + : null + + /** useRef */ + const inputRef = React.useRef<HTMLInputElement>(null) + React.useImperativeHandle( + ref, + () => { + const el = inputRef.current as HTMLInputElement + + const _select = el.select.bind(el) + el.select = () => { + if (!allowNavigation) { + // Cannot select all chars as navigation is disabled + return + } + + _select() + // Workaround proxy to update UI as native `.select()` does not trigger focus event + setMirrorSelectionStart(0) + setMirrorSelectionEnd(el.value.length) + } + + return el + }, + [allowNavigation], + ) + + /** Mirrors for UI rendering purpose only */ + const [isHoveringContainer, setIsHoveringContainer] = React.useState(false) + const [isFocused, setIsFocused] = React.useState(false) + const [mirrorSelectionStart, setMirrorSelectionStart] = React.useState< + number | null + >(null) + const [mirrorSelectionEnd, setMirrorSelectionEnd] = React.useState< + number | null + >(null) + + /** Effects */ + React.useEffect(() => { + if (value.length === maxLength) { + onComplete?.(value) + } + }, [maxLength, onComplete, value]) + + /** Event handlers */ + function _selectListener() { + if (!inputRef.current) { + return + } + + const _start = inputRef.current.selectionStart + const _end = inputRef.current.selectionEnd + const isSelected = _start !== null && _end !== null + + if (value.length !== 0 && isSelected) { + const isSingleCaret = _start === _end + const isInsertMode = _start === value.length && value.length < maxLength + + if (isSingleCaret && !isInsertMode) { + const caretPos = _start + + let start: number = -1 + let end: number = -1 + + if (caretPos === 0) { + start = 0 + end = 1 + } else if (caretPos === value.length) { + start = value.length - 1 + end = value.length + } else { + start = caretPos + end = caretPos + 1 + } + + if (start !== -1 && end !== -1) { + inputRef.current.setSelectionRange(start, end) + } + } + } + + syncTimeouts(() => { + setMirrorSelectionStart(inputRef.current?.selectionStart ?? null) + setMirrorSelectionEnd(inputRef.current?.selectionEnd ?? null) + }) + } + + function _changeListener(e: React.ChangeEvent<HTMLInputElement>) { + if ( + e.currentTarget.value.length > 0 && + regexp && + !regexp.test(e.currentTarget.value) + ) { + e.preventDefault() + return + } + onChange(e.currentTarget.value) + } + + function _keyDownListener(e: React.KeyboardEvent<HTMLInputElement>) { + if (!inputRef.current) { + return + } + + const inputSel = [ + inputRef.current.selectionStart, + inputRef.current.selectionEnd, + ] + if (inputSel[0] === null || inputSel[1] === null) { + return + } + + let selectionType: SelectionType + if (inputSel[0] === inputSel[1]) { + selectionType = SelectionType.CARET + } else if (inputSel[1] - inputSel[0] === 1) { + selectionType = SelectionType.CHAR + } else if (inputSel[1] - inputSel[0] > 1) { + selectionType = SelectionType.MULTI + } else { + throw new Error('Could not determine OTPInput selection type') + } + + if ( + e.key === 'ArrowLeft' || + e.key === 'ArrowRight' || + e.key === 'ArrowUp' || + e.key === 'ArrowDown' || + e.key === 'Home' || + e.key === 'End' + ) { + if (!allowNavigation) { + e.preventDefault() + } else { + if ( + e.key === 'ArrowLeft' && + selectionType === SelectionType.CHAR && + !e.shiftKey && + !e.metaKey && + !e.ctrlKey && + !e.altKey + ) { + e.preventDefault() + + const start = Math.max(inputSel[0] - 1, 0) + const end = Math.max(inputSel[1] - 1, 1) + + inputRef.current.setSelectionRange(start, end) + } + + if ( + e.altKey && + !e.shiftKey && + (e.key === 'ArrowLeft' || e.key === 'ArrowRight') + ) { + e.preventDefault() + + if (e.key === 'ArrowLeft') { + inputRef.current.setSelectionRange(0, Math.min(1, value.length)) + } + if (e.key === 'ArrowRight') { + inputRef.current.setSelectionRange( + Math.max(0, value.length - 1), + value.length, + ) + } + } + } + } + } + + function onContainerClick(e: React.MouseEvent<HTMLInputElement>) { + e.preventDefault() + if (!inputRef.current || document.activeElement === inputRef.current) { + return + } + inputRef.current.focus() + } + + /** Rendering */ + // TODO: memoize + const renderedInput = ( + <input + autoComplete={props.autoComplete || 'one-time-code'} + {...props} + inputMode={inputMode} + pattern={regexp?.source} + style={inputStyle} + autoFocus={autoFocus} + maxLength={maxLength} + value={value} + ref={inputRef} + onChange={_changeListener} + onSelect={_selectListener} + // onSelectionChange={_selectListener} + // onSelectStart={_selectListener} + // onBeforeXrSelect={_selectListener} + + onInput={e => { + syncTimeouts(_selectListener) + + props.onInput?.(e) + }} + onKeyDown={e => { + _keyDownListener(e) + syncTimeouts(_selectListener) + + props.onKeyDown?.(e) + }} + onKeyUp={e => { + syncTimeouts(_selectListener) + + props.onKeyUp?.(e) + }} + onFocus={e => { + if (!allowNavigation) { + inputRef.current?.setSelectionRange( + Math.min(inputRef.current.value.length, maxLength - 1), + inputRef.current.value.length, + ) + } + setIsFocused(true) + + props.onFocus?.(e) + }} + onBlur={e => { + setIsFocused(false) + + props.onBlur?.(e) + }} + /> + ) + + const renderedChildren = React.useMemo<ReturnType<typeof render>>(() => { + return render({ + slots: Array.from({ length: maxLength }).map((_, slotIdx) => { + const isActive = + isFocused && + mirrorSelectionStart !== null && + mirrorSelectionEnd !== null && + ((mirrorSelectionStart === mirrorSelectionEnd && + slotIdx === mirrorSelectionStart) || + (slotIdx >= mirrorSelectionStart && slotIdx < mirrorSelectionEnd)) + + const char = value[slotIdx] !== undefined ? value[slotIdx] : null + + return { + char, + isActive, + hasFakeCaret: isActive && char === null, + } + }), + isFocused, + isHovering: !props.disabled && isHoveringContainer, + }) + }, [ + render, + maxLength, + isFocused, + props.disabled, + isHoveringContainer, + value, + mirrorSelectionStart, + mirrorSelectionEnd, + ]) + + return ( + <div + style={rootStyle({ disabled: props.disabled })} + 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} + </div> + ) + }, +) +OTPInput.displayName = 'Input' + +const rootStyle = (params: { disabled?: boolean }) => + ({ + position: 'relative', + cursor: params.disabled ? 'default' : 'text', + userSelect: 'none', + WebkitUserSelect: 'none', + } satisfies React.CSSProperties) + +const inputStyle = { + position: 'absolute', + inset: 0, + opacity: 0, + pointerEvents: 'none', + outline: 'none !important', + // debugging purposes + // color: 'black', + // background: 'white', + // opacity: '1', + // pointerEvents: 'all', + // inset: undefined, + // position: undefined, +} satisfies React.CSSProperties diff --git a/src/regexp.tsx b/src/regexp.tsx new file mode 100644 index 0000000..f39b71c --- /dev/null +++ b/src/regexp.tsx @@ -0,0 +1,3 @@ +export const REGEXP_ONLY_DIGITS = '^\\d+$' +export const REGEXP_ONLY_CHARS = '^[a-zA-Z]+$' +export const REGEXP_ONLY_DIGITS_AND_CHARS = '^[a-zA-Z0-9]+$' diff --git a/src/sync-timeouts.ts b/src/sync-timeouts.ts new file mode 100644 index 0000000..ac908be --- /dev/null +++ b/src/sync-timeouts.ts @@ -0,0 +1,9 @@ +export function syncTimeouts(cb: (...args: any[]) => unknown): number[] { + const t1 = setTimeout(cb, 0) // For faster machines + const t2 = setTimeout(cb, 1_0) + const t3 = setTimeout(cb, 5_0) + return [t1,t2,t3] + + // const t3 = setTimeout(cb, 5_0) + // return [t3] +} \ No newline at end of file diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..9feee0c --- /dev/null +++ b/src/types.ts @@ -0,0 +1,31 @@ +export interface OTPInputRenderProps { + slots: { isActive: boolean; char: string | null; hasFakeCaret: boolean }[] + isFocused: boolean + isHovering: boolean +} +type OverrideProps<T, R> = Omit<T, keyof R> & R +export type OTPInputProps = OverrideProps< + React.InputHTMLAttributes<HTMLInputElement>, + { + value: string + onChange: (...args: any[]) => unknown + + maxLength: number + + autoFocus?: boolean + allowNavigation?: boolean + inputMode?: 'numeric' | 'text' + + onComplete?: (...args: any[]) => unknown + onBlur?: (...args: any[]) => unknown + + render: (props: OTPInputRenderProps) => React.ReactElement + + containerClassName?: string + } +> +export enum SelectionType { + CARET = 0, + CHAR = 1, + MULTI = 2, +} diff --git a/website/next.config.mjs b/website/next.config.mjs index 4678774..61cd5ed 100644 --- a/website/next.config.mjs +++ b/website/next.config.mjs @@ -1,4 +1,6 @@ /** @type {import('next').NextConfig} */ -const nextConfig = {}; +const nextConfig = { + reactStrictMode: false, +}; export default nextConfig; diff --git a/website/src/app/(pages)/(home)/_components/showcase.tsx b/website/src/app/(pages)/(home)/_components/showcase.tsx index 3c51437..fe3a362 100644 --- a/website/src/app/(pages)/(home)/_components/showcase.tsx +++ b/website/src/app/(pages)/(home)/_components/showcase.tsx @@ -2,11 +2,11 @@ import React from 'react' -import { OTPInput } from 'otp-input' import { cn } from '@/lib/utils' +import { OTPInput, REGEXP_ONLY_DIGITS } from 'otp-input' export function Showcase({ className, ...props }: { className?: string }) { - const [value, setValue] = React.useState('') + const [value, setValue] = React.useState('12') const inputRef = React.useRef<HTMLInputElement>(null) React.useEffect(() => { @@ -41,7 +41,8 @@ export function Showcase({ className, ...props }: { className?: string }) { onChange={setValue} containerClassName={cn('group flex items-center')} maxLength={6} - // regexp={null} // Allow everything + allowNavigation={true} + pattern={REGEXP_ONLY_DIGITS} render={({ slots, isFocused }) => ( <> <div className="flex"> diff --git a/website/src/app/(pages)/(home)/page.tsx b/website/src/app/(pages)/(home)/page.tsx index 643d062..4758ba4 100644 --- a/website/src/app/(pages)/(home)/page.tsx +++ b/website/src/app/(pages)/(home)/page.tsx @@ -1,19 +1,19 @@ -import Link from 'next/link' -import { Icons } from '../../../components/icons' +import { Icons } from '@/components/icons' import { PageActions, PageHeader, PageHeaderDescription, PageHeaderHeading, -} from '../../../components/page-header' -import { buttonVariants } from '../../../components/ui/button' -import { siteConfig } from '../../../config/site' -import { cn } from '../../../lib/utils/cn' +} from '@/components/page-header' +import { buttonVariants } from '@/components/ui/button' +import { siteConfig } from '@/config/site' +import { cn } from '@/lib/utils' +import Link from 'next/link' import { Showcase } from './_components/showcase' -export default function IndexPage() { - const fadeUpClassname = 'motion-safe:opacity-0 motion-safe:animate-fade-up' +const fadeUpClassname = 'motion-safe:opacity-0 motion-safe:animate-fade-up' +export default function IndexPage() { return ( <div className="container relative flex-1 flex flex-col justify-center"> <PageHeader> From e74d79ba9c88347e9d2f277bb977223296d04fda Mon Sep 17 00:00:00 2001 From: Guilherme Rodz <gui.rodz.dev@gmail.com> Date: Sun, 18 Feb 2024 19:37:33 -0300 Subject: [PATCH 3/8] test: adapt to v3 and remove unused tests --- test/src/app/props/page.tsx | 2 +- test/src/tests/base.delete-word.spec.ts | 4 ++-- test/src/tests/base.props.spec.ts | 2 +- test/src/tests/with-allow-spaces.spec.ts | 23 ----------------------- 4 files changed, 4 insertions(+), 27 deletions(-) delete mode 100644 test/src/tests/with-allow-spaces.spec.ts diff --git a/test/src/app/props/page.tsx b/test/src/app/props/page.tsx index 644b944..88645c0 100644 --- a/test/src/app/props/page.tsx +++ b/test/src/app/props/page.tsx @@ -13,7 +13,7 @@ export default function Page() { <BaseOTPInput data-testid="otp-input-wrapper-4" containerClassName='testclassname' /> <BaseOTPInput data-testid="otp-input-wrapper-5" maxLength={3} /> <BaseOTPInput data-testid="otp-input-wrapper-6" id='testid' name='testname' /> - <BaseOTPInput data-testid="otp-input-wrapper-7" regexp={/ /} /> + <BaseOTPInput data-testid="otp-input-wrapper-7" pattern={' '} /> </div> ) } diff --git a/test/src/tests/base.delete-word.spec.ts b/test/src/tests/base.delete-word.spec.ts index 88be7f0..31d91da 100644 --- a/test/src/tests/base.delete-word.spec.ts +++ b/test/src/tests/base.delete-word.spec.ts @@ -17,7 +17,7 @@ test.describe('Delete words', () => { await input.press(`${modifier}+Backspace`) await expect(input).toHaveValue('') }) - test('should backspace previous word (including selected character)', async ({ page }) => { + test('should backspace selected char', async ({ page }) => { const input = page.getByTestId('otp-input-wrapper').getByRole('textbox') await input.pressSequentially('123456') @@ -27,7 +27,7 @@ test.describe('Delete words', () => { await input.press('ArrowLeft') await input.press(`${modifier}+Backspace`) - await expect(input).toHaveValue('56') + await expect(input).toHaveValue('12356') }) test('should forward-delete character when pressing delete', async ({ page }) => { const input = page.getByTestId('otp-input-wrapper').getByRole('textbox') diff --git a/test/src/tests/base.props.spec.ts b/test/src/tests/base.props.spec.ts index 66c6642..702f54a 100644 --- a/test/src/tests/base.props.spec.ts +++ b/test/src/tests/base.props.spec.ts @@ -9,7 +9,7 @@ test.describe('Props tests', () => { const input1 = page.getByTestId('otp-input-wrapper-1').getByRole('textbox') const input2 = page.getByTestId('otp-input-wrapper-2').getByRole('textbox') const input3 = page.getByTestId('otp-input-wrapper-3').getByRole('textbox') - const inputWrapper4 = page.getByTestId('otp-input-wrapper-4') + const inputWrapper4 = page.getByTestId('otp-input-wrapper-4').first() const input5 = page.getByTestId('otp-input-wrapper-5').getByRole('textbox') const input6 = page.getByTestId('otp-input-wrapper-6').getByRole('textbox') const input7 = page.getByTestId('otp-input-wrapper-7').getByRole('textbox') diff --git a/test/src/tests/with-allow-spaces.spec.ts b/test/src/tests/with-allow-spaces.spec.ts deleted file mode 100644 index b294786..0000000 --- a/test/src/tests/with-allow-spaces.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { expect, test } from '@playwright/test' - -test.beforeEach(async ({ page }) => { - await page.goto('/with-allow-spaces') -}) - -test.describe('With allow spaces tests', () => { - test('should prevent spaces in the input value', async ({ page }) => { - const input = page.getByTestId('otp-input-wrapper-1').getByRole('textbox') - - await input.pressSequentially('1234567') - await expect(input).toHaveValue('123457') - }) - test('should allow spaces in the input value', async ({ page }) => { - const input = page.getByTestId('otp-input-wrapper-2').getByRole('textbox') - - await input.pressSequentially('1') - await expect(input).toHaveValue('1') - - await input.pressSequentially(' 34') - await expect(input).toHaveValue('1 34') - }) -}) From 77b8e97de20ac908c108c265060b5af736a437fd Mon Sep 17 00:00:00 2001 From: Guilherme Rodz <gui.rodz.dev@gmail.com> Date: Sun, 18 Feb 2024 19:39:58 -0300 Subject: [PATCH 4/8] chore(website): use react strict mode --- website/next.config.mjs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/website/next.config.mjs b/website/next.config.mjs index 61cd5ed..1d61478 100644 --- a/website/next.config.mjs +++ b/website/next.config.mjs @@ -1,6 +1,4 @@ /** @type {import('next').NextConfig} */ -const nextConfig = { - reactStrictMode: false, -}; +const nextConfig = {} -export default nextConfig; +export default nextConfig From ccaeb493e584e19e3becca657a02f3a50ba5385a Mon Sep 17 00:00:00 2001 From: Guilherme Rodz <gui.rodz.dev@gmail.com> Date: Sun, 18 Feb 2024 19:40:38 -0300 Subject: [PATCH 5/8] test: remove unused `with-allow-spaces` route --- test/src/app/with-allow-spaces/page.tsx | 14 -------------- 1 file changed, 14 deletions(-) delete mode 100644 test/src/app/with-allow-spaces/page.tsx diff --git a/test/src/app/with-allow-spaces/page.tsx b/test/src/app/with-allow-spaces/page.tsx deleted file mode 100644 index 9045227..0000000 --- a/test/src/app/with-allow-spaces/page.tsx +++ /dev/null @@ -1,14 +0,0 @@ -'use client' - -import * as React from 'react' - -import { BaseOTPInput } from '@/components/base-input' - -export default function Page() { - return ( - <div className="container relative flex-1 flex flex-col justify-center items-center"> - <BaseOTPInput data-testid="otp-input-wrapper-1" allowSpaces={false} /> - <BaseOTPInput data-testid="otp-input-wrapper-2" allowSpaces={true} /> - </div> - ) -} From e15cc7fe64d8e5730cbcb05cd62e562eb84673e7 Mon Sep 17 00:00:00 2001 From: Guilherme Rodz <gui.rodz.dev@gmail.com> Date: Sun, 18 Feb 2024 19:45:05 -0300 Subject: [PATCH 6/8] chore(otp-input): always select last char on focus --- src/input.tsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/input.tsx b/src/input.tsx index a87d3bf..e56292c 100644 --- a/src/input.tsx +++ b/src/input.tsx @@ -248,12 +248,10 @@ export const OTPInput = React.forwardRef<HTMLInputElement, OTPInputProps>( props.onKeyUp?.(e) }} onFocus={e => { - if (!allowNavigation) { - inputRef.current?.setSelectionRange( - Math.min(inputRef.current.value.length, maxLength - 1), - inputRef.current.value.length, - ) - } + inputRef.current?.setSelectionRange( + Math.min(inputRef.current.value.length, maxLength - 1), + inputRef.current.value.length, + ) setIsFocused(true) props.onFocus?.(e) From 469c9455e52853ea64c5fc7da5b04a7b1b68e2ab Mon Sep 17 00:00:00 2001 From: Guilherme Rodz <gui.rodz.dev@gmail.com> Date: Sun, 18 Feb 2024 21:52:28 -0300 Subject: [PATCH 7/8] test(skip): do not skip in CI --- test/src/tests/base.delete-word.spec.ts | 2 +- test/src/tests/base.selections.spec.ts | 8 ++++---- test/src/tests/with-allow-navigation.spec.ts | 8 ++++---- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/test/src/tests/base.delete-word.spec.ts b/test/src/tests/base.delete-word.spec.ts index 31d91da..4b3983a 100644 --- a/test/src/tests/base.delete-word.spec.ts +++ b/test/src/tests/base.delete-word.spec.ts @@ -6,7 +6,7 @@ test.beforeEach(async ({ page }) => { }) test.describe('Delete words', () => { - test.skip(process.env.CI === 'true', 'Breaks in CI as it cannot handle Arrow or Shift keys') + // test.skip(process.env.CI === 'true', 'Breaks in CI as it cannot handle Arrow or Shift keys') test('should backspace previous word (even if there is not a selected character)', async ({ page }) => { const input = page.getByTestId('otp-input-wrapper').getByRole('textbox') diff --git a/test/src/tests/base.selections.spec.ts b/test/src/tests/base.selections.spec.ts index e3ff020..eb90590 100644 --- a/test/src/tests/base.selections.spec.ts +++ b/test/src/tests/base.selections.spec.ts @@ -5,10 +5,10 @@ test.beforeEach(async ({ page }) => { }) test.describe('Base tests - Selections', () => { - test.skip( - process.env.CI === 'true', - 'Breaks in CI as it cannot handle Arrow or Shift keys', - ) + // test.skip( + // process.env.CI === 'true', + // 'Breaks in CI as it cannot handle Arrow or Shift keys', + // ) test('should replace selected char if another is pressed', async ({ page, diff --git a/test/src/tests/with-allow-navigation.spec.ts b/test/src/tests/with-allow-navigation.spec.ts index 21f830e..fcd6ba7 100644 --- a/test/src/tests/with-allow-navigation.spec.ts +++ b/test/src/tests/with-allow-navigation.spec.ts @@ -23,10 +23,10 @@ async function copyAndGetClipboardContent(params: { } test.describe('With allow navigation tests', () => { - test.skip( - process.env.CI === 'true', - 'Breaks in CI as it cannot handle Arrow or Shift keys', - ) + // test.skip( + // process.env.CI === 'true', + // 'Breaks in CI as it cannot handle Arrow or Shift keys', + // ) test('should allow navigation to the sides (arrows only)', async ({ page, From bac6e92a6e04879f86bb8565180074fef0c3d991 Mon Sep 17 00:00:00 2001 From: Guilherme Rodz <gui.rodz.dev@gmail.com> Date: Sun, 18 Feb 2024 21:59:58 -0300 Subject: [PATCH 8/8] test(skip): skip only `base.selections` test --- test/src/tests/base.delete-word.spec.ts | 2 -- test/src/tests/base.selections.spec.ts | 8 ++++---- test/src/tests/with-allow-navigation.spec.ts | 5 ----- 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/test/src/tests/base.delete-word.spec.ts b/test/src/tests/base.delete-word.spec.ts index 4b3983a..7d1fa9b 100644 --- a/test/src/tests/base.delete-word.spec.ts +++ b/test/src/tests/base.delete-word.spec.ts @@ -6,8 +6,6 @@ test.beforeEach(async ({ page }) => { }) test.describe('Delete words', () => { - // test.skip(process.env.CI === 'true', 'Breaks in CI as it cannot handle Arrow or Shift keys') - test('should backspace previous word (even if there is not a selected character)', async ({ page }) => { const input = page.getByTestId('otp-input-wrapper').getByRole('textbox') diff --git a/test/src/tests/base.selections.spec.ts b/test/src/tests/base.selections.spec.ts index eb90590..f933077 100644 --- a/test/src/tests/base.selections.spec.ts +++ b/test/src/tests/base.selections.spec.ts @@ -5,10 +5,10 @@ test.beforeEach(async ({ page }) => { }) test.describe('Base tests - Selections', () => { - // test.skip( - // process.env.CI === 'true', - // 'Breaks in CI as it cannot handle Arrow or Shift keys', - // ) + test.skip( + process.env.CI === 'true', + 'Breaks in CI as it cannot handle Shift key', + ) test('should replace selected char if another is pressed', async ({ page, diff --git a/test/src/tests/with-allow-navigation.spec.ts b/test/src/tests/with-allow-navigation.spec.ts index fcd6ba7..38b25f9 100644 --- a/test/src/tests/with-allow-navigation.spec.ts +++ b/test/src/tests/with-allow-navigation.spec.ts @@ -23,11 +23,6 @@ async function copyAndGetClipboardContent(params: { } test.describe('With allow navigation tests', () => { - // test.skip( - // process.env.CI === 'true', - // 'Breaks in CI as it cannot handle Arrow or Shift keys', - // ) - test('should allow navigation to the sides (arrows only)', async ({ page, context,