diff --git a/docs/data/base/components/input/OTPInput.js b/docs/data/base/components/input/OTPInput.js new file mode 100644 index 00000000000000..4b3e5512147af8 --- /dev/null +++ b/docs/data/base/components/input/OTPInput.js @@ -0,0 +1,231 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { Input as BaseInput } from '@mui/base/Input'; +import { Box, styled } from '@mui/system'; + +function OTP({ separator, length, value, onChange }) { + const inputRefs = React.useRef(new Array(length).fill(null)); + + const focusInput = (targetIndex) => { + const targetInput = inputRefs.current[targetIndex]; + targetInput.focus(); + }; + + const selectInput = (targetIndex) => { + const targetInput = inputRefs.current[targetIndex]; + targetInput.select(); + }; + + const handleKeyDown = (event, currentIndex) => { + switch (event.key) { + case 'ArrowUp': + case 'ArrowDown': + case ' ': + event.preventDefault(); + break; + case 'ArrowLeft': + event.preventDefault(); + if (currentIndex > 0) { + focusInput(currentIndex - 1); + selectInput(currentIndex - 1); + } + break; + case 'ArrowRight': + event.preventDefault(); + if (currentIndex < length - 1) { + focusInput(currentIndex + 1); + selectInput(currentIndex + 1); + } + break; + case 'Delete': + event.preventDefault(); + onChange((prevOtp) => { + const otp = + prevOtp.slice(0, currentIndex) + prevOtp.slice(currentIndex + 1); + return otp; + }); + + break; + case 'Backspace': + event.preventDefault(); + if (currentIndex > 0) { + focusInput(currentIndex - 1); + selectInput(currentIndex - 1); + } + + onChange((prevOtp) => { + const otp = + prevOtp.slice(0, currentIndex) + prevOtp.slice(currentIndex + 1); + return otp; + }); + break; + + default: + break; + } + }; + + const handleChange = (event, currentIndex) => { + const currentValue = event.target.value; + let indexToEnter = 0; + + while (indexToEnter <= currentIndex) { + if (inputRefs.current[indexToEnter].value && indexToEnter < currentIndex) { + indexToEnter += 1; + } else { + break; + } + } + onChange((prev) => { + const otpArray = prev.split(''); + const lastValue = currentValue[currentValue.length - 1]; + otpArray[indexToEnter] = lastValue; + return otpArray.join(''); + }); + if (currentValue !== '') { + if (currentIndex < length - 1) { + focusInput(currentIndex + 1); + } + } + }; + + const handleClick = (event, currentIndex) => { + selectInput(currentIndex); + }; + + const handlePaste = (event, currentIndex) => { + event.preventDefault(); + const clipboardData = event.clipboardData; + + // Check if there is text data in the clipboard + if (clipboardData.types.includes('text/plain')) { + let pastedText = clipboardData.getData('text/plain'); + pastedText = pastedText.substring(0, length).trim(); + let indexToEnter = 0; + + while (indexToEnter <= currentIndex) { + if (inputRefs.current[indexToEnter].value && indexToEnter < currentIndex) { + indexToEnter += 1; + } else { + break; + } + } + + const otpArray = value.split(''); + + for (let i = indexToEnter; i < length; i += 1) { + const lastValue = pastedText[i - indexToEnter] ?? ' '; + otpArray[i] = lastValue; + } + + onChange(otpArray.join('')); + } + }; + + return ( + + {new Array(length).fill(null).map((_, index) => ( + + { + inputRefs.current[index] = ele; + }, + onKeyDown: (event) => handleKeyDown(event, index), + onChange: (event) => handleChange(event, index), + onClick: (event) => handleClick(event, index), + onPaste: (event) => handlePaste(event, index), + value: value[index] ?? '', + }, + }} + /> + {index === length - 1 ? null : separator} + + ))} + + ); +} + +OTP.propTypes = { + length: PropTypes.number.isRequired, + onChange: PropTypes.func.isRequired, + separator: PropTypes.node, + value: PropTypes.string.isRequired, +}; + +export default function OTPInput() { + const [otp, setOtp] = React.useState(''); + + return ( + + -} value={otp} onChange={setOtp} length={5} /> + Entered value: {otp} + + ); +} + +const blue = { + 100: '#DAECFF', + 200: '#80BFFF', + 400: '#3399FF', + 500: '#007FFF', + 600: '#0072E5', + 700: '#0059B2', +}; + +const grey = { + 50: '#F3F6F9', + 100: '#E5EAF2', + 200: '#DAE2ED', + 300: '#C7D0DD', + 400: '#B0B8C4', + 500: '#9DA8B7', + 600: '#6B7A90', + 700: '#434D5B', + 800: '#303740', + 900: '#1C2025', +}; + +const InputElement = styled('input')( + ({ theme }) => ` + width: 40px; + font-family: 'IBM Plex Sans', sans-serif; + font-size: 0.875rem; + font-weight: 400; + line-height: 1.5; + padding: 8px 0px; + border-radius: 8px; + text-align: center; + color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]}; + background: ${theme.palette.mode === 'dark' ? grey[900] : '#fff'}; + border: 1px solid ${theme.palette.mode === 'dark' ? grey[700] : grey[200]}; + box-shadow: 0px 2px 4px ${ + theme.palette.mode === 'dark' ? 'rgba(0,0,0, 0.5)' : 'rgba(0,0,0, 0.05)' + }; + + &:hover { + border-color: ${blue[400]}; + } + + &:focus { + border-color: ${blue[400]}; + box-shadow: 0 0 0 3px ${theme.palette.mode === 'dark' ? blue[600] : blue[200]}; + } + + // firefox + &:focus-visible { + outline: 0; + } +`, +); diff --git a/docs/data/base/components/input/OTPInput.tsx b/docs/data/base/components/input/OTPInput.tsx new file mode 100644 index 00000000000000..16342a4935cf6d --- /dev/null +++ b/docs/data/base/components/input/OTPInput.tsx @@ -0,0 +1,245 @@ +import * as React from 'react'; +import { Input as BaseInput } from '@mui/base/Input'; +import { Box, styled } from '@mui/system'; + +function OTP({ + separator, + length, + value, + onChange, +}: { + separator: React.ReactNode; + length: number; + value: string; + onChange: React.Dispatch>; +}) { + const inputRefs = React.useRef(new Array(length).fill(null)); + + const focusInput = (targetIndex: number) => { + const targetInput = inputRefs.current[targetIndex]; + targetInput.focus(); + }; + + const selectInput = (targetIndex: number) => { + const targetInput = inputRefs.current[targetIndex]; + targetInput.select(); + }; + + const handleKeyDown = ( + event: React.KeyboardEvent, + currentIndex: number, + ) => { + switch (event.key) { + case 'ArrowUp': + case 'ArrowDown': + case ' ': + event.preventDefault(); + break; + case 'ArrowLeft': + event.preventDefault(); + if (currentIndex > 0) { + focusInput(currentIndex - 1); + selectInput(currentIndex - 1); + } + break; + case 'ArrowRight': + event.preventDefault(); + if (currentIndex < length - 1) { + focusInput(currentIndex + 1); + selectInput(currentIndex + 1); + } + break; + case 'Delete': + event.preventDefault(); + onChange((prevOtp) => { + const otp = + prevOtp.slice(0, currentIndex) + prevOtp.slice(currentIndex + 1); + return otp; + }); + + break; + case 'Backspace': + event.preventDefault(); + if (currentIndex > 0) { + focusInput(currentIndex - 1); + selectInput(currentIndex - 1); + } + + onChange((prevOtp) => { + const otp = + prevOtp.slice(0, currentIndex) + prevOtp.slice(currentIndex + 1); + return otp; + }); + break; + + default: + break; + } + }; + + const handleChange = ( + event: React.ChangeEvent, + currentIndex: number, + ) => { + const currentValue = event.target.value; + let indexToEnter = 0; + + while (indexToEnter <= currentIndex) { + if (inputRefs.current[indexToEnter].value && indexToEnter < currentIndex) { + indexToEnter += 1; + } else { + break; + } + } + onChange((prev) => { + const otpArray = prev.split(''); + const lastValue = currentValue[currentValue.length - 1]; + otpArray[indexToEnter] = lastValue; + return otpArray.join(''); + }); + if (currentValue !== '') { + if (currentIndex < length - 1) { + focusInput(currentIndex + 1); + } + } + }; + + const handleClick = ( + event: React.MouseEvent, + currentIndex: number, + ) => { + selectInput(currentIndex); + }; + + const handlePaste = ( + event: React.ClipboardEvent, + currentIndex: number, + ) => { + event.preventDefault(); + const clipboardData = event.clipboardData; + + // Check if there is text data in the clipboard + if (clipboardData.types.includes('text/plain')) { + let pastedText = clipboardData.getData('text/plain'); + pastedText = pastedText.substring(0, length).trim(); + let indexToEnter = 0; + + while (indexToEnter <= currentIndex) { + if (inputRefs.current[indexToEnter].value && indexToEnter < currentIndex) { + indexToEnter += 1; + } else { + break; + } + } + + const otpArray = value.split(''); + + for (let i = indexToEnter; i < length; i += 1) { + const lastValue = pastedText[i - indexToEnter] ?? ' '; + otpArray[i] = lastValue; + } + + onChange(otpArray.join('')); + } + }; + + return ( + + {new Array(length).fill(null).map((_, index) => ( + + { + inputRefs.current[index] = ele!; + }, + onKeyDown: (event) => handleKeyDown(event, index), + onChange: (event) => handleChange(event, index), + onClick: (event) => handleClick(event, index), + onPaste: (event) => handlePaste(event, index), + value: value[index] ?? '', + }, + }} + /> + {index === length - 1 ? null : separator} + + ))} + + ); +} + +export default function OTPInput() { + const [otp, setOtp] = React.useState(''); + + return ( + + -} value={otp} onChange={setOtp} length={5} /> + Entered value: {otp} + + ); +} + +const blue = { + 100: '#DAECFF', + 200: '#80BFFF', + 400: '#3399FF', + 500: '#007FFF', + 600: '#0072E5', + 700: '#0059B2', +}; + +const grey = { + 50: '#F3F6F9', + 100: '#E5EAF2', + 200: '#DAE2ED', + 300: '#C7D0DD', + 400: '#B0B8C4', + 500: '#9DA8B7', + 600: '#6B7A90', + 700: '#434D5B', + 800: '#303740', + 900: '#1C2025', +}; + +const InputElement = styled('input')( + ({ theme }) => ` + width: 40px; + font-family: 'IBM Plex Sans', sans-serif; + font-size: 0.875rem; + font-weight: 400; + line-height: 1.5; + padding: 8px 0px; + border-radius: 8px; + text-align: center; + color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]}; + background: ${theme.palette.mode === 'dark' ? grey[900] : '#fff'}; + border: 1px solid ${theme.palette.mode === 'dark' ? grey[700] : grey[200]}; + box-shadow: 0px 2px 4px ${ + theme.palette.mode === 'dark' ? 'rgba(0,0,0, 0.5)' : 'rgba(0,0,0, 0.05)' + }; + + &:hover { + border-color: ${blue[400]}; + } + + &:focus { + border-color: ${blue[400]}; + box-shadow: 0 0 0 3px ${theme.palette.mode === 'dark' ? blue[600] : blue[200]}; + } + + // firefox + &:focus-visible { + outline: 0; + } +`, +); diff --git a/docs/data/base/components/input/OTPInput.tsx.preview b/docs/data/base/components/input/OTPInput.tsx.preview new file mode 100644 index 00000000000000..9e5f02b24737cc --- /dev/null +++ b/docs/data/base/components/input/OTPInput.tsx.preview @@ -0,0 +1,2 @@ +-} value={otp} onChange={setOtp} length={5} /> +Entered value: {otp} \ No newline at end of file diff --git a/docs/data/base/components/input/input.md b/docs/data/base/components/input/input.md index 3e0ef2a7679d5d..5134081bc1f995 100644 --- a/docs/data/base/components/input/input.md +++ b/docs/data/base/components/input/input.md @@ -134,3 +134,11 @@ To set minimum and maximum sizes, add the `minRows` and `maxRows` props. The following demo shows how to insert a Textarea Autosize component into an Input so that its height grows with the length of the content: {{"demo": "InputMultilineAutosize.js"}} + +## Common examples + +### OTP Input + +The following demo shows how to build a one-time password component using `Input`. + +{{"demo": "OTPInput.js"}}