Skip to content

Commit

Permalink
Add One Time Passcode Input
Browse files Browse the repository at this point in the history
  • Loading branch information
ThomasSeaver authored Jun 4, 2024
1 parent 0242feb commit 400dbaa
Show file tree
Hide file tree
Showing 6 changed files with 197 additions and 0 deletions.
6 changes: 6 additions & 0 deletions .changeset/small-planes-beam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@spear-ai/storybook": minor
"@spear-ai/ui": minor
---

Created one time passcode input component.
1 change: 1 addition & 0 deletions packages/storybook/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"@sentry/nextjs": "8.2.1",
"@spear-ai/logo": "2.1.1",
"@spear-ai/ui": "*",
"input-otp": "1.2.4",
"next": "14.2.3",
"next-themes": "0.3.0",
"react": "18.3.1",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/* eslint-disable react/jsx-props-no-spreading, react/no-array-index-key */
import {
OneTimePasscodeInput,
OneTimePasscodeInputDash,
OneTimePasscodeInputSegment,
OneTimePasscodeInputSlot,
} from "@spear-ai/ui/components/one-time-passcode-input";
import type { Meta, StoryObj } from "@storybook/react";
import { SlotProps } from "input-otp";
import { useRef, useState } from "react";
import { Button } from "react-aria-components";
import { useIntl } from "react-intl";

const PreviewOneTimePasscodeInput = (properties: { isDisabled: boolean }) => {
const intl = useIntl();
const { isDisabled } = properties;
const [oneTimePasscodeState, setOneTimePasscodeState] = useState<string>("");
const buttonReference = useRef<HTMLButtonElement>(null);

return (
<div className="flex flex-col">
<OneTimePasscodeInput
isDisabled={isDisabled}
maxLength={6}
onChange={(changedValue: string) => {
setOneTimePasscodeState(changedValue);
}}
onComplete={() => {
buttonReference.current?.focus();
}}
render={({ slots }: { slots: SlotProps[] }) => (
<>
<OneTimePasscodeInputSegment>
{slots.slice(0, 3).map((slot, index) => (
<OneTimePasscodeInputSlot key={index} {...slot} />
))}
</OneTimePasscodeInputSegment>
<OneTimePasscodeInputDash />
<OneTimePasscodeInputSegment className="flex">
{slots.slice(3).map((slot, index) => (
<OneTimePasscodeInputSlot key={index} {...slot} />
))}
</OneTimePasscodeInputSegment>
</>
)}
value={oneTimePasscodeState}
/>
<Button
className="mt-10 rounded-full bg-primary-a-7 p-2 text-xl text-neutral-12"
ref={buttonReference}
>
{intl.formatMessage({
defaultMessage: "Confirm",
id: "N2IrpM",
})}
</Button>
</div>
);
};

const meta = {
component: PreviewOneTimePasscodeInput,
} satisfies Meta<typeof PreviewOneTimePasscodeInput>;

type Story = StoryObj<typeof meta>;

export const Standard: Story = {
args: {
isDisabled: false,
},
parameters: {
layout: "centered",
},
};

export default meta;
2 changes: 2 additions & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"eslint": "8.57.0",
"eslint-config-prettier": "9.1.0",
"graphql": "16.8.1",
"input-otp": "1.2.4",
"npm-package-json-lint": "7.1.0",
"prettier": "3.2.5",
"react": "18.3.1",
Expand Down Expand Up @@ -56,6 +57,7 @@
],
"license": "UNLICENSED",
"peerDependencies": {
"input-otp": "^1.2.4",
"react": "^18.3.1",
"react-aria-components": "^1.2.0",
"react-intl": "^6.6.5",
Expand Down
99 changes: 99 additions & 0 deletions packages/ui/src/components/one-time-passcode-input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { OTPInput as OTPInputPrimitive, SlotProps } from "input-otp";
import {
ComponentPropsWithoutRef,
ElementRef,
forwardRef,
HTMLAttributes,
SVGAttributes,
useState,
} from "react";
import { cx } from "@/helpers/cx";

export const OneTimePasscodeInput = forwardRef<
ElementRef<typeof OTPInputPrimitive>,
ComponentPropsWithoutRef<typeof OTPInputPrimitive> & {
className?: string | undefined;
isDisabled?: boolean | undefined;
}
>(({ className, isDisabled, maxLength, onChange, onComplete, render, value, ...properties }, reference) => {
const [isInvalid, setIsInvalid] = useState(false);
const mergedClassName = cx("text-neutral-12 flex items-center", className);
return (
<div className="group" data-disabled={isDisabled} data-invalid={isInvalid}>
{/* @ts-expect-error Type gets confused because of the wrapped primitive, I think */}
<OTPInputPrimitive
containerClassName={mergedClassName}
disabled={isDisabled}
{...properties}
inputMode="text"
maxLength={maxLength * 3}
onChange={(updatedValue: string) => {
const dehyphenatedValue = updatedValue.replaceAll("-", "");
const shortenedValue = dehyphenatedValue.slice(0, maxLength);
const cleanedValue = shortenedValue.replaceAll(/\D/gu, "");
if (shortenedValue === cleanedValue) {
onChange?.(cleanedValue);
if (shortenedValue.length === maxLength) {
onComplete?.();
}
setIsInvalid(false);
} else {
setIsInvalid(true);
}
}}
pattern="^.+$"
ref={reference}
render={(renderProperties) =>
render?.({ ...renderProperties, slots: renderProperties.slots.slice(0, maxLength) }) ?? <div />
}
value={value}
/>
</div>
);
});

OneTimePasscodeInput.displayName = "OneTimePasscodeInput";

export const OneTimePasscodeInputDash = forwardRef<SVGSVGElement, SVGAttributes<SVGElement>>(
({ className, ...properties }, reference) => {
const mergedClassName = cx("text-neutral-a-8 group size-5", className);

return (
<svg className={mergedClassName} ref={reference} viewBox="0 0 20 4" {...properties}>
<rect fill="currentColor" height="4" rx="2" width="12" x="4" />
</svg>
);
},
);

OneTimePasscodeInputDash.displayName = "OneTimePasscodeInputDash";

export const OneTimePasscodeInputSlot = forwardRef<
HTMLDivElement,
HTMLAttributes<HTMLDivElement> & SlotProps
>(({ char: character, className, isActive, ...properties }, reference) => {
const mergedClassName = cx(
"bg-white-a-3 dark:bg-black-a-3 group-data-[invalid=true]:data-[is-active=true]:outline-x-negative-8 outline-neutral-a-7 group-data-[disabled=true]:text-neutral-a-8 group-data-[disabled=true]:outline-neutral-a-6 data-[is-active=true]:outline-primary-8 group relative flex h-14 w-10 items-center justify-center text-2xl outline outline-1 -outline-offset-1 transition duration-300 first:rounded-s-md last:rounded-e-md data-[is-active=true]:z-10 data-[is-active=true]:outline-2",
className,
);
return (
<div className={mergedClassName} data-is-active={isActive} {...properties} ref={reference}>
{character !== null && character}
</div>
);
});

OneTimePasscodeInputSlot.displayName = "OneTimePasscodeInputSlot";

export const OneTimePasscodeInputSegment = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ children, className, ...properties }, reference) => {
const mergedClassName = cx("flex", className);
return (
<div className={mergedClassName} {...properties} ref={reference}>
{children}
</div>
);
},
);

OneTimePasscodeInputSegment.displayName = "OneTimePasscodeInputSegment";
13 changes: 13 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -6359,6 +6359,7 @@ __metadata:
eslint: "npm:8.57.0"
eslint-config-prettier: "npm:9.1.0"
graphql: "npm:16.8.1"
input-otp: "npm:1.2.4"
next: "npm:14.2.3"
next-themes: "npm:0.3.0"
npm-package-json-lint: "npm:7.1.0"
Expand Down Expand Up @@ -6457,6 +6458,7 @@ __metadata:
eslint: "npm:8.57.0"
eslint-config-prettier: "npm:9.1.0"
graphql: "npm:16.8.1"
input-otp: "npm:1.2.4"
next: "npm:^14.2.3"
npm-package-json-lint: "npm:7.1.0"
prettier: "npm:3.2.5"
Expand All @@ -6471,6 +6473,7 @@ __metadata:
turbo: "npm:1.13.3"
typescript: "npm:5.4.5"
peerDependencies:
input-otp: ^1.2.4
react: ^18.3.1
react-aria-components: ^1.2.0
react-intl: ^6.6.5
Expand Down Expand Up @@ -14023,6 +14026,16 @@ __metadata:
languageName: node
linkType: hard

"input-otp@npm:1.2.4":
version: 1.2.4
resolution: "input-otp@npm:1.2.4"
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
checksum: 10/dde21c35334864f0add026f6a66e87eac572278c24edb72c97ba9541e5133f3d6750a4230b9a9c6093f4bd84633e4e417c8849ef844a565834392b9119b4110f
languageName: node
linkType: hard

"internal-slot@npm:^1.0.4, internal-slot@npm:^1.0.7":
version: 1.0.7
resolution: "internal-slot@npm:1.0.7"
Expand Down

0 comments on commit 400dbaa

Please sign in to comment.