From cfaa988dc780f45a57c0861ef77363a8397c3419 Mon Sep 17 00:00:00 2001 From: Maharshi Alpesh Date: Tue, 10 Sep 2024 20:51:56 +0530 Subject: [PATCH 1/2] feat(input-otp): adding the functionality --- .changeset/spotty-flies-jump.md | 6 + apps/docs/config/routes.json | 7 + .../components/dropdown/variants.raw.jsx | 0 apps/docs/content/components/index.ts | 1 + .../components/input-otp/allowed-keys.raw.jsx | 22 + .../components/input-otp/allowed-keys.ts | 9 + .../components/input-otp/colors.raw.jsx | 19 + .../content/components/input-otp/colors.ts | 9 + .../components/input-otp/controlled.raw.jsx | 13 + .../components/input-otp/controlled.ts | 9 + .../components/input-otp/description.raw.jsx | 9 + .../components/input-otp/description.ts | 9 + .../components/input-otp/disabled.raw.jsx | 9 + .../content/components/input-otp/disabled.ts | 9 + .../input-otp/error-message.raw.jsx | 13 + .../components/input-otp/error-message.ts | 9 + .../content/components/input-otp/index.ts | 29 ++ .../components/input-otp/password.raw.jsx | 9 + .../content/components/input-otp/password.ts | 9 + .../components/input-otp/radius.raw.jsx | 19 + .../content/components/input-otp/radius.ts | 9 + .../components/input-otp/readonly.raw.jsx | 9 + .../content/components/input-otp/readonly.ts | 9 + .../components/input-otp/required.raw.jsx | 9 + .../content/components/input-otp/required.ts | 9 + .../components/input-otp/sizes.raw.jsx | 19 + .../content/components/input-otp/sizes.ts | 9 + .../components/input-otp/usage.raw.jsx | 19 + .../content/components/input-otp/usage.ts | 9 + .../components/input-otp/variants.raw.jsx | 19 + .../content/components/input-otp/variants.ts | 9 + .../content/docs/components/input-otp.mdx | 191 ++++++++ apps/docs/package.json | 1 + apps/docs/public/sitemap-0.xml | 136 +++--- packages/components/input-otp/README.md | 26 + .../input-otp/__tests__/input-otp.test.tsx | 296 +++++++++++ packages/components/input-otp/package.json | 61 +++ packages/components/input-otp/src/index.ts | 10 + .../input-otp/src/input-otp-context.ts | 9 + .../input-otp/src/input-otp-segment.tsx | 52 ++ .../components/input-otp/src/input-otp.tsx | 86 ++++ .../components/input-otp/src/use-input-otp.ts | 365 ++++++++++++++ .../input-otp/stories/input-otp.stories.tsx | 247 ++++++++++ packages/components/input-otp/tsconfig.json | 10 + packages/components/input-otp/tsup.config.ts | 8 + packages/core/react/package.json | 1 + packages/core/react/src/index.ts | 1 + packages/core/theme/src/components/index.ts | 1 + .../core/theme/src/components/input-otp.ts | 458 ++++++++++++++++++ pnpm-lock.yaml | 52 ++ 50 files changed, 2297 insertions(+), 62 deletions(-) create mode 100644 .changeset/spotty-flies-jump.md create mode 100644 apps/docs/content/components/dropdown/variants.raw.jsx create mode 100644 apps/docs/content/components/input-otp/allowed-keys.raw.jsx create mode 100644 apps/docs/content/components/input-otp/allowed-keys.ts create mode 100644 apps/docs/content/components/input-otp/colors.raw.jsx create mode 100644 apps/docs/content/components/input-otp/colors.ts create mode 100644 apps/docs/content/components/input-otp/controlled.raw.jsx create mode 100644 apps/docs/content/components/input-otp/controlled.ts create mode 100644 apps/docs/content/components/input-otp/description.raw.jsx create mode 100644 apps/docs/content/components/input-otp/description.ts create mode 100644 apps/docs/content/components/input-otp/disabled.raw.jsx create mode 100644 apps/docs/content/components/input-otp/disabled.ts create mode 100644 apps/docs/content/components/input-otp/error-message.raw.jsx create mode 100644 apps/docs/content/components/input-otp/error-message.ts create mode 100644 apps/docs/content/components/input-otp/index.ts create mode 100644 apps/docs/content/components/input-otp/password.raw.jsx create mode 100644 apps/docs/content/components/input-otp/password.ts create mode 100644 apps/docs/content/components/input-otp/radius.raw.jsx create mode 100644 apps/docs/content/components/input-otp/radius.ts create mode 100644 apps/docs/content/components/input-otp/readonly.raw.jsx create mode 100644 apps/docs/content/components/input-otp/readonly.ts create mode 100644 apps/docs/content/components/input-otp/required.raw.jsx create mode 100644 apps/docs/content/components/input-otp/required.ts create mode 100644 apps/docs/content/components/input-otp/sizes.raw.jsx create mode 100644 apps/docs/content/components/input-otp/sizes.ts create mode 100644 apps/docs/content/components/input-otp/usage.raw.jsx create mode 100644 apps/docs/content/components/input-otp/usage.ts create mode 100644 apps/docs/content/components/input-otp/variants.raw.jsx create mode 100644 apps/docs/content/components/input-otp/variants.ts create mode 100644 apps/docs/content/docs/components/input-otp.mdx create mode 100644 packages/components/input-otp/README.md create mode 100644 packages/components/input-otp/__tests__/input-otp.test.tsx create mode 100644 packages/components/input-otp/package.json create mode 100644 packages/components/input-otp/src/index.ts create mode 100644 packages/components/input-otp/src/input-otp-context.ts create mode 100644 packages/components/input-otp/src/input-otp-segment.tsx create mode 100644 packages/components/input-otp/src/input-otp.tsx create mode 100644 packages/components/input-otp/src/use-input-otp.ts create mode 100644 packages/components/input-otp/stories/input-otp.stories.tsx create mode 100644 packages/components/input-otp/tsconfig.json create mode 100644 packages/components/input-otp/tsup.config.ts create mode 100644 packages/core/theme/src/components/input-otp.ts diff --git a/.changeset/spotty-flies-jump.md b/.changeset/spotty-flies-jump.md new file mode 100644 index 0000000000..3a86369b06 --- /dev/null +++ b/.changeset/spotty-flies-jump.md @@ -0,0 +1,6 @@ +--- +"@nextui-org/input-otp": minor +"@nextui-org/theme": minor +--- + +Adding new input-otp component. diff --git a/apps/docs/config/routes.json b/apps/docs/config/routes.json index 1d5500f469..8f482940f3 100644 --- a/apps/docs/config/routes.json +++ b/apps/docs/config/routes.json @@ -273,6 +273,13 @@ "keywords": "input, text box, form field, data entry", "path": "/docs/components/input.mdx" }, + { + "key": "input-otp", + "title": "Input OTP", + "keywords": "input, otp, auth", + "path": "/docs/components/input-otp.mdx", + "newPost": true + }, { "key": "kbd", "title": "Kbd", diff --git a/apps/docs/content/components/dropdown/variants.raw.jsx b/apps/docs/content/components/dropdown/variants.raw.jsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apps/docs/content/components/index.ts b/apps/docs/content/components/index.ts index 45cfacb811..ced4352a6f 100644 --- a/apps/docs/content/components/index.ts +++ b/apps/docs/content/components/index.ts @@ -18,6 +18,7 @@ export * from "./user"; export * from "./skeleton"; export * from "./snippet"; export * from "./input"; +export * from "./input-otp"; export * from "./textarea"; export * from "./image"; export * from "./radio-group"; diff --git a/apps/docs/content/components/input-otp/allowed-keys.raw.jsx b/apps/docs/content/components/input-otp/allowed-keys.raw.jsx new file mode 100644 index 0000000000..a9d2e828aa --- /dev/null +++ b/apps/docs/content/components/input-otp/allowed-keys.raw.jsx @@ -0,0 +1,22 @@ +import {InputOtp} from "@nextui-org/react"; + +export default function App() { + const exps = [ + { + name: "For below InputOtp, only lower-case alphabets (a to z) are allowed:", + value: "^[a-z]*$", + }, + {name: "For below InputOtp, only upper-case alphabets(A to Z) are allowed:", value: "^[A-Z]*$"}, + ]; + + return ( +
+ {exps.map((exp, idx) => ( +
+
{exp.name}
+ +
+ ))} +
+ ); +} diff --git a/apps/docs/content/components/input-otp/allowed-keys.ts b/apps/docs/content/components/input-otp/allowed-keys.ts new file mode 100644 index 0000000000..4b28d9836f --- /dev/null +++ b/apps/docs/content/components/input-otp/allowed-keys.ts @@ -0,0 +1,9 @@ +import App from "./allowed-keys.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/input-otp/colors.raw.jsx b/apps/docs/content/components/input-otp/colors.raw.jsx new file mode 100644 index 0000000000..5523609028 --- /dev/null +++ b/apps/docs/content/components/input-otp/colors.raw.jsx @@ -0,0 +1,19 @@ +import {InputOtp} from "@nextui-org/react"; + +export default function App() { + const colors = ["default", "primary", "secondary", "success", "warning", "danger"]; + + return ( +
+ {colors.map((color) => ( +
+
color: {color}
+ +
+ ))} +
+ ); +} diff --git a/apps/docs/content/components/input-otp/colors.ts b/apps/docs/content/components/input-otp/colors.ts new file mode 100644 index 0000000000..d5bef810aa --- /dev/null +++ b/apps/docs/content/components/input-otp/colors.ts @@ -0,0 +1,9 @@ +import App from "./colors.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/input-otp/controlled.raw.jsx b/apps/docs/content/components/input-otp/controlled.raw.jsx new file mode 100644 index 0000000000..941963a4f1 --- /dev/null +++ b/apps/docs/content/components/input-otp/controlled.raw.jsx @@ -0,0 +1,13 @@ +import {InputOtp} from "@nextui-org/react"; +import React from "react"; + +export default function App() { + const [value, setValue] = React.useState(""); + + return ( +
+ +

InputOtp value: {value}

+
+ ); +} diff --git a/apps/docs/content/components/input-otp/controlled.ts b/apps/docs/content/components/input-otp/controlled.ts new file mode 100644 index 0000000000..2c3f0cacb4 --- /dev/null +++ b/apps/docs/content/components/input-otp/controlled.ts @@ -0,0 +1,9 @@ +import App from "./controlled.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/input-otp/description.raw.jsx b/apps/docs/content/components/input-otp/description.raw.jsx new file mode 100644 index 0000000000..54ac7feb36 --- /dev/null +++ b/apps/docs/content/components/input-otp/description.raw.jsx @@ -0,0 +1,9 @@ +import {InputOtp} from "@nextui-org/react"; + +export default function App() { + return ( +
+ +
+ ); +} diff --git a/apps/docs/content/components/input-otp/description.ts b/apps/docs/content/components/input-otp/description.ts new file mode 100644 index 0000000000..aeb6340b6b --- /dev/null +++ b/apps/docs/content/components/input-otp/description.ts @@ -0,0 +1,9 @@ +import App from "./description.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/input-otp/disabled.raw.jsx b/apps/docs/content/components/input-otp/disabled.raw.jsx new file mode 100644 index 0000000000..6af63e7e21 --- /dev/null +++ b/apps/docs/content/components/input-otp/disabled.raw.jsx @@ -0,0 +1,9 @@ +import {InputOtp} from "@nextui-org/react"; + +export default function App() { + return ( +
+ +
+ ); +} diff --git a/apps/docs/content/components/input-otp/disabled.ts b/apps/docs/content/components/input-otp/disabled.ts new file mode 100644 index 0000000000..1a215cc91f --- /dev/null +++ b/apps/docs/content/components/input-otp/disabled.ts @@ -0,0 +1,9 @@ +import App from "./disabled.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/input-otp/error-message.raw.jsx b/apps/docs/content/components/input-otp/error-message.raw.jsx new file mode 100644 index 0000000000..7dcfffbf1b --- /dev/null +++ b/apps/docs/content/components/input-otp/error-message.raw.jsx @@ -0,0 +1,13 @@ +import {InputOtp} from "@nextui-org/react"; + +export default function App() { + return ( +
+ +
+ ); +} diff --git a/apps/docs/content/components/input-otp/error-message.ts b/apps/docs/content/components/input-otp/error-message.ts new file mode 100644 index 0000000000..fb8101b132 --- /dev/null +++ b/apps/docs/content/components/input-otp/error-message.ts @@ -0,0 +1,9 @@ +import App from "./error-message.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/input-otp/index.ts b/apps/docs/content/components/input-otp/index.ts new file mode 100644 index 0000000000..2f9f87ce61 --- /dev/null +++ b/apps/docs/content/components/input-otp/index.ts @@ -0,0 +1,29 @@ +import usage from "./usage"; +import disabled from "./disabled"; +import readonly from "./readonly"; +import required from "./required"; +import sizes from "./sizes"; +import colors from "./colors"; +import variants from "./variants"; +import radius from "./radius"; +import description from "./description"; +import errorMessage from "./error-message"; +import allowedKeys from "./allowed-keys"; +import controlled from "./controlled"; +import password from "./password"; + +export const inputOtpContent = { + usage, + disabled, + readonly, + required, + sizes, + colors, + variants, + radius, + description, + errorMessage, + allowedKeys, + controlled, + password, +}; diff --git a/apps/docs/content/components/input-otp/password.raw.jsx b/apps/docs/content/components/input-otp/password.raw.jsx new file mode 100644 index 0000000000..223d1ec6f0 --- /dev/null +++ b/apps/docs/content/components/input-otp/password.raw.jsx @@ -0,0 +1,9 @@ +import {InputOtp} from "@nextui-org/react"; + +export default function App() { + return ( +
+ +
+ ); +} diff --git a/apps/docs/content/components/input-otp/password.ts b/apps/docs/content/components/input-otp/password.ts new file mode 100644 index 0000000000..7751eaf935 --- /dev/null +++ b/apps/docs/content/components/input-otp/password.ts @@ -0,0 +1,9 @@ +import App from "./password.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/input-otp/radius.raw.jsx b/apps/docs/content/components/input-otp/radius.raw.jsx new file mode 100644 index 0000000000..18ed4174f4 --- /dev/null +++ b/apps/docs/content/components/input-otp/radius.raw.jsx @@ -0,0 +1,19 @@ +import {InputOtp} from "@nextui-org/react"; + +export default function App() { + const radiusValues = ["none", "sm", "md", "lg", "full"]; + + return ( +
+ {radiusValues.map((radius) => ( +
+
radius: {radius}
+ +
+ ))} +
+ ); +} diff --git a/apps/docs/content/components/input-otp/radius.ts b/apps/docs/content/components/input-otp/radius.ts new file mode 100644 index 0000000000..7b78db1ce0 --- /dev/null +++ b/apps/docs/content/components/input-otp/radius.ts @@ -0,0 +1,9 @@ +import App from "./radius.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/input-otp/readonly.raw.jsx b/apps/docs/content/components/input-otp/readonly.raw.jsx new file mode 100644 index 0000000000..2d70284f38 --- /dev/null +++ b/apps/docs/content/components/input-otp/readonly.raw.jsx @@ -0,0 +1,9 @@ +import {InputOtp} from "@nextui-org/react"; + +export default function App() { + return ( +
+ +
+ ); +} diff --git a/apps/docs/content/components/input-otp/readonly.ts b/apps/docs/content/components/input-otp/readonly.ts new file mode 100644 index 0000000000..fabd05ba36 --- /dev/null +++ b/apps/docs/content/components/input-otp/readonly.ts @@ -0,0 +1,9 @@ +import App from "./readonly.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/input-otp/required.raw.jsx b/apps/docs/content/components/input-otp/required.raw.jsx new file mode 100644 index 0000000000..30c521e995 --- /dev/null +++ b/apps/docs/content/components/input-otp/required.raw.jsx @@ -0,0 +1,9 @@ +import {InputOtp} from "@nextui-org/react"; + +export default function App() { + return ( +
+ +
+ ); +} diff --git a/apps/docs/content/components/input-otp/required.ts b/apps/docs/content/components/input-otp/required.ts new file mode 100644 index 0000000000..b50b781e6f --- /dev/null +++ b/apps/docs/content/components/input-otp/required.ts @@ -0,0 +1,9 @@ +import App from "./required.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/input-otp/sizes.raw.jsx b/apps/docs/content/components/input-otp/sizes.raw.jsx new file mode 100644 index 0000000000..b3a3938afe --- /dev/null +++ b/apps/docs/content/components/input-otp/sizes.raw.jsx @@ -0,0 +1,19 @@ +import {InputOtp} from "@nextui-org/react"; + +export default function App() { + const sizes = ["sm", "md", "lg"]; + + return ( +
+ {sizes.map((size) => ( +
+
size: {size}
+ +
+ ))} +
+ ); +} diff --git a/apps/docs/content/components/input-otp/sizes.ts b/apps/docs/content/components/input-otp/sizes.ts new file mode 100644 index 0000000000..85a2f5b30b --- /dev/null +++ b/apps/docs/content/components/input-otp/sizes.ts @@ -0,0 +1,9 @@ +import App from "./sizes.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/input-otp/usage.raw.jsx b/apps/docs/content/components/input-otp/usage.raw.jsx new file mode 100644 index 0000000000..b51499795f --- /dev/null +++ b/apps/docs/content/components/input-otp/usage.raw.jsx @@ -0,0 +1,19 @@ +import {InputOtp, Button} from "@nextui-org/react"; + +export default function App() { + const [value, setValue] = React.useState(""); + + const handleSubmit = (e) => { + e.preventDefault(); + alert("Submitted OTP: " + value); + }; + + return ( +
+ + + + ); +} diff --git a/apps/docs/content/components/input-otp/usage.ts b/apps/docs/content/components/input-otp/usage.ts new file mode 100644 index 0000000000..1118304c37 --- /dev/null +++ b/apps/docs/content/components/input-otp/usage.ts @@ -0,0 +1,9 @@ +import App from "./usage.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/input-otp/variants.raw.jsx b/apps/docs/content/components/input-otp/variants.raw.jsx new file mode 100644 index 0000000000..b61cb9f2c8 --- /dev/null +++ b/apps/docs/content/components/input-otp/variants.raw.jsx @@ -0,0 +1,19 @@ +import {InputOtp} from "@nextui-org/react"; + +export default function App() { + const variants = ["flat", "bordered", "underlined", "faded"]; + + return ( +
+ {variants.map((variant) => ( +
+
variant: {variant}
+ +
+ ))} +
+ ); +} diff --git a/apps/docs/content/components/input-otp/variants.ts b/apps/docs/content/components/input-otp/variants.ts new file mode 100644 index 0000000000..ddea95fb2e --- /dev/null +++ b/apps/docs/content/components/input-otp/variants.ts @@ -0,0 +1,9 @@ +import App from "./variants.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/docs/components/input-otp.mdx b/apps/docs/content/docs/components/input-otp.mdx new file mode 100644 index 0000000000..94efd4464a --- /dev/null +++ b/apps/docs/content/docs/components/input-otp.mdx @@ -0,0 +1,191 @@ +--- +title: "Input OTP" +description: "InputOtp is a component that allows users to enter OTP." +--- + +import {inputOtpContent} from "@/content/components/input-otp"; + +# Input OTP + +InputOtp is a component that allows users to enter OTP. + +--- + + + +## Installation + + + + +## Import + + + +## Usage + + + +## Disabled + +Passing `isDisabled` property will make `input-otp` disabled. + + + +## Read Only + +Passing `isReadOnly` property will make `input-otp` read only. + + + +### Required + +Passing `isRequired` property will make `input-otp` required. + + + +### Sizes + +Size of the `input-otp` can be changed by `size` property. By default, `size` property is set to `md`. + + + +### Colors + +Color of the `input-otp` can be changed by `color` property. + + + +### Variants + +Styling/Variant of the `input-otp` can be changed by `variant` property. By default, `variant` property is set to `flat`. + + + +### Radius + +Radius of the `input-otp` can be changed by `radius` property. By default, `radius` property is set to `md`. + + + +### Password + +InputOtp can be used as password/secured-pin input by setting `type` as `password`. + + + +### Description + +Description of the `input-otp` can be set by `description` property. + + + +### Error Message + +Custom error message of the `input-otp` can be set by `errorMessage` property. + + + +### Allowed Keys + +* Users are only allowed to type certain keys. Any input other than allowed keys is simply ignored. +* Allowed Keys can be modified by `allowedKeys` property which accepts the regex of the keys which are allowed. +* By default, the value of `allowedKeys` is `^[0-9]*$` (i.e. only numerical digits are allowed). + + + +### Controlled + + + +## Slots + +- **base**: InputOtp wrapper, it handles alignment, placement, and general appearance. +- **inputWrapper**: Wraps the underlying `input` element of the InputOtp. +- **input**: The input element. +- **segmentWrapper**: Wraps all the segment elements. +- **segment**: The segment element. +- **caret**: The caret represents the typing indicator of the InputOtp component. +- **passwordChar**: The passwordChar represents the text styling when input-type is password. +- **helperWrapper**: Wraps the `description` and the `errorMessage`. +- **description**: The description of the InputOtp. +- **errorMessage**: The error message of the InputOtp. + + + +## Data Attributes + +`InputOtp` has the following attributes on the `base` element: + +- **data-invalid**: + When the input-otp is invalid. Based on `isInvalid` prop. +- **data-required**: + When the input-otp is required. Based on `isRequired` prop. +- **data-readonly**: + When the input-otp is readonly. Based on `isReadOnly` prop. +- **data-hover**: + When the input-otp is being hovered. Based on [useHover](https://react-spectrum.adobe.com/react-aria/useHover.html) +- **data-focus**: + When the input-otp is being focused. Based on [useFocusRing](https://react-spectrum.adobe.com/react-aria/useFocusRing.html). +- **data-filled**: + When the input-otp is completely filled. +- **data-disabled**: + When the input is disabled. Based on `isDisabled` prop. + + + +## Accessibility + +- Built with a native `` element. +- Required and invalid states exposed to assistive technology via ARIA. +- Support for description and error message help text linked to the input-otp via ARIA. + + + +## API + +### InputOtp Props + +| Attribute | Type | Description | Default | +| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | +| length | `number` | The length of the input-otp. | `4` | +| allowedKeys | `regEx string` | The allowed keys for the input-otp. | `^[0-9]*$`| +| variant | `flat` \| `bordered` \| `faded` \| `underlined` | The variant of the input-otp. | `flat` | +| color | `default` \| `primary` \| `secondary` \| `success` \| `warning` \| `danger` | The color of the input-otp. | `default` | +| size | `sm` \| `md` \| `lg` | The size of the input-otp. | `md` | +| radius | `none` \| `sm` \| `md` \| `lg` \| `full` | The radius of the input-otp. | - | +| value | `string` | The current value of the input-otp (controlled). | - | +| defaultValue | `string` | The default value of the input-otp (uncontrolled). | - | +| description | `ReactNode` | A description for the input. Provides a hint such as specific requirements for what to choose. | - | +| errorMessage | `ReactNode` \| `((v: ValidationResult) => ReactNode)` | An error message for the input-otp. It is only shown when `isInvalid` is set to `true` | - | | The position of the label. | `inside` | +| fullWidth | `boolean` | Whether the input-otp should take up the width of its parent. | `false` | +| isRequired | `boolean` | Whether user input-otp is required on the input before form submission. | `false` | +| isReadOnly | `boolean` | Whether the input-otp can be selected but not changed by the user. | | +| isDisabled | `boolean` | Whether the input-otp is disabled. | `false` | +| isInvalid | `boolean` | Whether the input-otp is invalid. | `false` | +| baseRef | `RefObject` | The ref to the base element. | - | +| validationState | `valid` \| `invalid` | Whether the input-otp should display its "valid" or "invalid" visual styling. (**Deprecated**) use **isInvalid** instead. | - | +| disableAnimation | `boolean` | Whether the input-otp should be animated. | `false` | +| classNames | `Record<"base"| "inputWrapper"| "input"| "segmentWrapper"| "segment" | "caret" | "passwordChar" | "helperWrapper" | "description" | "errorMessage", string>` | Allows to set custom class names for the Input slots. | - | + +### InputOtp Events + +| Attribute | Type | Description | +| ------------- | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| onChange | `React.ChangeEvent` | Handler that is called when the element's value changes. You can pull out the new value by accessing `event.target.value` (string). | +| onValueChange | `(value: string) => void` | Handler that is called when the element's value changes. | +| onFill | `(value: string) => void` | Handler that is called when the element's value is completely filled. | diff --git a/apps/docs/package.json b/apps/docs/package.json index 01e4bc4aba..b135d859ac 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -26,6 +26,7 @@ "@nextui-org/divider": "workspace:*", "@nextui-org/kbd": "workspace:*", "@nextui-org/listbox": "workspace:*", + "@nextui-org/input-otp": "workspace:*", "@nextui-org/react": "workspace:*", "@nextui-org/shared-icons": "workspace:*", "@nextui-org/shared-utils": "workspace:*", diff --git a/apps/docs/public/sitemap-0.xml b/apps/docs/public/sitemap-0.xml index 09725255fe..4d60321842 100644 --- a/apps/docs/public/sitemap-0.xml +++ b/apps/docs/public/sitemap-0.xml @@ -1,65 +1,77 @@ -https://nextui.org/feed.xml2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/figma2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/docs/components/accordion2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/docs/components/autocomplete2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/docs/components/avatar2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/docs/components/badge2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/docs/components/breadcrumbs2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/docs/components/button2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/docs/components/card2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/docs/components/checkbox-group2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/docs/components/checkbox2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/docs/components/chip2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/docs/components/circular-progress2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/docs/components/code2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/docs/components/divider2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/docs/components/dropdown2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/docs/components/image2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/docs/components/input2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/docs/components/kbd2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/docs/components/link2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/docs/components/listbox2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/docs/components/modal2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/docs/components/navbar2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/docs/components/pagination2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/docs/components/popover2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/docs/components/progress2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/docs/components/radio-group2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/docs/components/scroll-shadow2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/docs/components/select2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/docs/components/skeleton2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/docs/components/slider2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/docs/components/snippet2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/docs/components/spacer2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/docs/components/spinner2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/docs/components/switch2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/docs/components/table2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/docs/components/tabs2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/docs/components/textarea2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/docs/components/tooltip2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/docs/components/user2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/docs/frameworks/astro2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/docs/frameworks/nextjs2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/docs/frameworks/remix2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/docs/frameworks/vite2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/docs/guide/design-principles2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/docs/guide/installation2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/docs/guide/introduction2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/docs/guide/routing2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/docs/guide/upgrade-to-v22024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/docs/customization/colors2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/docs/customization/create-theme2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/docs/customization/custom-variants2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/docs/customization/customize-theme2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/docs/customization/dark-mode2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/docs/customization/layout2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/docs/customization/override-styles2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/docs/customization/theme2024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/blog/nextui-v22024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/blog/v2.1.02024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/blog/v2.2.02024-02-02T20:24:45.107Zdaily0.7 -https://nextui.org/blog2024-02-02T20:24:45.107Zdaily0.7 +https://nextui.org/feed.xml2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/figma2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/blog/nextui-v22024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/blog/v2.1.02024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/blog/v2.2.02024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/blog/v2.3.02024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/blog/v2.4.02024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/api-references/cli-api2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/api-references/nextui-provider2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/customization/colors2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/customization/create-theme2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/customization/custom-variants2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/customization/customize-theme2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/customization/dark-mode2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/customization/layout2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/customization/override-styles2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/customization/theme2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/components/accordion2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/components/autocomplete2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/components/avatar2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/components/badge2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/components/breadcrumbs2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/components/button2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/components/calendar2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/components/card2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/components/checkbox-group2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/components/checkbox2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/components/chip2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/components/circular-progress2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/components/code2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/components/date-input2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/components/date-picker2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/components/date-range-picker2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/components/divider2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/components/dropdown2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/components/image2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/components/input-otp2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/components/input2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/components/kbd2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/components/link2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/components/listbox2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/components/modal2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/components/navbar2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/components/pagination2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/components/popover2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/components/progress2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/components/radio-group2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/components/range-calendar2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/components/scroll-shadow2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/components/select2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/components/skeleton2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/components/slider2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/components/snippet2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/components/spacer2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/components/spinner2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/components/switch2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/components/table2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/components/tabs2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/components/textarea2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/components/time-input2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/components/tooltip2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/components/user2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/frameworks/astro2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/frameworks/nextjs2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/frameworks/remix2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/frameworks/vite2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/guide/cli2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/guide/design-principles2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/guide/installation2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/guide/introduction2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/guide/routing2024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/docs/guide/upgrade-to-v22024-10-23T19:45:31.239Zdaily0.7 +https://nextui.org/blog2024-10-23T19:45:31.239Zdaily0.7 \ No newline at end of file diff --git a/packages/components/input-otp/README.md b/packages/components/input-otp/README.md new file mode 100644 index 0000000000..61442f0d22 --- /dev/null +++ b/packages/components/input-otp/README.md @@ -0,0 +1,26 @@ +# @nextui-org/input-otp + +InputOTP is a component that allows users to enter otp input. It can be used to get user otp in forms. + +This package contains the InputOTPcomponent. + +Please refer to the [documentation](https://nextui.org/docs/components/input-otp) for more information. + +## Installation + +```sh +yarn add @nextui-org/input-otp +# or +npm i @nextui-org/input-otp +``` + +## Contribution + +Yes please! See the +[contributing guidelines](https://github.com/nextui-org/nextui/blob/master/CONTRIBUTING.md) +for details. + +## License + +This project is licensed under the terms of the +[MIT license](https://github.com/nextui-org/nextui/blob/master/LICENSE). diff --git a/packages/components/input-otp/__tests__/input-otp.test.tsx b/packages/components/input-otp/__tests__/input-otp.test.tsx new file mode 100644 index 0000000000..c9a8e95f2a --- /dev/null +++ b/packages/components/input-otp/__tests__/input-otp.test.tsx @@ -0,0 +1,296 @@ +import * as React from "react"; +import {act, render, renderHook} from "@testing-library/react"; +import {useForm} from "react-hook-form"; +import userEvent, {UserEvent} from "@testing-library/user-event"; + +import {InputOtp} from "../src"; + +describe("InputOtp", () => { + let user: UserEvent; + + beforeAll(() => { + user = userEvent.setup(); + }); + + it("should render correctly", () => { + const wrapper = render(); + + expect(() => wrapper.unmount()).not.toThrow(); + }); + + it("ref should be forwarded", () => { + const ref = React.createRef(); + + render(); + expect(ref.current).not.toBeNull(); + }); + + it("should have length according to the prop", async () => { + render(); + const segments = document.querySelectorAll("[data-slot=segment]"); + + expect(segments.length).toBe(5); + }); + + it("should display error message", async () => { + const errorMessage = "custom error message"; + + render(); + const base = document.querySelector("[data-slot=base]")!; + + expect(base).toHaveTextContent(errorMessage); + }); + + it("should display description message", async () => { + const descriptionMessage = "custom description message"; + + render(); + const base = document.querySelector("[data-slot=base]")!; + + expect(base).toHaveTextContent(descriptionMessage); + }); + + it("should not focus on disabled", async () => { + render(); + const input = document.querySelector("[data-slot=input]")!; + + await act(async () => { + await user.click(input); + }); + + expect(input).not.toHaveAttribute("data-focus", "true"); + }); + + it("should select first segment when clicked", async () => { + render(); + const base = document.querySelector("[data-slot=base]")!; + const input = document.querySelector("[data-slot=input]")!; + const segments = document.querySelectorAll("[data-slot=segment]"); + + expect(segments.length).toBe(4); + + await act(async () => { + await user.click(input); + }); + + expect(base).toHaveAttribute("data-focus", "true"); + expect(input).toHaveAttribute("data-focus", "true"); + + expect(segments[0]).toHaveAttribute("data-active", "true"); + expect(segments[1].getAttribute("data-active")).toBe(null); + expect(segments[2].getAttribute("data-active")).toBe(null); + expect(segments[3].getAttribute("data-active")).toBe(null); + }); + + it("should not be focused when disabled", async () => { + render(); + const input = document.querySelector("[data-slot=input]")!; + + await act(async () => { + await user.click(input); + }); + + expect(input).toBeDisabled(); + }); + + it("should shift focus to next segment when valid digit is typed", async () => { + render(); + + const base = document.querySelector("[data-slot=base]")!; + const input = document.querySelector("[data-slot=input]")!; + const segments = document.querySelectorAll("[data-slot=segment]"); + + expect(segments.length).toBe(4); + + await act(async () => { + await user.click(input); + }); + + expect(base).toHaveAttribute("data-focus", "true"); + expect(input).toHaveAttribute("data-focus", "true"); + // since no input is entered hence segment[1] will not be active + expect(segments[1].getAttribute("data-active")).toBe(null); + + await act(async () => { + await user.keyboard("1"); + }); + + // after the keypress, the focus should shift to segment[1] + expect(segments[1]).toHaveAttribute("data-active", "true"); + expect(input).toHaveAttribute("value", "1"); + }); + + it("should be able to erase the input", async () => { + render(); + + const input = document.querySelector("[data-slot=input]")!; + const segments = document.querySelectorAll("[data-slot=segment]"); + + expect(segments.length).toBe(4); + + // clicking on the component and typing in "12" + await act(async () => { + await user.click(input); + await user.keyboard("1"); + await user.keyboard("2"); + }); + + // value should be "12" and segement[2] should be active + expect(input).toHaveAttribute("value", "12"); + expect(segments[2]).toHaveAttribute("data-active", "true"); + + // removing the data by pressing backspace + await act(async () => { + await user.keyboard("[BackSpace]"); + }); + + // after one Backspace keypress, the value should be "1" and segment[1] should be active + expect(input).toHaveAttribute("value", "1"); + expect(segments[1]).toHaveAttribute("data-active", "true"); + }); + + it("should be able to paste value", async () => { + render(); + + const input = document.querySelector("[data-slot=input]")!; + const segments = document.querySelectorAll("[data-slot=segment]"); + + expect(segments.length).toBe(4); + + // clicking on the component and pasting in "1234" + await act(async () => { + await user.click(input); + await user.paste("1234"); + }); + + // value should be "1234" + expect(input).toHaveAttribute("value", "1234"); + }); + + it("should not take non-allowed inputs", async () => { + render(); + + const input = document.querySelector("[data-slot=input]")!; + const segments = document.querySelectorAll("[data-slot=segment]"); + + expect(segments.length).toBe(4); + + // clicking on the component and typing the unallowed letter (here, "a") + await act(async () => { + await user.click(input); + await user.keyboard("a"); + }); + + // since unallowed letter was typed, "value" should remain empty and segment[0] remains active + expect(segments[0]).toHaveAttribute("data-active", "true"); + expect(input).toHaveAttribute("value", ""); + }); + + it("should allow inputs based on custom regex", async () => { + // below exp matches with chars from small "a" to small "z" + const regEx = "^[a-z]*$"; + + render(); + + const input = document.querySelector("[data-slot=input]")!; + const segments = document.querySelectorAll("[data-slot=segment]"); + + expect(segments.length).toBe(4); + + // clicking on the component and typing the "a" letter + await act(async () => { + await user.click(input); + await user.keyboard("a"); + }); + + expect(segments[1]).toHaveAttribute("data-active", "true"); + expect(input).toHaveAttribute("value", "a"); + }); + + it("should call onFill callback when inputOtp is completely filled", async () => { + const onFill = jest.fn(); + + render(); + + const input = document.querySelector("[data-slot=input]")!; + const segments = document.querySelectorAll("[data-slot=segment]"); + + expect(segments.length).toBe(4); + + // clicking on the component and pasting "1234" + await act(async () => { + await user.click(input); + await user.paste("1234"); + }); + + expect(onFill).toHaveBeenCalledTimes(1); + }); +}); + +describe("InputOtp with react hook form", () => { + let inputOtp1: Element; + let inputOtp2: Element; + let inputOtp3: Element; + let submitButton: HTMLButtonElement; + let onSubmit: () => void; + let user: UserEvent; + + beforeAll(() => { + user = userEvent.setup(); + }); + + beforeEach(() => { + const {result} = renderHook(() => + useForm({ + defaultValues: { + defaultValue: "1234", + withoutDefaultValue: "", + requiredField: "", + }, + }), + ); + + const { + handleSubmit, + register, + formState: {errors}, + } = result.current; + + onSubmit = jest.fn(); + + render( +
+ + + + {errors.requiredField && This field is required} + + , + ); + + inputOtp1 = document.querySelectorAll("[data-slot=input]")[0]!; + inputOtp2 = document.querySelectorAll("[data-slot=input]")[1]!; + inputOtp3 = document.querySelectorAll("[data-slot=input]")[2]!; + submitButton = document.querySelector("button")!; + }); + + it("should work with defaultValues", () => { + expect(inputOtp1).toHaveValue("1234"); + expect(inputOtp2).toHaveValue(""); + expect(inputOtp3).toHaveValue(""); + }); + + it("should not submit form when required field is empty", async () => { + await user.click(submitButton); + + expect(onSubmit).toHaveBeenCalledTimes(0); + }); + + it("should submit form when required field is not empty", async () => { + await user.type(inputOtp3, "1234"); + + await user.click(submitButton); + + expect(onSubmit).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/components/input-otp/package.json b/packages/components/input-otp/package.json new file mode 100644 index 0000000000..6db6335717 --- /dev/null +++ b/packages/components/input-otp/package.json @@ -0,0 +1,61 @@ +{ + "name": "@nextui-org/input-otp", + "version": "2.0.0", + "description": "", + "keywords": [ + "input-otp" + ], + "author": "Junior Garcia ", + "homepage": "https://nextui.org", + "license": "MIT", + "main": "src/index.ts", + "sideEffects": false, + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/nextui-org/nextui.git", + "directory": "packages/components/input-otp" + }, + "bugs": { + "url": "https://github.com/nextui-org/nextui/issues" + }, + "scripts": { + "build": "tsup src --dts", + "build:fast": "tsup src", + "dev": "pnpm build:fast --watch", + "clean": "rimraf dist .turbo", + "typecheck": "tsc --noEmit", + "prepack": "clean-package", + "postpack": "clean-package restore" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18", + "@nextui-org/theme": ">=2.0.0", + "@nextui-org/system": ">=2.0.0" + }, + "dependencies": { + "@nextui-org/shared-utils": "workspace:*", + "@nextui-org/react-utils": "workspace:*", + "@nextui-org/use-safe-layout-effect": "workspace:*", + "@react-aria/focus": "3.17.1", + "@react-aria/utils": "3.24.1", + "@react-stately/utils": "3.10.1", + "@react-types/textfield": "3.9.3", + "@react-aria/textfield": "3.14.5" + }, + "devDependencies": { + "@nextui-org/theme": "workspace:*", + "@nextui-org/system": "workspace:*", + "clean-package": "2.2.0", + "react": "^18.0.0", + "react-dom": "^18.0.0", + "react-hook-form": "^7.51.3" + }, + "clean-package": "../../../clean-package.config.json" +} diff --git a/packages/components/input-otp/src/index.ts b/packages/components/input-otp/src/index.ts new file mode 100644 index 0000000000..3d5502027c --- /dev/null +++ b/packages/components/input-otp/src/index.ts @@ -0,0 +1,10 @@ +import InputOtp from "./input-otp"; + +// export types +export type {InputOtpProps} from "./input-otp"; + +// export hooks +export {useInputOtp} from "./use-input-otp"; + +// export component +export {InputOtp}; diff --git a/packages/components/input-otp/src/input-otp-context.ts b/packages/components/input-otp/src/input-otp-context.ts new file mode 100644 index 0000000000..18779ac3d4 --- /dev/null +++ b/packages/components/input-otp/src/input-otp-context.ts @@ -0,0 +1,9 @@ +import {createContext} from "@nextui-org/react-utils"; + +import {UseInputOtpReturn} from "./use-input-otp"; + +export const [InputOtpProvider, useInputOtpContext] = createContext({ + name: "InputOtpContext", + errorMessage: + "useInputOtpContext: `context` is undefined. Seems like you forgot to wrap all input-otp components within ``", +}); diff --git a/packages/components/input-otp/src/input-otp-segment.tsx b/packages/components/input-otp/src/input-otp-segment.tsx new file mode 100644 index 0000000000..2beda82545 --- /dev/null +++ b/packages/components/input-otp/src/input-otp-segment.tsx @@ -0,0 +1,52 @@ +import {clsx, dataAttr} from "@nextui-org/shared-utils"; +import {HTMLNextUIProps} from "@nextui-org/system"; +import {useMemo} from "react"; + +import {useInputOtpContext} from "./input-otp-context"; + +interface InputOtpSegmentProps extends HTMLNextUIProps<"div"> { + accessorIndex: number; +} + +export const InputOtpSegment = ({accessorIndex}: InputOtpSegmentProps) => { + const {length, value, isInputFocused, classNames, slots, type} = useInputOtpContext(); + + const isActive = useMemo( + () => + (value.length == accessorIndex || (value.length == length && accessorIndex == length - 1)) && + isInputFocused, + [value, isInputFocused], + ); + const hasValue = useMemo(() => value.length > accessorIndex, [value, accessorIndex]); + + const segmentStyles = clsx(classNames?.segment); + const caretStyles = clsx(classNames?.caret); + const passwordCharStyles = clsx(classNames?.passwordChar); + + const displayValue = useMemo(() => { + if (hasValue && type == "password") { + return
; + } + + if (hasValue) { + return value[accessorIndex]; + } + + if (isActive) { + return
; + } + + return null; + }, [type, hasValue, value, isActive]); + + return ( +
+ {displayValue} +
+ ); +}; diff --git a/packages/components/input-otp/src/input-otp.tsx b/packages/components/input-otp/src/input-otp.tsx new file mode 100644 index 0000000000..e92f5a401f --- /dev/null +++ b/packages/components/input-otp/src/input-otp.tsx @@ -0,0 +1,86 @@ +import {forwardRef} from "@nextui-org/system"; +import {useMemo} from "react"; + +import {UseInputOtpProps, useInputOtp} from "./use-input-otp"; +import {InputOtpSegment} from "./input-otp-segment"; +import {InputOtpProvider} from "./input-otp-context"; + +export interface InputOtpProps extends UseInputOtpProps {} + +const InputOtp = forwardRef<"div", InputOtpProps>((props, ref) => { + const context = useInputOtp({...props, ref}); + + const { + Component, + length, + hasHelper, + isInvalid, + errorMessage, + description, + getBaseProps, + getInputWrapperProps, + getInputProps, + getSegmentWrapperProps, + getHelperWrapperProps, + getErrorMessageProps, + getDescriptionProps, + } = context; + + const segmentsSection = useMemo(() => { + return ( +
+ {Array.from(Array(length)).map((_, idx) => ( + + ))} +
+ ); + }, [length, getSegmentWrapperProps]); + + const inputSection = useMemo(() => { + return ( +
+ +
+ ); + }, [getInputWrapperProps, getInputProps]); + + const helperSection = useMemo(() => { + if (!hasHelper) { + return null; + } + + return ( +
+ {isInvalid && errorMessage ? ( +
{errorMessage}
+ ) : ( +
{description}
+ )} +
+ ); + }, [ + hasHelper, + isInvalid, + errorMessage, + description, + getHelperWrapperProps, + getErrorMessageProps, + getDescriptionProps, + ]); + + return ( + + +
+ {segmentsSection} + {inputSection} + {helperSection} +
+
+
+ ); +}); + +InputOtp.displayName = "NextUI.InputOtp"; + +export default InputOtp; diff --git a/packages/components/input-otp/src/use-input-otp.ts b/packages/components/input-otp/src/use-input-otp.ts new file mode 100644 index 0000000000..3c74c2872b --- /dev/null +++ b/packages/components/input-otp/src/use-input-otp.ts @@ -0,0 +1,365 @@ +import type { + InputOtpReturnType, + InputOtpSlots, + InputOtpVariantProps, + SlotsToClasses, +} from "@nextui-org/theme"; + +import { + HTMLNextUIProps, + mapPropsVariants, + PropGetter, + useProviderContext, +} from "@nextui-org/system"; +import {inputOtp} from "@nextui-org/theme"; +import {filterDOMProps, ReactRef, useDOMRef} from "@nextui-org/react-utils"; +import {clsx, dataAttr, isEmpty, objectToDeps, safeAriaLabel} from "@nextui-org/shared-utils"; +import {useCallback, useMemo} from "react"; +import {useFocusRing} from "@react-aria/focus"; +import {mergeProps} from "@react-aria/utils"; +import {useHover} from "@react-aria/interactions"; +import {AriaTextFieldProps} from "@react-types/textfield"; +import {AriaTextFieldOptions, useTextField} from "@react-aria/textfield"; +import {useControlledState} from "@react-stately/utils"; +import {useSafeLayoutEffect} from "@nextui-org/use-safe-layout-effect"; + +interface Props extends HTMLNextUIProps<"div"> { + /** + * Ref to the DOM node. + */ + ref?: ReactRef; + /** + * Ref to the container DOM node. + */ + baseRef?: ReactRef; + /** + * Length required for the otp. + */ + length: number; + /** + * Regex string for the allowed keys. + */ + allowedKeys?: string; + /** + * Callback that will be fired when the value has length equal to otp length + */ + onFill?: (v?: string) => void; + /** + * Boolean to disable the input-otp component. + */ + isDisabled?: boolean; + /** + * Boolean to disable the animation in input-otp component. + */ + disableAnimation?: boolean; + /** + * Classname or List of classes to change the classNames of the element. + * if `className` is passed, it will be added to the base slot. + * + * @example + * ```ts + * + * ``` + */ + classNames?: SlotsToClasses; + /** + * React aria onChange event. + */ + onValueChange?: (value: string) => void; +} + +export type ValueTypes = { + slots: InputOtpReturnType; + classNames: SlotsToClasses; +}; + +export type UseInputOtpProps = Props & InputOtpVariantProps & Omit; + +export function useInputOtp(originalProps: UseInputOtpProps) { + const globalContext = useProviderContext(); + const [props, variantProps] = mapPropsVariants(originalProps, inputOtp.variantKeys); + + const { + ref, + baseRef, + as, + className, + classNames, + length = 4, + onFill = () => {}, + onValueChange = () => {}, + allowedKeys = "^[0-9]*$", + validationBehavior = globalContext?.validationBehavior ?? "aria", + type, + ...otherProps + } = props; + + const Component = as || "div"; + + const inputRef = useDOMRef(ref); + const baseDomRef = useDOMRef(baseRef); + + const handleValueChange = useCallback( + (value: string | undefined) => { + onValueChange(value ?? ""); + if (value && value?.length === length) { + onFill(value); + } + }, + [onValueChange, onFill, length], + ); + + const [value, setValue] = useControlledState( + props.value, + props.defaultValue ?? "", + handleValueChange, + ); + + const disableAnimation = + originalProps.disableAnimation ?? globalContext?.disableAnimation ?? false; + const isDisabled = originalProps.isDisabled ?? false; + const baseStyles = clsx(classNames?.base, className); + + const {focusProps, isFocused: isInputFocused} = useFocusRing({isTextInput: true}); + const allowedKeysRegex = new RegExp(allowedKeys); + const isFilled = !isEmpty(value); + const {isHovered, hoverProps} = useHover({isDisabled: !!originalProps?.isDisabled}); + + const onKeyDownCapture = (e: React.KeyboardEvent) => { + const key = e.key; + + if (key === "Backspace") { + return; + } + if (key === "ArrowLeft" || key === "ArrowRight") { + e.stopPropagation(); + e.preventDefault(); + + return; + } + if (!allowedKeysRegex.test(key)) { + e.stopPropagation(); + e.preventDefault(); + + return; + } + + return; + }; + + type AutoCapitalize = AriaTextFieldOptions<"input">["autoCapitalize"]; + + const { + inputProps, + isInvalid: isAriaInvalid, + validationErrors, + validationDetails, + descriptionProps, + errorMessageProps, + } = useTextField( + { + ...originalProps, + validationBehavior, + autoCapitalize: originalProps.autoCapitalize as AutoCapitalize, + value: value, + "aria-label": safeAriaLabel( + originalProps["aria-label"], + originalProps.label, + originalProps.placeholder, + ), + inputElementType: "input", + onChange: setValue, + minLength: length, + maxLength: length, + }, + inputRef, + ); + + const isReadOnly = originalProps.isReadOnly ?? false; + const isInvalid = originalProps.isInvalid || isAriaInvalid; + const errorMessage = + typeof props.errorMessage === "function" + ? props.errorMessage({isInvalid, validationErrors, validationDetails}) + : props.errorMessage || validationErrors?.join(" "); + const description = props.description; + const hasHelper = !!description || !!errorMessage; + + const slots = useMemo( + () => + inputOtp({ + ...variantProps, + disableAnimation, + isInvalid, + isReadOnly, + }), + [objectToDeps(variantProps), disableAnimation, isInvalid], + ); + + useSafeLayoutEffect(() => { + if (!inputRef.current) return; + + setValue(inputRef.current.value); + }, [inputRef.current]); + + const getBaseProps: PropGetter = useCallback( + (props = {}) => { + return { + ref: baseDomRef, + className: slots.base({ + class: baseStyles, + }), + onKeyDownCapture: onKeyDownCapture, + "data-slot": "base", + "data-filled": dataAttr(isFilled), + "data-focus": dataAttr(isInputFocused), + "data-hover": dataAttr(isHovered), + "data-disabled": dataAttr(isDisabled), + "data-invalid": dataAttr(isInvalid), + "data-required": dataAttr(originalProps?.isRequired), + "data-readonly": dataAttr(originalProps?.isReadOnly), + ...props, + }; + }, + [baseDomRef, slots, baseStyles, isFilled, isInputFocused, isDisabled], + ); + + const getInputWrapperProps: PropGetter = useCallback( + (props = {}) => { + return { + className: slots.inputWrapper({ + class: clsx(classNames?.inputWrapper, props?.className), + }), + "data-slot": "input-wrapper", + ...mergeProps(otherProps, props), + }; + }, + [slots, classNames?.inputWrapper], + ); + + const getInputProps: PropGetter = useCallback( + (props = {}) => { + return { + ref: inputRef, + className: slots.input({ + class: clsx(classNames?.input, props?.className), + }), + maxLength: length, + minLength: length, + value, + disabled: isDisabled, + ...mergeProps( + focusProps, + hoverProps, + inputProps, + filterDOMProps(otherProps, { + enabled: true, + omitEventNames: new Set(Object.keys(inputProps)), + }), + props, + ), + placeholder: "", + "data-slot": "input", + "data-focus": dataAttr(isInputFocused), + "data-filled": dataAttr(isFilled), + "data-disabled": dataAttr(isDisabled), + }; + }, + [ + inputRef, + slots, + classNames?.input, + length, + value, + isDisabled, + setValue, + isInputFocused, + isFilled, + ], + ); + + const getSegmentWrapperProps: PropGetter = useCallback( + (props = {}) => { + return { + className: slots.segmentWrapper({ + class: clsx(classNames?.segmentWrapper, props?.className), + }), + "data-slot": "segment-wrapper", + "data-disabled": dataAttr(isDisabled), + ...props, + }; + }, + [slots, classNames?.segmentWrapper, isDisabled], + ); + + const getHelperWrapperProps: PropGetter = useCallback( + (props = {}) => { + return { + className: slots.helperWrapper({ + class: clsx(classNames?.helperWrapper, props?.className), + }), + "data-slot": "helper-wrapper", + ...props, + }; + }, + [slots, classNames?.helperWrapper], + ); + + const getErrorMessageProps: PropGetter = useCallback( + (props = {}) => { + return { + className: slots.errorMessage({ + class: clsx(classNames?.errorMessage, props?.className), + }), + "data-slot": "error-message", + ...mergeProps(errorMessageProps, props), + }; + }, + [slots, classNames?.errorMessage], + ); + + const getDescriptionProps: PropGetter = useCallback( + (props = {}) => { + return { + className: slots.description({ + class: clsx(classNames?.description, props?.className), + }), + "data-slot": "description", + ...mergeProps(descriptionProps, props), + }; + }, + [slots, classNames?.description], + ); + + return { + Component, + inputRef, + length, + value, + isInputFocused, + classNames, + slots, + hasHelper, + isInvalid, + description, + errorMessage, + type, + getBaseProps, + getInputWrapperProps, + getInputProps, + getSegmentWrapperProps, + getHelperWrapperProps, + getErrorMessageProps, + getDescriptionProps, + }; +} + +export type UseInputOtpReturn = ReturnType; diff --git a/packages/components/input-otp/stories/input-otp.stories.tsx b/packages/components/input-otp/stories/input-otp.stories.tsx new file mode 100644 index 0000000000..b149379f07 --- /dev/null +++ b/packages/components/input-otp/stories/input-otp.stories.tsx @@ -0,0 +1,247 @@ +import React from "react"; +import {Meta} from "@storybook/react"; +import {button, inputOtp} from "@nextui-org/theme"; +import {useForm} from "react-hook-form"; +import {ValidationResult} from "@react-types/shared"; + +import {InputOtp} from "../src"; + +export default { + title: "Components/InputOtp", + component: InputOtp, + argTypes: { + variant: { + control: {type: "select"}, + options: ["flat", "faded", "bordered", "underlined"], + }, + color: { + control: {type: "select"}, + options: ["default", "primary", "secondary", "success", "warning", "danger"], + }, + radius: { + control: {type: "select"}, + options: ["none", "sm", "md", "lg", "full"], + }, + size: { + control: {type: "select"}, + options: ["sm", "md", "lg"], + }, + isDisabled: { + control: { + type: "boolean", + }, + }, + validationBehavior: { + control: { + type: "select", + }, + options: ["aria", "native"], + }, + }, +} as Meta; + +const defaultProps = { + ...inputOtp.defaultVariants, +}; + +const Template = (args) => ( +
+ +
+); + +const RequiredTemplate = (args) => { + const { + register, + formState: {errors}, + handleSubmit, + } = useForm({ + defaultValues: { + otp: "", + }, + }); + + const onSubmit = (data: any) => { + // eslint-disable-next-line no-console + alert("Submitted value: " + JSON.stringify(data)); + }; + + return ( +
+
+ + {errors.otp &&
This field is required
} + + +
+ ); +}; + +const ControlledTemplate = (args) => { + const [value, setValue] = React.useState(""); + + return ( +
+ +

Input value: {value}

+
+ ); +}; + +const WithReactHookFormTemplate = (args) => { + const { + register, + formState: {errors}, + handleSubmit, + } = useForm({ + defaultValues: { + withDefaultValue: "12", + requiredField: "", + }, + }); + + const onSubmit = (data: any) => { + // eslint-disable-next-line no-console + console.log(data); + alert("Submitted value: " + JSON.stringify(data)); + }; + + return ( +
+
+
Default value:
+ +
+
+
Required value:
+ + {errors.requiredField && ( + This field is required + )} +
+ +
+ ); +}; + +export const Default = { + render: Template, + args: { + ...defaultProps, + length: 4, + }, +}; + +export const Required = { + render: RequiredTemplate, + args: { + ...defaultProps, + length: 4, + }, +}; + +export const Disabled = { + render: Template, + args: { + ...defaultProps, + length: 4, + defaultValue: "123", + isDisabled: true, + }, +}; + +export const ReadOnly = { + render: Template, + args: { + ...defaultProps, + length: 4, + value: "12", + isReadOnly: true, + }, +}; + +export const WithDescription = { + render: Template, + args: { + ...defaultProps, + length: 4, + description: "description for the otp component", + }, +}; + +export const WithErrorMessage = { + render: Template, + args: { + ...defaultProps, + length: 4, + isInvalid: true, + errorMessage: "Please enter a valid OTP.", + }, +}; + +export const Password = { + render: Template, + args: { + ...defaultProps, + type: "password", + }, +}; + +export const WithErrorMessageFunction = { + render: WithReactHookFormTemplate, + args: { + ...defaultProps, + length: 4, + isRequired: true, + minLength: 4, + errorMessage: (value: ValidationResult) => { + if (value.validationDetails.tooShort) { + return "Value is too short"; + } + }, + }, +}; + +export const isInvalid = { + render: Template, + args: { + ...defaultProps, + length: 4, + isInvalid: true, + errorMessage: "Invalid OTP", + }, +}; + +export const Controlled = { + render: ControlledTemplate, + args: { + ...defaultProps, + }, +}; + +export const WithReactHookForm = { + render: WithReactHookFormTemplate, + args: { + ...defaultProps, + length: 4, + }, +}; + +export const CustomWithClassNames = { + render: Template, + + args: { + ...defaultProps, + length: 4, + classNames: { + segment: "bg-gradient-to-tr from-pink-500 to-yellow-500", + caret: "bg-red-700", + }, + radius: "md", + description: "custom otp component", + }, +}; diff --git a/packages/components/input-otp/tsconfig.json b/packages/components/input-otp/tsconfig.json new file mode 100644 index 0000000000..5d012f6e61 --- /dev/null +++ b/packages/components/input-otp/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "tailwind-variants": ["../../../node_modules/tailwind-variants"] + }, + }, + "include": ["src", "index.ts"] +} diff --git a/packages/components/input-otp/tsup.config.ts b/packages/components/input-otp/tsup.config.ts new file mode 100644 index 0000000000..3e2bcff6cc --- /dev/null +++ b/packages/components/input-otp/tsup.config.ts @@ -0,0 +1,8 @@ +import {defineConfig} from "tsup"; + +export default defineConfig({ + clean: true, + target: "es2019", + format: ["cjs", "esm"], + banner: {js: '"use client";'}, +}); diff --git a/packages/core/react/package.json b/packages/core/react/package.json index 5513fa10f0..9d2cc0b0d0 100644 --- a/packages/core/react/package.json +++ b/packages/core/react/package.json @@ -62,6 +62,7 @@ "@nextui-org/user": "workspace:*", "@nextui-org/progress": "workspace:*", "@nextui-org/input": "workspace:*", + "@nextui-org/input-otp": "workspace:*", "@nextui-org/popover": "workspace:*", "@nextui-org/dropdown": "workspace:*", "@nextui-org/image": "workspace:*", diff --git a/packages/core/react/src/index.ts b/packages/core/react/src/index.ts index cdaab4d346..038933be97 100644 --- a/packages/core/react/src/index.ts +++ b/packages/core/react/src/index.ts @@ -45,6 +45,7 @@ export * from "@nextui-org/date-input"; export * from "@nextui-org/date-picker"; export * from "@nextui-org/alert"; export * from "@nextui-org/drawer"; +export * from "@nextui-org/input-otp"; /** * React Aria - Exports diff --git a/packages/core/theme/src/components/index.ts b/packages/core/theme/src/components/index.ts index bd7aa182b2..20be0fb2db 100644 --- a/packages/core/theme/src/components/index.ts +++ b/packages/core/theme/src/components/index.ts @@ -17,6 +17,7 @@ export * from "./toggle"; export * from "./accordion"; export * from "./progress"; export * from "./circular-progress"; +export * from "./input-otp"; export * from "./input"; export * from "./dropdown"; export * from "./image"; diff --git a/packages/core/theme/src/components/input-otp.ts b/packages/core/theme/src/components/input-otp.ts new file mode 100644 index 0000000000..e448f77c05 --- /dev/null +++ b/packages/core/theme/src/components/input-otp.ts @@ -0,0 +1,458 @@ +import type {VariantProps} from "tailwind-variants"; + +import {tv} from "../utils/tv"; + +const inputOtp = tv({ + slots: { + base: ["relative", "flex", "flex-col", "w-fit"], + inputWrapper: [], + input: [ + "absolute", + "inset-0", + "border-none", + "outline-none", + "bg-transparent", + "text-transparent", + ], + segmentWrapper: ["inline-flex", "gap-x-1", "py-2"], + segment: [ + "h-10", + "w-10", + "font-semibold", + "flex", + "justify-center", + "items-center", + "border-default-200", + "data-[active=true]:border-default-400", + "data-[active=true]:scale-110", + ], + passwordChar: ["w-1", "h-1", "bg-default-800", "rounded-full"], + caret: [ + "animate-[appearance-in_1s_infinite]", + "font-extralight", + "h-full", + "w-full", + "flex", + "justify-center", + "items-center", + "text-2xl", + "h-[50%]", + "w-px", + "bg-white", + ], + helperWrapper: ["text-xs", "mt-0.5", "font-extralight", ""], + errorMessage: ["text-tiny text-danger w-full"], + description: ["text-tiny text-foreground-400"], + }, + variants: { + variant: { + flat: { + segment: ["border-none", "bg-default-100", "data-[active=true]:bg-default-200"], + }, + faded: { + segment: ["bg-default-100", "border-1", "data-[active=true]:border-2"], + }, + bordered: { + segment: ["border-1", "data-[active=true]:border-2"], + }, + underlined: { + segment: ["border-b-1", "data-[active=true]:border-b-2", "data-[active=true]:scale-100"], + }, + }, + disableAnimation: { + true: { + segment: "transition-none", + }, + false: { + segment: "transition duration-150", + }, + }, + isDisabled: { + true: { + segment: "opacity-disabled pointer-events-none", + input: "pointer-events-none", + }, + }, + isInvalid: { + true: {}, + }, + isReadOnly: { + true: { + caret: "bg-transparent", + segment: "transition-none data-[active=true]:scale-100", + }, + }, + fullWidth: { + true: { + base: "w-full", + }, + }, + radius: { + none: { + segment: "rounded-none", + }, + sm: { + segment: "rounded-sm", + }, + md: { + segment: "rounded-md", + }, + lg: { + segment: "rounded-lg", + }, + full: { + segment: "rounded-full", + }, + }, + color: { + default: {}, + primary: {}, + secondary: {}, + success: {}, + warning: {}, + danger: {}, + }, + size: { + sm: { + segment: "h-8 min-h-8 w-8 min-w-8 text-small", + }, + md: { + segment: "h-10 min-h-10 w-10 min-w-10 text-small", + }, + lg: { + segment: "h-12 min-h-12 w-12 min-w-12 text-medium", + }, + }, + }, + defaultVariants: { + variant: "flat", + color: "default", + }, + compoundVariants: [ + // flat & color + { + variant: "flat", + color: "default", + class: { + segment: "data-[has-value=true]:text-default-foreground", + }, + }, + { + variant: "flat", + color: "primary", + class: { + segment: ["bg-primary-50", "data-[active=true]:bg-primary-100", "text-primary"], + caret: ["bg-primary"], + passwordChar: ["bg-primary"], + }, + }, + { + variant: "flat", + color: "secondary", + class: { + segment: ["bg-secondary-50", "data-[active=true]:bg-secondary-100", "text-secondary"], + caret: ["bg-secondary"], + passwordChar: ["bg-secondary"], + }, + }, + { + variant: "flat", + color: "success", + class: { + segment: ["bg-success-50", "data-[active=true]:bg-success-100", "text-success"], + caret: ["bg-success"], + passwordChar: ["bg-success"], + }, + }, + { + variant: "flat", + color: "warning", + class: { + segment: ["bg-warning-50", "data-[active=true]:bg-warning-100", "text-warning"], + caret: ["bg-warning"], + passwordChar: ["bg-warning"], + }, + }, + { + variant: "flat", + color: "danger", + class: { + segment: ["bg-danger-50", "data-[active=true]:bg-danger-100", "text-danger"], + caret: ["bg-danger"], + passwordChar: ["bg-danger"], + }, + }, + // faded & color + { + variant: "faded", + color: "default", + class: { + segment: "data-[has-value=true]:text-default-foreground", + }, + }, + { + variant: "faded", + color: "primary", + class: { + segment: [ + "bg-primary-50", + "text-primary", + "border-1", + "border-primary-200", + "data-[active=true]:border-2", + "data-[active=true]:border-primary-400", + ], + caret: ["bg-primary"], + passwordChar: ["bg-primary"], + }, + }, + { + variant: "faded", + color: "secondary", + class: { + segment: [ + "bg-secondary-50", + "text-secondary", + "border-1", + "border-secondary-200", + "data-[active=true]:border-2", + "data-[active=true]:border-secondary-400", + ], + caret: ["bg-secondary"], + passwordChar: ["bg-secondary"], + }, + }, + { + variant: "faded", + color: "success", + class: { + segment: [ + "bg-success-50", + "text-success", + "border-1", + "border-success-200", + "data-[active=true]:border-2", + "data-[active=true]:border-success-400", + ], + caret: ["bg-success"], + passwordChar: ["bg-success"], + }, + }, + { + variant: "faded", + color: "warning", + class: { + segment: [ + "bg-warning-50", + "text-warning", + "border-1", + "border-warning-200", + "data-[active=true]:border-2", + "data-[active=true]:border-warning-400", + ], + caret: ["bg-warning"], + passwordChar: ["bg-warning"], + }, + }, + { + variant: "faded", + color: "danger", + class: { + segment: [ + "bg-danger-50", + "text-danger", + "border-1", + "border-danger-200", + "data-[active=true]:border-2", + "data-[active=true]:border-danger-400", + ], + caret: ["bg-danger"], + passwordChar: ["bg-danger"], + }, + }, + // bordered & color + { + variant: "bordered", + color: "default", + class: { + segment: "data-[has-value=true]:text-default-foreground", + }, + }, + { + variant: "bordered", + color: "primary", + class: { + segment: ["border-primary-200", "text-primary", "data-[active=true]:border-primary-400"], + caret: ["bg-primary"], + passwordChar: ["bg-primary"], + }, + }, + { + variant: "bordered", + color: "secondary", + class: { + segment: [ + "border-secondary-200", + "text-secondary", + "data-[active=true]:border-secondary-400", + ], + caret: ["bg-secondary"], + passwordChar: ["bg-secondary"], + }, + }, + { + variant: "bordered", + color: "success", + class: { + segment: ["border-success-200", "text-success", "data-[active=true]:border-success-400"], + caret: ["bg-success"], + passwordChar: ["bg-success"], + }, + }, + { + variant: "bordered", + color: "warning", + class: { + segment: ["border-warning-200", "text-warning", "data-[active=true]:border-warning-400"], + caret: ["bg-warning"], + passwordChar: ["bg-warning"], + }, + }, + { + variant: "bordered", + color: "danger", + class: { + segment: ["border-danger-200", "text-danger", "data-[active=true]:border-danger-400"], + caret: ["bg-danger"], + passwordChar: ["bg-danger"], + }, + }, + // underlined & color + { + variant: "underlined", + color: "default", + class: { + segment: "data-[has-value=true]:text-default-foreground rounded-none", + }, + }, + { + variant: "underlined", + color: "primary", + class: { + segment: [ + "border-primary-200", + "text-primary", + "data-[active=true]:border-primary-400", + "rounded-none", + ], + caret: ["bg-primary"], + passwordChar: ["bg-primary"], + }, + }, + { + variant: "underlined", + color: "secondary", + class: { + segment: [ + "border-secondary-200", + "text-secondary", + "data-[active=true]:border-secondary-400", + "rounded-none", + ], + caret: ["bg-secondary"], + passwordChar: ["bg-secondary"], + }, + }, + { + variant: "underlined", + color: "success", + class: { + segment: [ + "border-success-200", + "text-success", + "data-[active=true]:border-success-400", + "rounded-none", + ], + caret: ["bg-success"], + passwordChar: ["bg-success"], + }, + }, + { + variant: "underlined", + color: "warning", + class: { + segment: [ + "border-warning-200", + "text-warning", + "data-[active=true]:border-warning-400", + "rounded-none", + ], + caret: ["bg-warning"], + passwordChar: ["bg-warning"], + }, + }, + { + variant: "underlined", + color: "danger", + class: { + segment: [ + "border-danger-200", + "text-danger", + "data-[active=true]:border-danger-400", + "rounded-none", + ], + caret: ["bg-danger"], + passwordChar: ["bg-danger"], + }, + }, + // isInvalid and flat + { + variant: "flat", + isInvalid: true, + class: { + segment: ["bg-danger-50", "data-[active=true]:bg-danger-100", "text-danger"], + caret: ["bg-danger"], + }, + }, + // isInvalid and faded + { + variant: "faded", + isInvalid: true, + class: { + segment: [ + "bg-danger-50", + "text-danger", + "border-1", + "border-danger-200", + "data-[active=true]:border-2", + "data-[active=true]:border-danger-400", + ], + caret: ["bg-danger"], + }, + }, + // isInvalid and bordered + { + variant: "bordered", + isInvalid: true, + class: { + segment: ["border-danger-200", "text-danger", "data-[active=true]:border-danger-400"], + caret: ["bg-danger"], + }, + }, + // isInvalid anf underlined + { + variant: "underlined", + isInvalid: true, + class: { + segment: ["border-danger-200", "text-danger", "data-[active=true]:border-danger-400"], + caret: ["bg-danger"], + }, + }, + ], +}); + +export type InputOtpVariantProps = VariantProps; +export type InputOtpSlots = keyof ReturnType; +export type InputOtpReturnType = ReturnType; + +export {inputOtp}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 25fe5137e6..c0846ce9eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -291,6 +291,9 @@ importers: '@nextui-org/divider': specifier: workspace:* version: link:../../packages/components/divider + '@nextui-org/input-otp': + specifier: workspace:* + version: link:../../packages/components/input-otp '@nextui-org/kbd': specifier: workspace:* version: link:../../packages/components/kbd @@ -1662,6 +1665,52 @@ importers: specifier: ^7.51.3 version: 7.53.1(react@18.3.1) + packages/components/input-otp: + dependencies: + '@nextui-org/react-utils': + specifier: workspace:* + version: link:../../utilities/react-utils + '@nextui-org/shared-utils': + specifier: workspace:* + version: link:../../utilities/shared-utils + '@nextui-org/use-safe-layout-effect': + specifier: workspace:* + version: link:../../hooks/use-safe-layout-effect + '@react-aria/focus': + specifier: 3.17.1 + version: 3.17.1(react@18.2.0) + '@react-aria/textfield': + specifier: 3.14.5 + version: 3.14.5(react@18.2.0) + '@react-aria/utils': + specifier: 3.24.1 + version: 3.24.1(react@18.2.0) + '@react-stately/utils': + specifier: 3.10.1 + version: 3.10.1(react@18.2.0) + '@react-types/textfield': + specifier: 3.9.3 + version: 3.9.3(react@18.2.0) + devDependencies: + '@nextui-org/system': + specifier: workspace:* + version: link:../../core/system + '@nextui-org/theme': + specifier: workspace:* + version: link:../../core/theme + clean-package: + specifier: 2.2.0 + version: 2.2.0 + react: + specifier: ^18.2.0 + version: 18.2.0 + react-dom: + specifier: ^18.2.0 + version: 18.2.0(react@18.2.0) + react-hook-form: + specifier: ^7.51.3 + version: 7.51.3(react@18.2.0) + packages/components/kbd: dependencies: '@nextui-org/react-utils': @@ -2995,6 +3044,9 @@ importers: '@nextui-org/input': specifier: workspace:* version: link:../../components/input + '@nextui-org/input-otp': + specifier: workspace:* + version: link:../../components/input-otp '@nextui-org/kbd': specifier: workspace:* version: link:../../components/kbd From 9385097c8af388e897c7eabc80ccfe2f4f221ef5 Mon Sep 17 00:00:00 2001 From: Maharshi Alpesh Date: Wed, 13 Nov 2024 23:17:48 +0530 Subject: [PATCH 2/2] fix(input-otp): making the use of input-otp library --- .../components/input-otp/usage.raw.jsx | 17 +- .../content/docs/components/input-otp.mdx | 11 +- .../input-otp/__tests__/input-otp.test.tsx | 249 ++++++------------ packages/components/input-otp/package.json | 6 +- .../input-otp/src/input-otp-segment.tsx | 50 ++-- .../components/input-otp/src/input-otp.tsx | 50 ++-- .../components/input-otp/src/use-input-otp.ts | 175 ++++-------- .../input-otp/stories/input-otp.stories.tsx | 42 ++- .../core/theme/src/components/input-otp.ts | 32 +-- pnpm-lock.yaml | 84 +++--- 10 files changed, 263 insertions(+), 453 deletions(-) diff --git a/apps/docs/content/components/input-otp/usage.raw.jsx b/apps/docs/content/components/input-otp/usage.raw.jsx index b51499795f..c94601370f 100644 --- a/apps/docs/content/components/input-otp/usage.raw.jsx +++ b/apps/docs/content/components/input-otp/usage.raw.jsx @@ -1,19 +1,14 @@ -import {InputOtp, Button} from "@nextui-org/react"; +import {InputOtp} from "@nextui-org/react"; export default function App() { const [value, setValue] = React.useState(""); - const handleSubmit = (e) => { - e.preventDefault(); - alert("Submitted OTP: " + value); - }; - return ( -
+
- - +
+ OTP value: {value} +
+
); } diff --git a/apps/docs/content/docs/components/input-otp.mdx b/apps/docs/content/docs/components/input-otp.mdx index 94efd4464a..e1478727a7 100644 --- a/apps/docs/content/docs/components/input-otp.mdx +++ b/apps/docs/content/docs/components/input-otp.mdx @@ -115,7 +115,7 @@ Custom error message of the `input-otp` can be set by `errorMessage` property. ## Slots - **base**: InputOtp wrapper, it handles alignment, placement, and general appearance. -- **inputWrapper**: Wraps the underlying `input` element of the InputOtp. +- **wrapper**: Wraps the underlying input-otp component. Sent as `containerClassName` prop to underlying input-otp component. - **input**: The input element. - **segmentWrapper**: Wraps all the segment elements. - **segment**: The segment element. @@ -137,10 +137,6 @@ Custom error message of the `input-otp` can be set by `errorMessage` property. When the input-otp is required. Based on `isRequired` prop. - **data-readonly**: When the input-otp is readonly. Based on `isReadOnly` prop. -- **data-hover**: - When the input-otp is being hovered. Based on [useHover](https://react-spectrum.adobe.com/react-aria/useHover.html) -- **data-focus**: - When the input-otp is being focused. Based on [useFocusRing](https://react-spectrum.adobe.com/react-aria/useFocusRing.html). - **data-filled**: When the input-otp is completely filled. - **data-disabled**: @@ -150,7 +146,7 @@ Custom error message of the `input-otp` can be set by `errorMessage` property. ## Accessibility -- Built with a native `` element. +- Built on top of [input-otp](https://github.com/guilhermerodz/input-otp). - Required and invalid states exposed to assistive technology via ARIA. - Support for description and error message help text linked to the input-otp via ARIA. @@ -178,7 +174,6 @@ Custom error message of the `input-otp` can be set by `errorMessage` property. | isDisabled | `boolean` | Whether the input-otp is disabled. | `false` | | isInvalid | `boolean` | Whether the input-otp is invalid. | `false` | | baseRef | `RefObject` | The ref to the base element. | - | -| validationState | `valid` \| `invalid` | Whether the input-otp should display its "valid" or "invalid" visual styling. (**Deprecated**) use **isInvalid** instead. | - | | disableAnimation | `boolean` | Whether the input-otp should be animated. | `false` | | classNames | `Record<"base"| "inputWrapper"| "input"| "segmentWrapper"| "segment" | "caret" | "passwordChar" | "helperWrapper" | "description" | "errorMessage", string>` | Allows to set custom class names for the Input slots. | - | @@ -188,4 +183,4 @@ Custom error message of the `input-otp` can be set by `errorMessage` property. | ------------- | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | onChange | `React.ChangeEvent` | Handler that is called when the element's value changes. You can pull out the new value by accessing `event.target.value` (string). | | onValueChange | `(value: string) => void` | Handler that is called when the element's value changes. | -| onFill | `(value: string) => void` | Handler that is called when the element's value is completely filled. | +| onComplete | `(value: string) => void` | Handler that is called when the element's value is completely filled. | diff --git a/packages/components/input-otp/__tests__/input-otp.test.tsx b/packages/components/input-otp/__tests__/input-otp.test.tsx index c9a8e95f2a..fdde22f4d6 100644 --- a/packages/components/input-otp/__tests__/input-otp.test.tsx +++ b/packages/components/input-otp/__tests__/input-otp.test.tsx @@ -1,11 +1,20 @@ import * as React from "react"; -import {act, render, renderHook} from "@testing-library/react"; +import {render, renderHook, screen} from "@testing-library/react"; import {useForm} from "react-hook-form"; import userEvent, {UserEvent} from "@testing-library/user-event"; import {InputOtp} from "../src"; -describe("InputOtp", () => { +// Mock document.elementFromPoint to avoid test environment errors +beforeAll(() => { + document.elementFromPoint = jest.fn(() => { + const mockElement = document.createElement("div"); + + return mockElement; + }); +}); + +describe("InputOtp Component", () => { let user: UserEvent; beforeAll(() => { @@ -18,228 +27,131 @@ describe("InputOtp", () => { expect(() => wrapper.unmount()).not.toThrow(); }); - it("ref should be forwarded", () => { + it("should forward ref correctly", () => { const ref = React.createRef(); render(); expect(ref.current).not.toBeNull(); }); - it("should have length according to the prop", async () => { + it("should create segments according to length prop", () => { render(); - const segments = document.querySelectorAll("[data-slot=segment]"); + const segments = screen.getAllByRole("presentation"); expect(segments.length).toBe(5); }); - it("should display error message", async () => { + it("should display error message when isInvalid is true", () => { const errorMessage = "custom error message"; render(); - const base = document.querySelector("[data-slot=base]")!; - - expect(base).toHaveTextContent(errorMessage); + expect(screen.getByText(errorMessage)).toBeInTheDocument(); }); - it("should display description message", async () => { + it("should display description message", () => { const descriptionMessage = "custom description message"; render(); - const base = document.querySelector("[data-slot=base]")!; - - expect(base).toHaveTextContent(descriptionMessage); + expect(screen.getByText(descriptionMessage)).toBeInTheDocument(); }); - it("should not focus on disabled", async () => { + it("should not focus when disabled", async () => { render(); - const input = document.querySelector("[data-slot=input]")!; - - await act(async () => { - await user.click(input); - }); + const input = screen.getByRole("textbox"); + await user.click(input); expect(input).not.toHaveAttribute("data-focus", "true"); }); - it("should select first segment when clicked", async () => { + it("should activate the first segment on click", async () => { render(); - const base = document.querySelector("[data-slot=base]")!; - const input = document.querySelector("[data-slot=input]")!; - const segments = document.querySelectorAll("[data-slot=segment]"); - - expect(segments.length).toBe(4); - - await act(async () => { - await user.click(input); - }); - - expect(base).toHaveAttribute("data-focus", "true"); - expect(input).toHaveAttribute("data-focus", "true"); + const input = screen.getByRole("textbox"); + const segments = screen.getAllByRole("presentation"); + await user.click(input); expect(segments[0]).toHaveAttribute("data-active", "true"); - expect(segments[1].getAttribute("data-active")).toBe(null); - expect(segments[2].getAttribute("data-active")).toBe(null); - expect(segments[3].getAttribute("data-active")).toBe(null); - }); - - it("should not be focused when disabled", async () => { - render(); - const input = document.querySelector("[data-slot=input]")!; - - await act(async () => { - await user.click(input); - }); - - expect(input).toBeDisabled(); + expect(segments[1]).not.toHaveAttribute("data-active"); }); - it("should shift focus to next segment when valid digit is typed", async () => { + it("should move focus to the next segment on valid input", async () => { render(); + const input = screen.getByRole("textbox"); + const segments = screen.getAllByRole("presentation"); - const base = document.querySelector("[data-slot=base]")!; - const input = document.querySelector("[data-slot=input]")!; - const segments = document.querySelectorAll("[data-slot=segment]"); - - expect(segments.length).toBe(4); - - await act(async () => { - await user.click(input); - }); - - expect(base).toHaveAttribute("data-focus", "true"); - expect(input).toHaveAttribute("data-focus", "true"); - // since no input is entered hence segment[1] will not be active - expect(segments[1].getAttribute("data-active")).toBe(null); - - await act(async () => { - await user.keyboard("1"); - }); + await user.click(input); + expect(segments[1]).not.toHaveAttribute("data-active"); - // after the keypress, the focus should shift to segment[1] + await user.keyboard("1"); expect(segments[1]).toHaveAttribute("data-active", "true"); expect(input).toHaveAttribute("value", "1"); }); - it("should be able to erase the input", async () => { + it("should clear input on backspace", async () => { render(); + const input = screen.getByRole("textbox"); + const segments = screen.getAllByRole("presentation"); - const input = document.querySelector("[data-slot=input]")!; - const segments = document.querySelectorAll("[data-slot=segment]"); - - expect(segments.length).toBe(4); - - // clicking on the component and typing in "12" - await act(async () => { - await user.click(input); - await user.keyboard("1"); - await user.keyboard("2"); - }); - - // value should be "12" and segement[2] should be active + await user.click(input); + await user.keyboard("12"); expect(input).toHaveAttribute("value", "12"); expect(segments[2]).toHaveAttribute("data-active", "true"); - // removing the data by pressing backspace - await act(async () => { - await user.keyboard("[BackSpace]"); - }); - - // after one Backspace keypress, the value should be "1" and segment[1] should be active + await user.keyboard("[Backspace]"); expect(input).toHaveAttribute("value", "1"); expect(segments[1]).toHaveAttribute("data-active", "true"); }); - it("should be able to paste value", async () => { + it("should paste values", async () => { render(); + const input = screen.getByRole("textbox"); - const input = document.querySelector("[data-slot=input]")!; - const segments = document.querySelectorAll("[data-slot=segment]"); - - expect(segments.length).toBe(4); - - // clicking on the component and pasting in "1234" - await act(async () => { - await user.click(input); - await user.paste("1234"); - }); - - // value should be "1234" + await user.click(input); + await user.paste("1234"); expect(input).toHaveAttribute("value", "1234"); }); - it("should not take non-allowed inputs", async () => { + it("should restrict non-allowed inputs", async () => { render(); + const input = screen.getByRole("textbox"); + const segments = screen.getAllByRole("presentation"); - const input = document.querySelector("[data-slot=input]")!; - const segments = document.querySelectorAll("[data-slot=segment]"); - - expect(segments.length).toBe(4); - - // clicking on the component and typing the unallowed letter (here, "a") - await act(async () => { - await user.click(input); - await user.keyboard("a"); - }); - - // since unallowed letter was typed, "value" should remain empty and segment[0] remains active - expect(segments[0]).toHaveAttribute("data-active", "true"); + await user.click(input); + await user.keyboard("a"); expect(input).toHaveAttribute("value", ""); + expect(segments[0]).toHaveAttribute("data-active", "true"); }); it("should allow inputs based on custom regex", async () => { - // below exp matches with chars from small "a" to small "z" const regEx = "^[a-z]*$"; render(); + const input = screen.getByRole("textbox"); - const input = document.querySelector("[data-slot=input]")!; - const segments = document.querySelectorAll("[data-slot=segment]"); - - expect(segments.length).toBe(4); - - // clicking on the component and typing the "a" letter - await act(async () => { - await user.click(input); - await user.keyboard("a"); - }); - - expect(segments[1]).toHaveAttribute("data-active", "true"); + await user.click(input); + await user.keyboard("a"); expect(input).toHaveAttribute("value", "a"); }); - it("should call onFill callback when inputOtp is completely filled", async () => { - const onFill = jest.fn(); - - render(); - - const input = document.querySelector("[data-slot=input]")!; - const segments = document.querySelectorAll("[data-slot=segment]"); + it("should call onComplete when all segments are filled", async () => { + const onComplete = jest.fn(); - expect(segments.length).toBe(4); + render(); + const input = screen.getByRole("textbox"); - // clicking on the component and pasting "1234" - await act(async () => { - await user.click(input); - await user.paste("1234"); - }); - - expect(onFill).toHaveBeenCalledTimes(1); + await user.click(input); + await user.paste("1234"); + expect(onComplete).toHaveBeenCalledTimes(1); }); }); -describe("InputOtp with react hook form", () => { - let inputOtp1: Element; - let inputOtp2: Element; - let inputOtp3: Element; - let submitButton: HTMLButtonElement; - let onSubmit: () => void; +describe("InputOtp with react-hook-form", () => { let user: UserEvent; beforeAll(() => { user = userEvent.setup(); }); - beforeEach(() => { + it("should integrate with react-hook-form correctly", async () => { const {result} = renderHook(() => useForm({ defaultValues: { @@ -255,42 +167,29 @@ describe("InputOtp with react hook form", () => { register, formState: {errors}, } = result.current; - - onSubmit = jest.fn(); + const onSubmit = jest.fn(); render( -
- - - - {errors.requiredField && This field is required} + + + + + {errors.requiredField && This field is required} , ); - inputOtp1 = document.querySelectorAll("[data-slot=input]")[0]!; - inputOtp2 = document.querySelectorAll("[data-slot=input]")[1]!; - inputOtp3 = document.querySelectorAll("[data-slot=input]")[2]!; - submitButton = document.querySelector("button")!; - }); - - it("should work with defaultValues", () => { - expect(inputOtp1).toHaveValue("1234"); - expect(inputOtp2).toHaveValue(""); - expect(inputOtp3).toHaveValue(""); - }); - - it("should not submit form when required field is empty", async () => { - await user.click(submitButton); - + await user.click(screen.getByText(/Submit/i)); expect(onSubmit).toHaveBeenCalledTimes(0); - }); - it("should submit form when required field is not empty", async () => { - await user.type(inputOtp3, "1234"); - - await user.click(submitButton); + const inputOtp3 = screen.getAllByRole("textbox")[2]; + await user.type(inputOtp3, "1234"); + await user.click(screen.getByText(/Submit/i)); expect(onSubmit).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/components/input-otp/package.json b/packages/components/input-otp/package.json index 6db6335717..0008d78683 100644 --- a/packages/components/input-otp/package.json +++ b/packages/components/input-otp/package.json @@ -42,12 +42,12 @@ "dependencies": { "@nextui-org/shared-utils": "workspace:*", "@nextui-org/react-utils": "workspace:*", - "@nextui-org/use-safe-layout-effect": "workspace:*", - "@react-aria/focus": "3.17.1", "@react-aria/utils": "3.24.1", + "@react-aria/form": "3.0.8", "@react-stately/utils": "3.10.1", + "@react-stately/form": "3.0.5", "@react-types/textfield": "3.9.3", - "@react-aria/textfield": "3.14.5" + "input-otp": "1.4.1" }, "devDependencies": { "@nextui-org/theme": "workspace:*", diff --git a/packages/components/input-otp/src/input-otp-segment.tsx b/packages/components/input-otp/src/input-otp-segment.tsx index 2beda82545..9a106f63b4 100644 --- a/packages/components/input-otp/src/input-otp-segment.tsx +++ b/packages/components/input-otp/src/input-otp-segment.tsx @@ -1,50 +1,38 @@ -import {clsx, dataAttr} from "@nextui-org/shared-utils"; -import {HTMLNextUIProps} from "@nextui-org/system"; +import {SlotProps} from "input-otp"; import {useMemo} from "react"; +import {clsx, dataAttr} from "@nextui-org/shared-utils"; import {useInputOtpContext} from "./input-otp-context"; -interface InputOtpSegmentProps extends HTMLNextUIProps<"div"> { - accessorIndex: number; -} +export const InputOtpSegment = (props: SlotProps) => { + const {classNames, slots, type} = useInputOtpContext(); -export const InputOtpSegment = ({accessorIndex}: InputOtpSegmentProps) => { - const {length, value, isInputFocused, classNames, slots, type} = useInputOtpContext(); - - const isActive = useMemo( - () => - (value.length == accessorIndex || (value.length == length && accessorIndex == length - 1)) && - isInputFocused, - [value, isInputFocused], - ); - const hasValue = useMemo(() => value.length > accessorIndex, [value, accessorIndex]); - - const segmentStyles = clsx(classNames?.segment); - const caretStyles = clsx(classNames?.caret); const passwordCharStyles = clsx(classNames?.passwordChar); + const caretStyles = clsx(classNames?.caret); + const segmentStyles = clsx(classNames?.segment); const displayValue = useMemo(() => { - if (hasValue && type == "password") { - return
; - } - - if (hasValue) { - return value[accessorIndex]; - } - - if (isActive) { + if (props.isActive && !props.char) { return
; } + if (props.char) { + return type === "password" ? ( +
+ ) : ( +
{props.char}
+ ); + } - return null; - }, [type, hasValue, value, isActive]); + return
{props.placeholderChar}
; + }, [props.char, props.isActive, type]); return (
{displayValue}
diff --git a/packages/components/input-otp/src/input-otp.tsx b/packages/components/input-otp/src/input-otp.tsx index e92f5a401f..8e8f0cf351 100644 --- a/packages/components/input-otp/src/input-otp.tsx +++ b/packages/components/input-otp/src/input-otp.tsx @@ -1,9 +1,11 @@ import {forwardRef} from "@nextui-org/system"; import {useMemo} from "react"; +import {OTPInput} from "input-otp"; +import {clsx} from "@nextui-org/shared-utils"; import {UseInputOtpProps, useInputOtp} from "./use-input-otp"; -import {InputOtpSegment} from "./input-otp-segment"; import {InputOtpProvider} from "./input-otp-context"; +import {InputOtpSegment} from "./input-otp-segment"; export interface InputOtpProps extends UseInputOtpProps {} @@ -17,33 +19,16 @@ const InputOtp = forwardRef<"div", InputOtpProps>((props, ref) => { isInvalid, errorMessage, description, + slots, + classNames, getBaseProps, - getInputWrapperProps, - getInputProps, + getInputOtpProps, getSegmentWrapperProps, getHelperWrapperProps, getErrorMessageProps, getDescriptionProps, } = context; - const segmentsSection = useMemo(() => { - return ( -
- {Array.from(Array(length)).map((_, idx) => ( - - ))} -
- ); - }, [length, getSegmentWrapperProps]); - - const inputSection = useMemo(() => { - return ( -
- -
- ); - }, [getInputWrapperProps, getInputProps]); - const helperSection = useMemo(() => { if (!hasHelper) { return null; @@ -68,14 +53,27 @@ const InputOtp = forwardRef<"div", InputOtpProps>((props, ref) => { getDescriptionProps, ]); + const wrapperStyles = clsx(classNames?.wrapper); + return ( -
- {segmentsSection} - {inputSection} - {helperSection} -
+ ( +
+ {slots.map((slot, idx) => ( + + ))} +
+ )} + {...getInputOtpProps()} + data-slot="input" + role="textbox" + /> + {helperSection}
); diff --git a/packages/components/input-otp/src/use-input-otp.ts b/packages/components/input-otp/src/use-input-otp.ts index 3c74c2872b..f437d67622 100644 --- a/packages/components/input-otp/src/use-input-otp.ts +++ b/packages/components/input-otp/src/use-input-otp.ts @@ -12,16 +12,14 @@ import { useProviderContext, } from "@nextui-org/system"; import {inputOtp} from "@nextui-org/theme"; -import {filterDOMProps, ReactRef, useDOMRef} from "@nextui-org/react-utils"; -import {clsx, dataAttr, isEmpty, objectToDeps, safeAriaLabel} from "@nextui-org/shared-utils"; +import {ReactRef, useDOMRef} from "@nextui-org/react-utils"; +import {clsx, dataAttr, objectToDeps} from "@nextui-org/shared-utils"; import {useCallback, useMemo} from "react"; -import {useFocusRing} from "@react-aria/focus"; -import {mergeProps} from "@react-aria/utils"; -import {useHover} from "@react-aria/interactions"; +import {chain, mergeProps} from "@react-aria/utils"; import {AriaTextFieldProps} from "@react-types/textfield"; -import {AriaTextFieldOptions, useTextField} from "@react-aria/textfield"; import {useControlledState} from "@react-stately/utils"; -import {useSafeLayoutEffect} from "@nextui-org/use-safe-layout-effect"; +import {useFormValidationState} from "@react-stately/form"; +import {useFormValidation} from "@react-aria/form"; interface Props extends HTMLNextUIProps<"div"> { /** @@ -43,7 +41,7 @@ interface Props extends HTMLNextUIProps<"div"> { /** * Callback that will be fired when the value has length equal to otp length */ - onFill?: (v?: string) => void; + onComplete?: (v?: string) => void; /** * Boolean to disable the input-otp component. */ @@ -95,12 +93,12 @@ export function useInputOtp(originalProps: UseInputOtpProps) { className, classNames, length = 4, - onFill = () => {}, + onComplete = () => {}, onValueChange = () => {}, allowedKeys = "^[0-9]*$", validationBehavior = globalContext?.validationBehavior ?? "aria", type, - ...otherProps + name, } = props; const Component = as || "div"; @@ -111,11 +109,8 @@ export function useInputOtp(originalProps: UseInputOtpProps) { const handleValueChange = useCallback( (value: string | undefined) => { onValueChange(value ?? ""); - if (value && value?.length === length) { - onFill(value); - } }, - [onValueChange, onFill, length], + [onValueChange], ); const [value, setValue] = useControlledState( @@ -126,70 +121,30 @@ export function useInputOtp(originalProps: UseInputOtpProps) { const disableAnimation = originalProps.disableAnimation ?? globalContext?.disableAnimation ?? false; - const isDisabled = originalProps.isDisabled ?? false; + const isDisabled = originalProps.isDisabled; const baseStyles = clsx(classNames?.base, className); - const {focusProps, isFocused: isInputFocused} = useFocusRing({isTextInput: true}); - const allowedKeysRegex = new RegExp(allowedKeys); - const isFilled = !isEmpty(value); - const {isHovered, hoverProps} = useHover({isDisabled: !!originalProps?.isDisabled}); - - const onKeyDownCapture = (e: React.KeyboardEvent) => { - const key = e.key; - - if (key === "Backspace") { - return; - } - if (key === "ArrowLeft" || key === "ArrowRight") { - e.stopPropagation(); - e.preventDefault(); - - return; - } - if (!allowedKeysRegex.test(key)) { - e.stopPropagation(); - e.preventDefault(); - - return; - } - - return; - }; + const validationState = useFormValidationState({ + ...props, + validationBehavior, + value, + }); - type AutoCapitalize = AriaTextFieldOptions<"input">["autoCapitalize"]; + useFormValidation(props, validationState, inputRef); const { - inputProps, isInvalid: isAriaInvalid, validationErrors, validationDetails, - descriptionProps, - errorMessageProps, - } = useTextField( - { - ...originalProps, - validationBehavior, - autoCapitalize: originalProps.autoCapitalize as AutoCapitalize, - value: value, - "aria-label": safeAriaLabel( - originalProps["aria-label"], - originalProps.label, - originalProps.placeholder, - ), - inputElementType: "input", - onChange: setValue, - minLength: length, - maxLength: length, - }, - inputRef, - ); - - const isReadOnly = originalProps.isReadOnly ?? false; + } = validationState.displayValidation; + const isReadOnly = originalProps.isReadOnly; + const isRequired = originalProps.isRequired; const isInvalid = originalProps.isInvalid || isAriaInvalid; const errorMessage = typeof props.errorMessage === "function" ? props.errorMessage({isInvalid, validationErrors, validationDetails}) : props.errorMessage || validationErrors?.join(" "); + const description = props.description; const hasHelper = !!description || !!errorMessage; @@ -204,12 +159,6 @@ export function useInputOtp(originalProps: UseInputOtpProps) { [objectToDeps(variantProps), disableAnimation, isInvalid], ); - useSafeLayoutEffect(() => { - if (!inputRef.current) return; - - setValue(inputRef.current.value); - }, [inputRef.current]); - const getBaseProps: PropGetter = useCallback( (props = {}) => { return { @@ -217,72 +166,44 @@ export function useInputOtp(originalProps: UseInputOtpProps) { className: slots.base({ class: baseStyles, }), - onKeyDownCapture: onKeyDownCapture, "data-slot": "base", - "data-filled": dataAttr(isFilled), - "data-focus": dataAttr(isInputFocused), - "data-hover": dataAttr(isHovered), "data-disabled": dataAttr(isDisabled), "data-invalid": dataAttr(isInvalid), "data-required": dataAttr(originalProps?.isRequired), "data-readonly": dataAttr(originalProps?.isReadOnly), + "data-filled": dataAttr(value.length === length), + role: "base", ...props, }; }, - [baseDomRef, slots, baseStyles, isFilled, isInputFocused, isDisabled], + [baseDomRef, slots, baseStyles, isDisabled], ); - const getInputWrapperProps: PropGetter = useCallback( - (props = {}) => { - return { - className: slots.inputWrapper({ - class: clsx(classNames?.inputWrapper, props?.className), - }), - "data-slot": "input-wrapper", - ...mergeProps(otherProps, props), - }; - }, - [slots, classNames?.inputWrapper], - ); - - const getInputProps: PropGetter = useCallback( - (props = {}) => { - return { - ref: inputRef, - className: slots.input({ - class: clsx(classNames?.input, props?.className), - }), - maxLength: length, - minLength: length, - value, - disabled: isDisabled, - ...mergeProps( - focusProps, - hoverProps, - inputProps, - filterDOMProps(otherProps, { - enabled: true, - omitEventNames: new Set(Object.keys(inputProps)), - }), - props, - ), - placeholder: "", - "data-slot": "input", - "data-focus": dataAttr(isInputFocused), - "data-filled": dataAttr(isFilled), - "data-disabled": dataAttr(isDisabled), - }; - }, + const getInputOtpProps = useCallback( + () => ({ + required: isRequired, + disabled: isDisabled, + readOnly: isReadOnly, + pattern: allowedKeys, + ref: inputRef, + name: name, + value: value, + onChange: chain(setValue), + onBlur: props.onBlur, + onComplete: onComplete, + }), [ + isRequired, + isDisabled, + isReadOnly, + allowedKeys, inputRef, - slots, - classNames?.input, + name, length, - value, - isDisabled, + props.onChange, setValue, - isInputFocused, - isFilled, + props.onBlur, + onComplete, ], ); @@ -320,7 +241,7 @@ export function useInputOtp(originalProps: UseInputOtpProps) { class: clsx(classNames?.errorMessage, props?.className), }), "data-slot": "error-message", - ...mergeProps(errorMessageProps, props), + ...mergeProps(props), }; }, [slots, classNames?.errorMessage], @@ -333,7 +254,7 @@ export function useInputOtp(originalProps: UseInputOtpProps) { class: clsx(classNames?.description, props?.className), }), "data-slot": "description", - ...mergeProps(descriptionProps, props), + ...mergeProps(props), }; }, [slots, classNames?.description], @@ -344,7 +265,6 @@ export function useInputOtp(originalProps: UseInputOtpProps) { inputRef, length, value, - isInputFocused, classNames, slots, hasHelper, @@ -353,8 +273,7 @@ export function useInputOtp(originalProps: UseInputOtpProps) { errorMessage, type, getBaseProps, - getInputWrapperProps, - getInputProps, + getInputOtpProps, getSegmentWrapperProps, getHelperWrapperProps, getErrorMessageProps, diff --git a/packages/components/input-otp/stories/input-otp.stories.tsx b/packages/components/input-otp/stories/input-otp.stories.tsx index b149379f07..9eedd6669d 100644 --- a/packages/components/input-otp/stories/input-otp.stories.tsx +++ b/packages/components/input-otp/stories/input-otp.stories.tsx @@ -79,6 +79,30 @@ const RequiredTemplate = (args) => { ); }; +const ErrorMessageFunctionTemplate = (args) => { + const {register, handleSubmit} = useForm({ + defaultValues: { + otp: "", + }, + }); + + const onSubmit = (data: any) => { + // eslint-disable-next-line no-console + alert("Submitted value: " + JSON.stringify(data)); + }; + + return ( +
+
+ + + +
+ ); +}; + const ControlledTemplate = (args) => { const [value, setValue] = React.useState(""); @@ -183,16 +207,8 @@ export const WithErrorMessage = { }, }; -export const Password = { - render: Template, - args: { - ...defaultProps, - type: "password", - }, -}; - export const WithErrorMessageFunction = { - render: WithReactHookFormTemplate, + render: ErrorMessageFunctionTemplate, args: { ...defaultProps, length: 4, @@ -206,6 +222,14 @@ export const WithErrorMessageFunction = { }, }; +export const Password = { + render: RequiredTemplate, + args: { + ...defaultProps, + type: "password", + }, +}; + export const isInvalid = { render: Template, args: { diff --git a/packages/core/theme/src/components/input-otp.ts b/packages/core/theme/src/components/input-otp.ts index e448f77c05..db359587e3 100644 --- a/packages/core/theme/src/components/input-otp.ts +++ b/packages/core/theme/src/components/input-otp.ts @@ -5,7 +5,7 @@ import {tv} from "../utils/tv"; const inputOtp = tv({ slots: { base: ["relative", "flex", "flex-col", "w-fit"], - inputWrapper: [], + wrapper: ["group", "flex items-center", "has-[:disabled]:opacity-60"], input: [ "absolute", "inset-0", @@ -38,11 +38,11 @@ const inputOtp = tv({ "text-2xl", "h-[50%]", "w-px", - "bg-white", + "bg-foreground", ], helperWrapper: ["text-xs", "mt-0.5", "font-extralight", ""], - errorMessage: ["text-tiny text-danger w-full"], - description: ["text-tiny text-foreground-400"], + errorMessage: ["text-xs text-danger w-full"], + description: ["text-xs text-foreground-400"], }, variants: { variant: { @@ -134,14 +134,14 @@ const inputOtp = tv({ variant: "flat", color: "default", class: { - segment: "data-[has-value=true]:text-default-foreground", + segment: ["bg-default-200", "data-[active=true]:bg-default-400"], }, }, { variant: "flat", color: "primary", class: { - segment: ["bg-primary-50", "data-[active=true]:bg-primary-100", "text-primary"], + segment: ["bg-primary-100", "data-[active=true]:bg-primary-200", "text-primary"], caret: ["bg-primary"], passwordChar: ["bg-primary"], }, @@ -150,7 +150,7 @@ const inputOtp = tv({ variant: "flat", color: "secondary", class: { - segment: ["bg-secondary-50", "data-[active=true]:bg-secondary-100", "text-secondary"], + segment: ["bg-secondary-100", "data-[active=true]:bg-secondary-200", "text-secondary"], caret: ["bg-secondary"], passwordChar: ["bg-secondary"], }, @@ -159,7 +159,7 @@ const inputOtp = tv({ variant: "flat", color: "success", class: { - segment: ["bg-success-50", "data-[active=true]:bg-success-100", "text-success"], + segment: ["bg-success-100", "data-[active=true]:bg-success-200", "text-success"], caret: ["bg-success"], passwordChar: ["bg-success"], }, @@ -168,7 +168,7 @@ const inputOtp = tv({ variant: "flat", color: "warning", class: { - segment: ["bg-warning-50", "data-[active=true]:bg-warning-100", "text-warning"], + segment: ["bg-warning-100", "data-[active=true]:bg-warning-200", "text-warning"], caret: ["bg-warning"], passwordChar: ["bg-warning"], }, @@ -177,7 +177,7 @@ const inputOtp = tv({ variant: "flat", color: "danger", class: { - segment: ["bg-danger-50", "data-[active=true]:bg-danger-100", "text-danger"], + segment: ["bg-danger-100", "data-[active=true]:bg-danger-200", "text-danger"], caret: ["bg-danger"], passwordChar: ["bg-danger"], }, @@ -187,7 +187,7 @@ const inputOtp = tv({ variant: "faded", color: "default", class: { - segment: "data-[has-value=true]:text-default-foreground", + segment: "bg-default-200", }, }, { @@ -195,7 +195,7 @@ const inputOtp = tv({ color: "primary", class: { segment: [ - "bg-primary-50", + "bg-primary-100", "text-primary", "border-1", "border-primary-200", @@ -211,7 +211,7 @@ const inputOtp = tv({ color: "secondary", class: { segment: [ - "bg-secondary-50", + "bg-secondary-100", "text-secondary", "border-1", "border-secondary-200", @@ -227,7 +227,7 @@ const inputOtp = tv({ color: "success", class: { segment: [ - "bg-success-50", + "bg-success-100", "text-success", "border-1", "border-success-200", @@ -243,7 +243,7 @@ const inputOtp = tv({ color: "warning", class: { segment: [ - "bg-warning-50", + "bg-warning-100", "text-warning", "border-1", "border-warning-200", @@ -259,7 +259,7 @@ const inputOtp = tv({ color: "danger", class: { segment: [ - "bg-danger-50", + "bg-danger-100", "text-danger", "border-1", "border-danger-200", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c0846ce9eb..226b775b01 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1673,24 +1673,24 @@ importers: '@nextui-org/shared-utils': specifier: workspace:* version: link:../../utilities/shared-utils - '@nextui-org/use-safe-layout-effect': - specifier: workspace:* - version: link:../../hooks/use-safe-layout-effect - '@react-aria/focus': - specifier: 3.17.1 - version: 3.17.1(react@18.2.0) - '@react-aria/textfield': - specifier: 3.14.5 - version: 3.14.5(react@18.2.0) + '@react-aria/form': + specifier: 3.0.8 + version: 3.0.8(react@18.3.1) '@react-aria/utils': specifier: 3.24.1 - version: 3.24.1(react@18.2.0) + version: 3.24.1(react@18.3.1) + '@react-stately/form': + specifier: 3.0.5 + version: 3.0.5(react@18.3.1) '@react-stately/utils': specifier: 3.10.1 - version: 3.10.1(react@18.2.0) + version: 3.10.1(react@18.3.1) '@react-types/textfield': specifier: 3.9.3 - version: 3.9.3(react@18.2.0) + version: 3.9.3(react@18.3.1) + input-otp: + specifier: 1.4.1 + version: 1.4.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) devDependencies: '@nextui-org/system': specifier: workspace:* @@ -1703,13 +1703,13 @@ importers: version: 2.2.0 react: specifier: ^18.2.0 - version: 18.2.0 + version: 18.3.1 react-dom: specifier: ^18.2.0 - version: 18.2.0(react@18.2.0) + version: 18.3.1(react@18.3.1) react-hook-form: specifier: ^7.51.3 - version: 7.51.3(react@18.2.0) + version: 7.53.1(react@18.3.1) packages/components/kbd: dependencies: @@ -7157,6 +7157,11 @@ packages: peerDependencies: react: ^18.2.0 + '@react-types/textfield@3.9.3': + resolution: {integrity: sha512-DoAY6cYOL0pJhgNGI1Rosni7g72GAt4OVr2ltEx2S9ARmFZ0DBvdhA9lL2nywcnKMf27PEJcKMXzXc10qaHsJw==} + peerDependencies: + react: ^18.2.0 + '@react-types/textfield@3.9.6': resolution: {integrity: sha512-0uPqjJh4lYp1aL1HL9IlV8Cgp8eT0PcsNfdoCktfkLytvvBPmox2Pfm57W/d0xTtzZu2CjxhYNTob+JtGAOeXA==} peerDependencies: @@ -11106,6 +11111,12 @@ packages: inline-style-parser@0.2.4: resolution: {integrity: sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==} + input-otp@1.4.1: + resolution: {integrity: sha512-+yvpmKYKHi9jIGngxagY9oWiiblPB7+nEO75F2l2o4vs+6vpPZZmUl4tBNYuTCvQjhvEIbdNeJu70bhfYP2nbw==} + peerDependencies: + react: ^18.2.0 + react-dom: ^18.2.0 + inquirer@6.5.2: resolution: {integrity: sha512-cntlB5ghuB0iuO65Ovoi8ogLHiWGs/5yNrtUcKjFhSSiVeAIVpD7koaSU9RM8mpXw5YDi9RdYXGQMaOURB7ycQ==} engines: {node: '>=6.0.0'} @@ -20386,6 +20397,11 @@ snapshots: '@react-types/shared': 3.24.1(react@18.3.1) react: 18.3.1 + '@react-types/textfield@3.9.3(react@18.3.1)': + dependencies: + '@react-types/shared': 3.24.1(react@18.3.1) + react: 18.3.1 + '@react-types/textfield@3.9.6(react@18.3.1)': dependencies: '@react-types/shared': 3.24.1(react@18.3.1) @@ -23885,7 +23901,7 @@ snapshots: eslint: 7.32.0 eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@7.32.0) - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@4.9.5))(eslint-import-resolver-typescript@2.7.1(eslint-plugin-import@2.31.0)(eslint@7.32.0))(eslint@7.32.0) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@4.9.5))(eslint-import-resolver-typescript@2.7.1)(eslint@7.32.0) eslint-plugin-jsx-a11y: 6.10.2(eslint@7.32.0) eslint-plugin-react: 7.37.2(eslint@7.32.0) eslint-plugin-react-hooks: 4.6.2(eslint@7.32.0) @@ -23955,7 +23971,7 @@ snapshots: is-bun-module: 1.2.1 is-glob: 4.0.3 optionalDependencies: - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@4.9.5))(eslint-import-resolver-typescript@2.7.1(eslint-plugin-import@2.31.0)(eslint@7.32.0))(eslint@7.32.0) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@4.9.5))(eslint-import-resolver-typescript@2.7.1)(eslint@7.32.0) transitivePeerDependencies: - '@typescript-eslint/parser' - eslint-import-resolver-node @@ -24006,35 +24022,6 @@ snapshots: lodash: 4.17.21 string-natural-compare: 3.0.1 - eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@4.9.5))(eslint-import-resolver-typescript@2.7.1(eslint-plugin-import@2.31.0)(eslint@7.32.0))(eslint@7.32.0): - dependencies: - '@rtsao/scc': 1.1.0 - array-includes: 3.1.8 - array.prototype.findlastindex: 1.2.5 - array.prototype.flat: 1.3.2 - array.prototype.flatmap: 1.3.2 - debug: 3.2.7 - doctrine: 2.1.0 - eslint: 7.32.0 - eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@7.32.0))(eslint@7.32.0) - hasown: 2.0.2 - is-core-module: 2.15.1 - is-glob: 4.0.3 - minimatch: 3.1.2 - object.fromentries: 2.0.8 - object.groupby: 1.0.3 - object.values: 1.2.0 - semver: 6.3.1 - string.prototype.trimend: 1.0.8 - tsconfig-paths: 3.15.0 - optionalDependencies: - '@typescript-eslint/parser': 5.62.0(eslint@7.32.0)(typescript@4.9.5) - transitivePeerDependencies: - - eslint-import-resolver-typescript - - eslint-import-resolver-webpack - - supports-color - eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@4.9.5))(eslint-import-resolver-typescript@2.7.1)(eslint@7.32.0): dependencies: '@rtsao/scc': 1.1.0 @@ -25403,6 +25390,11 @@ snapshots: inline-style-parser@0.2.4: {} + input-otp@1.4.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + inquirer@6.5.2: dependencies: ansi-escapes: 3.2.0