@rantomah/react-safe-password
Version:
A **secure, standalone and fully customizable React password input** component designed for modern React applications. It works across all browsers without relying on browser password management features, ensuring your users' passwords are never auto-save
159 lines (158 loc) • 8.09 kB
JavaScript
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import classNames from 'classnames';
import { forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState } from 'react';
const EyeIcon = (_jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", children: _jsx("path", { d: "M12 5C7 5 2.73 8.11 1 12c1.73 3.89 6 7 11 7s9.27-3.11 11-7c-1.73-3.89-6-7-11-7zm0 12a5 5 0 110-10 5 5 0 010 10z", fill: "currentColor" }) }));
const EyeSlashIcon = (_jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", children: _jsx("path", { d: "M2 2l20 20M10.58 5.08A9.77 9.77 0 0112 5c5 0 9.27 3.11 11 7a14.94 14.94 0 01-4.22 5.06M4.22 6.18A14.94 14.94 0 001 12c1.73 3.89 6 7 11 7a9.86 9.86 0 004.58-1.08", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round" }) }));
const SafePassword = forwardRef(({ id, name, value, onChange, placeholder, required = false, disabled = false, isError = false, inputClassName, errorClassName, containerClassName, togglerContainerClassName, containerStyle, inputStyle, togglerContainerStyle, showToggler = true, togglerRightOffset = '1rem', paddingRightOffset = '1.5rem', hideTitle = 'Hide', showTitle = 'Show', iconShow = EyeIcon, iconHide = EyeSlashIcon, onReset, errorId, }, ref) => {
const HIDER_CHAR = '\u2022';
const inputRef = useRef(null);
// states that affect rendering
const [visible, setVisible] = useState(false);
const [rawValue, setRawValue] = useState(value || '');
// selection stored in a ref to avoid re-renders when selection changes
const selectionRef = useRef(null);
// masked or raw displayed value
const syncedValue = useMemo(() => (visible ? rawValue : HIDER_CHAR.repeat(rawValue.length)), [visible, rawValue]);
// triggerChange: stable callback, only depends on onChange
const triggerChange = useCallback((v) => {
setRawValue(v);
onChange?.(v);
}, [onChange]);
// set cursor position helper (no re-render)
const setCursor = useCallback((pos) => {
const input = inputRef.current;
if (!input)
return;
try {
input.setSelectionRange(pos, pos);
}
catch {
// ignore invalid ranges silently (preserve original resilience)
}
}, []);
// main onChange handler
const handleChange = useCallback(() => {
const input = inputRef.current;
if (!input)
return;
// early returns (same behavior)
if (visible || input.value === '') {
triggerChange(input.value);
return;
}
const newValue = input.value;
const oldValue = rawValue;
const diff = newValue.length - oldValue.length;
const selectionStart = input.selectionStart;
if (selectionStart === null)
return;
let updated;
const handleInsertion = () => {
const lastChar = newValue[selectionStart - 1];
return oldValue.slice(0, selectionStart - 1) + lastChar + oldValue.slice(selectionStart - 1);
};
const handleDeletionReplacement = () => {
const sel = selectionRef.current;
if (sel && sel.start === 0 && sel.end === oldValue.length)
return newValue;
const selStart = sel?.start ?? selectionStart;
const selEnd = sel?.end ?? selectionStart;
if (Math.abs(diff) === selEnd - selStart) {
return oldValue.slice(0, selectionStart) + oldValue.slice(selectionStart - diff);
}
return oldValue.slice(0, selStart) + newValue.charAt(selStart) + oldValue.slice(selEnd);
};
if (diff > 0) {
updated = handleInsertion();
}
else {
updated = handleDeletionReplacement();
}
triggerChange(updated);
// keep cursor at same position (preserve queueMicrotask)
queueMicrotask(() => {
setCursor(selectionStart);
});
}, [rawValue, triggerChange, visible, setCursor]);
// onSelect: preserve behavior (including preventDefault), but store selection in ref to avoid re-render
const handleSelect = useCallback((e) => {
e.preventDefault();
const input = inputRef.current;
if (!input)
return;
const direction = input.selectionDirection;
const start = input.selectionStart;
const end = input.selectionEnd;
if (start !== null && end !== null && end > start && direction && direction !== 'none') {
selectionRef.current = { start, end, direction };
return;
}
selectionRef.current = null;
}, []);
// replace selection with pasted text
const handlePaste = useCallback((e) => {
e.preventDefault();
const input = inputRef.current;
if (!input)
return;
const pastedText = e.clipboardData.getData('text');
const start = input.selectionStart ?? rawValue.length;
const end = input.selectionEnd ?? rawValue.length;
const updated = rawValue.slice(0, start) + pastedText + rawValue.slice(end);
triggerChange(updated);
queueMicrotask(() => {
setCursor(start + pastedText.length);
});
}, [rawValue, triggerChange, setCursor]);
// prevent copy/cut default behavior handlers
const handleCopy = useCallback((e) => {
e.preventDefault();
}, []);
const handleCut = useCallback((e) => {
e.preventDefault();
}, []);
const toggleShow = useCallback(() => setVisible((p) => !p), []);
const reset = useCallback(() => {
if (onReset) {
onReset();
return;
}
setVisible(false);
triggerChange('');
}, [onReset, triggerChange]);
useImperativeHandle(ref, () => ({ reset }), [reset]);
const mergedInputStyle = useMemo(() => ({
paddingRight: showToggler ? `calc(${togglerRightOffset} + ${paddingRightOffset})` : undefined,
...inputStyle,
}), [inputStyle, showToggler, togglerRightOffset, paddingRightOffset]);
const mergedContainerStyle = useMemo(() => ({
position: 'relative',
backgroundColor: 'transparent',
border: 'none',
outline: 'none',
margin: '0',
padding: '0',
...containerStyle,
}), [containerStyle]);
const mergedTogglerStyle = useMemo(() => ({
position: 'absolute',
backgroundColor: 'transparent',
border: 'none',
margin: 0,
padding: 0,
top: '50%',
transform: 'translateY(-50%)',
cursor: 'pointer',
userSelect: 'none',
zIndex: 10,
right: togglerRightOffset,
...togglerContainerStyle,
}), [togglerContainerStyle, togglerRightOffset]);
return (_jsxs("div", { className: classNames('react-safe-password-container', containerClassName), style: mergedContainerStyle, children: [_jsx("input", { type: "text", autoComplete: "off", spellCheck: false, onCopy: handleCopy, onCut: handleCut, ref: inputRef, id: id, name: name, value: syncedValue, onChange: handleChange, onPaste: handlePaste, onSelect: handleSelect, placeholder: placeholder, required: required, disabled: disabled, className: classNames('react-safe-password-input', inputClassName, isError && errorClassName), style: mergedInputStyle, "aria-invalid": isError, "aria-describedby": isError && errorId ? errorId : undefined }), showToggler && !!rawValue && (_jsx("div", { className: classNames('react-safe-password-toggler-container', togglerContainerClassName), style: mergedTogglerStyle, onClick: toggleShow, onKeyDown: (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleShow();
}
}, role: "button", tabIndex: 0, "aria-label": visible ? hideTitle : showTitle, "aria-pressed": visible, "aria-live": "polite", children: visible ? iconHide : iconShow }))] }));
});
export default SafePassword;