{"version":3,"sources":["../src/input.tsx","../src/regexp.ts","../src/sync-timeouts.ts","../src/use-previous.ts","../src/use-pwm-badge.tsx"],"sourcesContent":["'use client'\n\nimport * as React from 'react'\n\nimport { REGEXP_ONLY_DIGITS } from './regexp'\nimport { syncTimeouts } from './sync-timeouts'\nimport { OTPInputProps, RenderProps } from './types'\nimport { usePrevious } from './use-previous'\nimport { usePasswordManagerBadge } from './use-pwm-badge'\n\nexport const OTPInputContext = React.createContext(\n {} as RenderProps,\n)\n\nexport const OTPInput = React.forwardRef(\n (\n {\n value: uncheckedValue,\n onChange: uncheckedOnChange,\n maxLength,\n textAlign = 'left',\n pattern = REGEXP_ONLY_DIGITS,\n inputMode = 'numeric',\n onComplete,\n pushPasswordManagerStrategy = 'increase-width',\n containerClassName,\n noScriptCSSFallback = NOSCRIPT_CSS_FALLBACK,\n\n render,\n children,\n\n ...props\n },\n ref,\n ) => {\n // Only used when `value` state is not provided\n const [internalValue, setInternalValue] = React.useState(\n typeof props.defaultValue === 'string' ? props.defaultValue : '',\n )\n\n // Definitions\n const value = uncheckedValue ?? internalValue\n const previousValue = usePrevious(value)\n const onChange = React.useCallback(\n (newValue: string) => {\n uncheckedOnChange?.(newValue)\n setInternalValue(newValue)\n },\n [uncheckedOnChange],\n )\n const regexp = React.useMemo(\n () =>\n pattern\n ? typeof pattern === 'string'\n ? new RegExp(pattern)\n : pattern\n : null,\n [pattern],\n )\n\n /** useRef */\n const inputRef = React.useRef(null)\n const containerRef = React.useRef(null)\n const initialLoadRef = React.useRef({\n value,\n onChange,\n isIOS:\n typeof window !== 'undefined' &&\n window?.CSS?.supports?.('-webkit-touch-callout', 'none'),\n })\n const inputMetadataRef = React.useRef<{\n prev: [number | null, number | null, 'none' | 'forward' | 'backward']\n }>({\n prev: [\n inputRef.current?.selectionStart,\n inputRef.current?.selectionEnd,\n inputRef.current?.selectionDirection,\n ],\n })\n React.useImperativeHandle(ref, () => inputRef.current, [])\n React.useEffect(() => {\n const input = inputRef.current\n const container = containerRef.current\n\n if (!input || !container) {\n return\n }\n\n // Sync input value\n if (initialLoadRef.current.value !== input.value) {\n initialLoadRef.current.onChange(input.value)\n }\n\n // Previous selection\n inputMetadataRef.current.prev = [\n input.selectionStart,\n input.selectionEnd,\n input.selectionDirection,\n ]\n function onDocumentSelectionChange() {\n if (document.activeElement !== input) {\n setMirrorSelectionStart(null)\n setMirrorSelectionEnd(null)\n return\n }\n\n // Aliases\n const _s = input.selectionStart\n const _e = input.selectionEnd\n const _dir = input.selectionDirection\n const _ml = input.maxLength\n const _val = input.value\n const _prev = inputMetadataRef.current.prev\n\n // Algorithm\n let start = -1\n let end = -1\n let direction: 'forward' | 'backward' | 'none' = undefined\n if (_val.length !== 0 && _s !== null && _e !== null) {\n const isSingleCaret = _s === _e\n const isInsertMode = _s === _val.length && _val.length < _ml\n\n if (isSingleCaret && !isInsertMode) {\n const c = _s\n if (c === 0) {\n start = 0\n end = 1\n direction = 'forward'\n } else if (c === _ml) {\n start = c - 1\n end = c\n direction = 'backward'\n } else if (_ml > 1 && _val.length > 1) {\n let offset = 0\n if (_prev[0] !== null && _prev[1] !== null) {\n direction = c < _prev[1] ? 'backward' : 'forward'\n const wasPreviouslyInserting =\n _prev[0] === _prev[1] && _prev[0] < _ml\n if (direction === 'backward' && !wasPreviouslyInserting) {\n offset = -1\n }\n }\n\n start = offset + c\n end = offset + c + 1\n }\n }\n\n if (start !== -1 && end !== -1 && start !== end) {\n inputRef.current.setSelectionRange(start, end, direction)\n }\n }\n\n // Finally, update the state\n const s = start !== -1 ? start : _s\n const e = end !== -1 ? end : _e\n const dir = direction ?? _dir\n setMirrorSelectionStart(s)\n setMirrorSelectionEnd(e)\n // Store the previous selection value\n inputMetadataRef.current.prev = [s, e, dir]\n }\n document.addEventListener('selectionchange', onDocumentSelectionChange, {\n capture: true,\n })\n\n // Set initial mirror state\n onDocumentSelectionChange()\n document.activeElement === input && setIsFocused(true)\n\n // Apply needed styles\n if (!document.getElementById('input-otp-style')) {\n const styleEl = document.createElement('style')\n styleEl.id = 'input-otp-style'\n document.head.appendChild(styleEl)\n\n if (styleEl.sheet) {\n const autofillStyles =\n 'background: transparent !important; color: transparent !important; border-color: transparent !important; opacity: 0 !important; box-shadow: none !important; -webkit-box-shadow: none !important; -webkit-text-fill-color: transparent !important;'\n\n safeInsertRule(\n styleEl.sheet,\n '[data-input-otp]::selection { background: transparent !important; color: transparent !important; }',\n )\n safeInsertRule(\n styleEl.sheet,\n `[data-input-otp]:autofill { ${autofillStyles} }`,\n )\n safeInsertRule(\n styleEl.sheet,\n `[data-input-otp]:-webkit-autofill { ${autofillStyles} }`,\n )\n // iOS\n safeInsertRule(\n styleEl.sheet,\n `@supports (-webkit-touch-callout: none) { [data-input-otp] { letter-spacing: -.6em !important; font-weight: 100 !important; font-stretch: ultra-condensed; font-optical-sizing: none !important; left: -1px !important; right: 1px !important; } }`,\n )\n // PWM badges\n safeInsertRule(\n styleEl.sheet,\n `[data-input-otp] + * { pointer-events: all !important; }`,\n )\n }\n }\n // Track root height\n const updateRootHeight = () => {\n if (container) {\n container.style.setProperty(\n '--root-height',\n `${input.clientHeight}px`,\n )\n }\n }\n updateRootHeight()\n const resizeObserver = new ResizeObserver(updateRootHeight)\n resizeObserver.observe(input)\n\n return () => {\n document.removeEventListener(\n 'selectionchange',\n onDocumentSelectionChange,\n { capture: true },\n )\n resizeObserver.disconnect()\n }\n }, [])\n\n /** Mirrors for UI rendering purpose only */\n const [isHoveringInput, setIsHoveringInput] = React.useState(false)\n const [isFocused, setIsFocused] = React.useState(false)\n const [mirrorSelectionStart, setMirrorSelectionStart] = React.useState<\n number | null\n >(null)\n const [mirrorSelectionEnd, setMirrorSelectionEnd] = React.useState<\n number | null\n >(null)\n\n /** Effects */\n React.useEffect(() => {\n syncTimeouts(() => {\n // Forcefully remove :autofill state\n inputRef.current?.dispatchEvent(new Event('input'))\n\n // Update the selection state\n const s = inputRef.current?.selectionStart\n const e = inputRef.current?.selectionEnd\n const dir = inputRef.current?.selectionDirection\n if (s !== null && e !== null) {\n setMirrorSelectionStart(s)\n setMirrorSelectionEnd(e)\n inputMetadataRef.current.prev = [s, e, dir]\n }\n })\n }, [value, isFocused])\n\n React.useEffect(() => {\n if (previousValue === undefined) {\n return\n }\n\n if (\n value !== previousValue &&\n previousValue.length < maxLength &&\n value.length === maxLength\n ) {\n onComplete?.(value)\n }\n }, [maxLength, onComplete, previousValue, value])\n\n const pwmb = usePasswordManagerBadge({\n containerRef,\n inputRef,\n pushPasswordManagerStrategy,\n isFocused,\n })\n\n /** Event handlers */\n const _changeListener = React.useCallback(\n (e: React.ChangeEvent) => {\n const newValue = e.currentTarget.value.slice(0, maxLength)\n if (newValue.length > 0 && regexp && !regexp.test(newValue)) {\n e.preventDefault()\n return\n }\n const maybeHasDeleted =\n typeof previousValue === 'string' &&\n newValue.length < previousValue.length\n if (maybeHasDeleted) {\n // Since cutting/deleting text doesn't trigger\n // selectionchange event, we'll have to dispatch it manually.\n // NOTE: The following line also triggers when cmd+A then pasting\n // a value with smaller length, which is not ideal for performance.\n document.dispatchEvent(new Event('selectionchange'))\n }\n onChange(newValue)\n },\n [maxLength, onChange, previousValue, regexp],\n )\n const _focusListener = React.useCallback(() => {\n if (inputRef.current) {\n const start = Math.min(inputRef.current.value.length, maxLength - 1)\n const end = inputRef.current.value.length\n inputRef.current?.setSelectionRange(start, end)\n setMirrorSelectionStart(start)\n setMirrorSelectionEnd(end)\n }\n setIsFocused(true)\n }, [maxLength])\n // Fix iOS pasting\n const _pasteListener = React.useCallback(\n (e: React.ClipboardEvent) => {\n const input = inputRef.current\n if (!initialLoadRef.current.isIOS || !e.clipboardData || !input) {\n return\n }\n\n const content = e.clipboardData.getData('text/plain')\n e.preventDefault()\n\n const start = inputRef.current?.selectionStart\n const end = inputRef.current?.selectionEnd\n\n const isReplacing = start !== end\n\n const newValueUncapped = isReplacing\n ? value.slice(0, start) + content + value.slice(end) // Replacing\n : value.slice(0, start) + content + value.slice(start) // Inserting\n const newValue = newValueUncapped.slice(0, maxLength)\n\n if (newValue.length > 0 && regexp && !regexp.test(newValue)) {\n return\n }\n\n input.value = newValue\n onChange(newValue)\n\n const _start = Math.min(newValue.length, maxLength - 1)\n const _end = newValue.length\n\n input.setSelectionRange(_start, _end)\n setMirrorSelectionStart(_start)\n setMirrorSelectionEnd(_end)\n },\n [maxLength, onChange, regexp, value],\n )\n\n /** Styles */\n const rootStyle = React.useMemo(\n () => ({\n position: 'relative',\n cursor: props.disabled ? 'default' : 'text',\n userSelect: 'none',\n WebkitUserSelect: 'none',\n pointerEvents: 'none',\n }),\n [props.disabled],\n )\n\n const inputStyle = React.useMemo(\n () => ({\n position: 'absolute',\n inset: 0,\n width: pwmb.willPushPWMBadge\n ? `calc(100% + ${pwmb.PWM_BADGE_SPACE_WIDTH})`\n : '100%',\n clipPath: pwmb.willPushPWMBadge\n ? `inset(0 ${pwmb.PWM_BADGE_SPACE_WIDTH} 0 0)`\n : undefined,\n height: '100%',\n display: 'flex',\n textAlign,\n opacity: '1', // Mandatory for iOS hold-paste\n color: 'transparent',\n pointerEvents: 'all',\n background: 'transparent',\n caretColor: 'transparent',\n border: '0 solid transparent',\n outline: '0 solid transparent',\n boxShadow: 'none',\n lineHeight: '1',\n letterSpacing: '-.5em',\n fontSize: 'var(--root-height)',\n fontFamily: 'monospace',\n fontVariantNumeric: 'tabular-nums',\n // letterSpacing: '-1em',\n // transform: 'scale(1.5)',\n // paddingRight: '100%',\n // paddingBottom: '100%',\n // debugging purposes\n // inset: undefined,\n // position: undefined,\n // color: 'black',\n // background: 'white',\n // opacity: '1',\n // caretColor: 'black',\n // padding: '0',\n // letterSpacing: 'unset',\n // fontSize: 'unset',\n // paddingInline: '.5rem',\n }),\n [pwmb.PWM_BADGE_SPACE_WIDTH, pwmb.willPushPWMBadge, textAlign],\n )\n\n /** Rendering */\n const renderedInput = React.useMemo(\n () => (\n {\n _pasteListener(e)\n props.onPaste?.(e)\n }}\n onChange={_changeListener}\n onMouseOver={e => {\n setIsHoveringInput(true)\n props.onMouseOver?.(e)\n }}\n onMouseLeave={e => {\n setIsHoveringInput(false)\n props.onMouseLeave?.(e)\n }}\n onFocus={e => {\n _focusListener()\n props.onFocus?.(e)\n }}\n onBlur={e => {\n setIsFocused(false)\n props.onBlur?.(e)\n }}\n />\n ),\n [\n _changeListener,\n _focusListener,\n _pasteListener,\n inputMode,\n inputStyle,\n maxLength,\n mirrorSelectionEnd,\n mirrorSelectionStart,\n props,\n regexp?.source,\n value,\n ],\n )\n\n const contextValue = React.useMemo(() => {\n return {\n slots: Array.from({ length: maxLength }).map((_, slotIdx) => {\n const isActive =\n isFocused &&\n mirrorSelectionStart !== null &&\n mirrorSelectionEnd !== null &&\n ((mirrorSelectionStart === mirrorSelectionEnd &&\n slotIdx === mirrorSelectionStart) ||\n (slotIdx >= mirrorSelectionStart && slotIdx < mirrorSelectionEnd))\n\n const char = value[slotIdx] !== undefined ? value[slotIdx] : null\n\n return {\n char,\n isActive,\n hasFakeCaret: isActive && char === null,\n }\n }),\n isFocused,\n isHovering: !props.disabled && isHoveringInput,\n }\n }, [\n isFocused,\n isHoveringInput,\n maxLength,\n mirrorSelectionEnd,\n mirrorSelectionStart,\n props.disabled,\n value,\n ])\n\n const renderedChildren = React.useMemo(() => {\n if (render) {\n return render(contextValue)\n }\n return (\n \n {children}\n \n )\n }, [children, contextValue, render])\n\n return (\n <>\n {noScriptCSSFallback !== null && (\n \n )}\n\n \n {renderedChildren}\n\n \n {renderedInput}\n \n \n \n )\n },\n)\nOTPInput.displayName = 'Input'\n\nfunction safeInsertRule(sheet: CSSStyleSheet, rule: string) {\n try {\n sheet.insertRule(rule)\n } catch {\n console.error('input-otp could not insert CSS rule:', rule)\n }\n}\n\n// Decided to go with