@pagopa/mui-italia
Version:
[Material-UI](https://mui.com/core/) theme inspired by [Bootstrap Italia](https://italia.github.io/bootstrap-italia/).
284 lines (283 loc) • 13.7 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const jsx_runtime_1 = require("react/jsx-runtime");
const material_1 = require("@mui/material");
const react_1 = require("react");
const colors_1 = require("./../../theme/foundations/colors");
/**
* Layout constants used to size the code input component.
* All values are expressed in theme spacing units.
* These control the size and spacing of character boxes,
* as well as the padding and border radius of the container.
*/
const charBoxWidth = 2; // 2 * 8px = 16px
const charBoxSpacing = 2;
const codeBoxPaddingX = 3;
const codeBoxPaddingTop = 1.5;
const codeBoxPaddingBottom = 2;
const codeBoxBorderRadius = 1;
const codeBoxErrorBorder = 0.25;
const blink = (0, material_1.keyframes) `
0% { opacity: 0; }
50% { opacity: 1; }
100% { opacity: 0; }
`;
const Caret = (0, material_1.styled)('div')(({ position }) => ({
position: 'absolute',
top: '50%',
transform: 'translateY(-50%)',
width: '2px',
borderRadius: '2px',
height: '1em',
backgroundColor: colors_1.colors.blue[500],
animation: `${blink} 1s step-start infinite`,
transformOrigin: 'center',
left: position === 'center' ? '50%' : position === 'end' ? 'calc(100% + 1px)' : '-1px',
}));
const OverlayInput = (0, material_1.styled)('input')(({ theme }) => ({
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
boxSizing: 'border-box',
background: 'transparent',
border: 0,
outline: 'none',
// metrics tuned to align native selection with virtual boxes
fontFamily: 'monospace',
fontSize: '1em',
letterSpacing: 'calc(var(--char-box-w) + var(--char-gap) - 1ch)',
paddingLeft: 'calc(var(--pad-x) - (var(--char-gap) / 2))',
paddingRight: 'calc(var(--pad-x) - (var(--char-gap) / 2))',
paddingTop: theme.spacing(codeBoxPaddingTop),
paddingBottom: theme.spacing(codeBoxPaddingBottom),
lineHeight: '1.5em',
// hide text and native caret
color: 'transparent',
caretColor: 'transparent',
zIndex: 1,
// highlight selection (no text, background only)
'::selection': {
background: (0, material_1.alpha)(theme.palette.primary.main, 0.35),
color: 'transparent',
},
'::-moz-selection': {
background: (0, material_1.alpha)(theme.palette.primary.main, 0.35),
color: 'transparent',
},
}));
const CodeBox = (0, material_1.styled)(material_1.Box, {
shouldForwardProp: (prop) => prop !== 'error',
})(({ theme, error }) => ({
position: 'relative',
display: 'inline-block',
cursor: 'text',
padding: `${theme.spacing(codeBoxPaddingTop)} ${theme.spacing(codeBoxPaddingX)} ${theme.spacing(codeBoxPaddingBottom)}`,
border: `${theme.spacing(error ? codeBoxErrorBorder : 0.125)} solid ${error ? colors_1.colors.error[600] : colors_1.colors.neutral.grey[650]}`,
borderRadius: theme.spacing(codeBoxBorderRadius),
// Shared layout variables for OverlayInput and CharBox.
// Define box width, gap, and horizontal padding to keep
// visual boxes and native selection perfectly aligned.
'--char-box-w': theme.spacing(charBoxWidth),
'--char-gap': theme.spacing(charBoxSpacing),
'--pad-x': theme.spacing(codeBoxPaddingX),
}));
const CharBox = (0, material_1.styled)(material_1.Box)(({ theme }) => ({
width: theme.spacing(charBoxWidth),
height: '1.5em',
lineHeight: '1.5em',
paddingBottom: theme.spacing(0.25),
marginBottom: theme.spacing(0.5),
borderBottom: `1px solid ${colors_1.colors.neutral.grey[700]}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
}));
const HelperText = (0, material_1.styled)(material_1.Typography, {
shouldForwardProp: (prop) => prop !== 'error',
})(({ error, theme }) => ({
marginTop: theme.spacing(1),
fontSize: '14px',
lineHeight: '1em',
alignSelf: 'stretch',
width: 'auto',
maxWidth: '100%',
wordBreak: 'break-word',
boxSizing: 'content-box',
paddingLeft: theme.spacing(3),
paddingRight: theme.spacing(3),
color: error ? colors_1.colors.error[600] : theme.palette.text.primary,
}));
/**
* CodeInput – React component for entering OTP or PIN codes.
*
* Displays a sequence of visually separated character boxes, simulating individual inputs,
* with a hidden `input` field handling the real input logic.
* It supports both controlled and uncontrolled usage.
*
* @component
* @example
* ```tsx
* <CodeInput
* value={code}
* onChange={(val, status) => setCode(val)}
* length={6}
* error={hasError}
* helperText="Enter the code"
* encrypted
* />
* ```
*/
const CodeInput = ({ length, onChange, inputMode = 'text', label, value, readOnly = false, id: idProp, name, encrypted = false, error = false, helperText, ariaLabel, ariaLabelledby, ariaDescribedby, }) => {
const theme = (0, material_1.useTheme)();
const generatedId = (0, react_1.useId)();
const id = idProp !== null && idProp !== void 0 ? idProp : generatedId;
const helperTextId = helperText ? `${id}-helper-text` : undefined;
const isControlled = value !== undefined;
const [internalValue, setInternalValue] = (0, react_1.useState)('');
const currentValue = isControlled ? value : internalValue;
const sanitizedValue = currentValue.slice(0, length);
const hiddenInputRef = (0, react_1.useRef)(null);
const [caretPosition, setCaretPosition] = (0, react_1.useState)(null);
const codeBoxContentWidth = length * charBoxWidth + (length - 1) * charBoxSpacing;
const containerWidth = theme.spacing(codeBoxContentWidth + 2 * codeBoxPaddingX + 2 * codeBoxErrorBorder);
const handleChange = (e) => {
// Fires immediately when the input’s value is changed by the user (for example, it fires on every keystroke)
const raw = e.target.value;
const filtered = raw.slice(0, length);
if (!isControlled) {
setInternalValue(filtered);
}
// we need to immediatly update the caret to avoid a flickering in the caret update
// so we can't do the update in the onSelect callback
syncCaretFromInput(filtered.length);
onChange === null || onChange === void 0 ? void 0 : onChange(filtered);
};
const updateCaretPosition = (pos, valueLen = sanitizedValue.length) => {
if (pos < 0 || pos > length) {
return;
}
if (valueLen === length && pos === length) {
setCaretPosition({ index: length - 1, position: 'end' });
}
else if (pos === valueLen) {
setCaretPosition({ index: valueLen, position: 'center' });
}
else {
setCaretPosition({ index: pos, position: 'start' });
}
};
const shouldShowCaret = () => {
const el = hiddenInputRef.current;
if (!el || readOnly || document.activeElement !== el) {
return false;
}
const start = el.selectionStart;
const end = el.selectionEnd;
return start === end;
};
/**
* Synchronizes the virtual caret position with the real input's selection.
* Reads the current cursor (selectionStart) from the native input element
* and updates the visual caret position accordingly.
*
* @param el - The native input element reference.
* @param valueLen - The current value length (defaults to `sanitizedValue.length`).
*/
const syncCaretFromInput = (valueLen = sanitizedValue.length) => {
var _a, _b;
const el = hiddenInputRef.current;
if (!el) {
setCaretPosition(null);
return;
}
if (readOnly || document.activeElement !== el) {
return;
}
const start = (_a = el.selectionStart) !== null && _a !== void 0 ? _a : valueLen;
const end = (_b = el.selectionEnd) !== null && _b !== void 0 ? _b : start;
// do not show the caret with an active selection
if (start !== end) {
setCaretPosition(null);
return;
}
updateCaretPosition(start, valueLen);
};
(0, react_1.useLayoutEffect)(() => {
// If readOnly becomes true we want to:
// - stop listening to `selectionchange`
// - reset the caret
if (readOnly) {
setCaretPosition(null);
return;
}
const input = hiddenInputRef.current;
// Re-sync the caret immediately. This allows iOS to behave correctly when
// `selectionchange` is not emitted on programmatic value updates
if (input && document.activeElement === input) {
syncCaretFromInput(sanitizedValue.length);
}
const onSelectionChange = () => {
if (document.activeElement === input) {
syncCaretFromInput(sanitizedValue.length);
}
};
/**
* We subscribe to the native `selectionchange` event at the document level
* to keep the virtual caret in sync with the browser's native selection.
*
* React doesn't provide a reliable equivalent event: built-in handlers like
* `onSelect` or `onChange` only capture a subset of user interactions and
* often fire too late to reflect the caret movement in real time.
*
* The `selectionchange` event fires whenever the browser updates the text
* selection or caret position, covering cases such as:
*
* - Arrow keys and `HOME`/`END` navigation
* - Mouse clicks and drag selections
* - Cursor move/selection on mobile devices
*
* We attach the listener to `document` rather than to the specific `input`
* because browser support for `selectionchange` on individual inputs is
* relatively recent and showed inconsistent behavior in our tests,
* especially on mobile.
*
* Maurizio Flauti, November 12th, 2025
*/
document.addEventListener('selectionchange', onSelectionChange);
return () => {
document.removeEventListener('selectionchange', onSelectionChange);
};
}, [readOnly, sanitizedValue]);
return ((0, jsx_runtime_1.jsxs)(material_1.Box, Object.assign({ sx: { display: 'inline-block', width: containerWidth } }, { children: [label && ((0, jsx_runtime_1.jsx)(material_1.FormLabel, Object.assign({ htmlFor: id, sx: { display: 'block', fontSize: 16, fontWeight: 600, mb: theme.spacing(2) } }, { children: label }))), (0, jsx_runtime_1.jsxs)(CodeBox, Object.assign({ error: error, sx: { cursor: readOnly ? 'default' : 'text' } }, { children: [(0, jsx_runtime_1.jsx)(OverlayInput, Object.assign({ id: id }, (name && { name }), { ref: hiddenInputRef, type: encrypted ? 'password' : 'text', inputMode: inputMode, autoComplete: "one-time-code",
/* Disable mobile keyboard features that can change caret/value on Android (Gboard)
This field is an OTP/PIN, not natural text; we want raw keystrokes without "smart" edits */
autoCorrect: "off", autoCapitalize: "off", spellCheck: false, value: sanitizedValue, maxLength: length, readOnly: readOnly, "aria-invalid": error || undefined }, (ariaLabel && { 'aria-label': ariaLabel }), (ariaLabelledby && { 'aria-labelledby': ariaLabelledby }), (helperTextId || ariaDescribedby
? {
'aria-describedby': [helperTextId, ariaDescribedby].filter(Boolean).join(' '),
}
: {}), { onFocus: () => {
// We use a 0ms timeout to ensure the browser has properly updated
// focus and selection before reading the caret position
setTimeout(() => {
syncCaretFromInput();
}, 0);
}, onBlur: () => {
setCaretPosition(null); // Avoid showing a stale caret when re-focusing after a reset
}, onChange: handleChange, sx: encrypted ? { fontSize: '1.5em' } : undefined })), (0, jsx_runtime_1.jsx)(material_1.Stack, Object.assign({ direction: "row", spacing: theme.spacing(charBoxSpacing), sx: {
fontSize: encrypted ? '1.5em' : '1em',
fontFamily: `'Titillium Web', sans-serif`,
fontWeight: 600,
color: theme.palette.text.primary,
position: 'relative',
zIndex: 0,
pointerEvents: 'none',
}, "aria-hidden": true }, { children: Array.from({ length }).map((_, i) => {
const char = sanitizedValue[i] || '';
const displayedChar = encrypted && char ? '•' : char;
const showCaret = shouldShowCaret() && (caretPosition === null || caretPosition === void 0 ? void 0 : caretPosition.index) === i;
return ((0, jsx_runtime_1.jsxs)(CharBox, { children: [displayedChar && (0, jsx_runtime_1.jsx)(material_1.Box, Object.assign({ component: "span" }, { children: displayedChar })), showCaret && (0, jsx_runtime_1.jsx)(Caret, { position: caretPosition.position })] }, i));
}) }))] })), helperText && ((0, jsx_runtime_1.jsx)(HelperText, Object.assign({ id: helperTextId, error: error }, { children: helperText })))] })));
};
exports.default = CodeInput;