diff --git a/src/components/form/form/form.stories.tsx b/src/components/form/form/form.stories.tsx index 81f49b2..d036f94 100644 --- a/src/components/form/form/form.stories.tsx +++ b/src/components/form/form/form.stories.tsx @@ -2,6 +2,7 @@ import { fn } from "@storybook/test"; import { FormComponent } from "./form.component"; import { InputComponent, SelectorComponent, ButtonComponent } from "../index"; import { BoxComponent, CardComponent } from "../../../components"; +import { OtpComponent } from "../otp"; export default { title: "Components/Form", @@ -50,6 +51,7 @@ export const Primary = { type="password" placeholder="repeat password" /> + & + React.HTMLProps; + +export const OtpComponent: React.FC = ({ + className, + wrapperClassName, + placeholder, + bordered, + length = 6, + ...props +}) => { + const id = useId(); + const [otherProps, boxProps] = extractBoxProps(props); + + const otpValueRef = useRef([]); + const hiddenInputRef = useRef(null); + + const onChangeInput = useCallback( + (index: number) => (event) => { + if (!parseInt(event.target.value)) { + event.target.value = null; + return event.preventDefault(); + } + try { + document.activeElement.nextElementSibling.focus(); + } catch (e) {} + otpValueRef.current[index] = event.target.value; + hiddenInputRef.current.value = otpValueRef.current.join(""); + }, + [], + ); + + const inputRender = useMemo(() => { + const inputs = []; + for (let i = 0; i < length; i++) { + inputs.push( + , + ); + } + return inputs; + }, [length, onChangeInput, id, className]); + + return ( + + + +
{inputRender}
+
+ ); +}; diff --git a/src/components/form/otp/otp.module.scss b/src/components/form/otp/otp.module.scss new file mode 100644 index 0000000..1a065a3 --- /dev/null +++ b/src/components/form/otp/otp.module.scss @@ -0,0 +1,71 @@ +@use "../../../styles/all.module" as *; + +.inputWrapper { + display: inline-flex; + position: relative; + width: 100%; + flex-direction: column; + + .placeholder { + color: var(--input-placeholder-c); + padding: 0; + cursor: text; + user-select: none; + margin-left: 0rem; + } + + .inputs { + display: flex; + gap: 0.5rem; + width: 100%; + + .input { + background-color: var(--input-bg); + color: var(--input-c); + width: 3rem; + border: 0.2rem solid transparent; + + padding: 1rem 0; + border-radius: var(--border-radius); + outline: none; + text-align: center; + + display: inline-flex; + + &.hasPlaceholder { + transition: padding; + transition-duration: 0.3s; + + &:not(:placeholder-shown), + :-webkit-autofill { + padding-top: 1.7rem; + padding-bottom: 0.3rem; + } + } + + &:active, + &:focus { + background-color: var(--input-focus-bg); + color: var(--input-focus-c); + } + } + + &.bordered .input { + border: 0.2rem solid var(--input-bd); + + &:active, + &:focus { + border: 0.2rem solid var(--input-focus-bd); + } + } + &.disabled { + opacity: 0.5; + } + + // Weird hack to style autofill color in Chrome + .input:-webkit-autofill { + box-shadow: 0 0 0 1000px var(--input-bg) inset; + -webkit-text-fill-color: var(--input-c); + } + } +} diff --git a/src/components/form/otp/otp.stories.ts b/src/components/form/otp/otp.stories.ts new file mode 100644 index 0000000..b28b895 --- /dev/null +++ b/src/components/form/otp/otp.stories.ts @@ -0,0 +1,19 @@ +import { fn } from "@storybook/test"; +import { OtpComponent } from "./otp.component"; + +export default { + title: "Components/Form/OTP Input", + component: OtpComponent, + parameters: { + layout: "centered", + backgrounds: { default: "dark" }, + }, + args: { onChange: fn() }, +}; + +export const Primary = { + args: { + placeholder: "2FA", + name: "otp", + }, +};