From 1246647323cf7fa0c00dc2217e13fee673469efb Mon Sep 17 00:00:00 2001 From: David Totrashvili <8580261+totraev@users.noreply.github.com> Date: Wed, 11 Dec 2024 17:49:13 +0500 Subject: [PATCH] feat: Form widget (#58) --- .changeset/green-days-exist.md | 5 +++ package-lock.json | 63 +++++++++++++++++++++++++++-- package.json | 5 ++- src/index.tsx | 2 + src/widgets/Form/Form.stories.tsx | 40 +++++++++++++++++++ src/widgets/Form/Form.tsx | 66 +++++++++++++++++++++++++++++++ src/widgets/Form/hooks.ts | 35 ++++++++++++++++ src/widgets/Form/index.tsx | 3 ++ vite.config.ts | 2 +- 9 files changed, 215 insertions(+), 6 deletions(-) create mode 100644 .changeset/green-days-exist.md create mode 100644 src/widgets/Form/Form.stories.tsx create mode 100644 src/widgets/Form/Form.tsx create mode 100644 src/widgets/Form/hooks.ts create mode 100644 src/widgets/Form/index.tsx diff --git a/.changeset/green-days-exist.md b/.changeset/green-days-exist.md new file mode 100644 index 0000000..3fd42ca --- /dev/null +++ b/.changeset/green-days-exist.md @@ -0,0 +1,5 @@ +--- +"@babylonlabs-io/bbn-core-ui": minor +--- + +add Form widget diff --git a/package-lock.json b/package-lock.json index b15931f..b404b70 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,16 @@ { "name": "@babylonlabs-io/bbn-core-ui", - "version": "0.4.0", + "version": "0.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@babylonlabs-io/bbn-core-ui", - "version": "0.4.0", + "version": "0.4.1", "dependencies": { + "@hookform/resolvers": "^3.9.1", "@popperjs/core": "^2.11.8", + "react-hook-form": "^7.54.0", "react-popper": "^2.3.0" }, "devDependencies": { @@ -50,7 +52,8 @@ "peerDependencies": { "react": "^18.3.1", "react-dom": "^18.3.1", - "tailwind-merge": "^2.5.4" + "tailwind-merge": "^2.5.4", + "yup": "^1.5.0" } }, "node_modules/@adobe/css-tools": { @@ -1261,6 +1264,14 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@hookform/resolvers": { + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.9.1.tgz", + "integrity": "sha512-ud2HqmGBM0P0IABqoskKWI6PEf6ZDDBZkFqe2Vnl+mTHCEHzr3ISjjZyCwTjC/qpL25JC9aIDkloQejvMeq0ug==", + "peerDependencies": { + "react-hook-form": "^7.0.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -6869,6 +6880,12 @@ "node": ">= 0.6.0" } }, + "node_modules/property-expr": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz", + "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==", + "peer": true + }, "node_modules/pseudomap": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", @@ -6979,6 +6996,21 @@ "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==", "license": "MIT" }, + "node_modules/react-hook-form": { + "version": "7.54.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.54.0.tgz", + "integrity": "sha512-PS05+UQy/IdSbJNojBypxAo9wllhHgGmyr8/dyGQcPoiMf3e7Dfb9PWYVRco55bLbxH9S+1yDDJeTdlYCSxO3A==", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-icons": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.3.0.tgz", @@ -7839,6 +7871,12 @@ "node": ">=0.8" } }, + "node_modules/tiny-case": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", + "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==", + "peer": true + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -7888,6 +7926,12 @@ "node": ">=8.0" } }, + "node_modules/toposort": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", + "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==", + "peer": true + }, "node_modules/ts-api-utils": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.0.tgz", @@ -7957,7 +8001,6 @@ "version": "2.19.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", - "dev": true, "engines": { "node": ">=12.20" }, @@ -8410,6 +8453,18 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/yup": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/yup/-/yup-1.5.0.tgz", + "integrity": "sha512-NJfBIHnp1QbqZwxcgl6irnDMIsb/7d1prNhFx02f1kp8h+orpi4xs3w90szNpOh68a/iHPdMsYvhZWoDmUvXBQ==", + "peer": true, + "dependencies": { + "property-expr": "^2.0.5", + "tiny-case": "^1.0.3", + "toposort": "^2.0.2", + "type-fest": "^2.19.0" + } } } } diff --git a/package.json b/package.json index 57b4153..7a24355 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,8 @@ "peerDependencies": { "react": "^18.3.1", "react-dom": "^18.3.1", - "tailwind-merge": "^2.5.4" + "tailwind-merge": "^2.5.4", + "yup": "^1.5.0" }, "devDependencies": { "@changesets/cli": "^2.27.9", @@ -87,7 +88,9 @@ ] }, "dependencies": { + "@hookform/resolvers": "^3.9.1", "@popperjs/core": "^2.11.8", + "react-hook-form": "^7.54.0", "react-popper": "^2.3.0" } } diff --git a/src/index.tsx b/src/index.tsx index 8b736da..42cdccd 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -13,4 +13,6 @@ export * from "./components/Loader"; export * from "./components/Table"; export * from "./components/Popover"; +export * from "./widgets/Form"; + export { ScrollLocker } from "@/context/Dialog.context"; diff --git a/src/widgets/Form/Form.stories.tsx b/src/widgets/Form/Form.stories.tsx new file mode 100644 index 0000000..7d72734 --- /dev/null +++ b/src/widgets/Form/Form.stories.tsx @@ -0,0 +1,40 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import * as yup from "yup"; + +import { Form } from "./Form"; +import { useField } from "./hooks"; +import { Input } from "@/components/Form"; + +const meta: Meta = { + component: Form, + tags: ["autodocs"], +}; + +export default meta; + +type Story = StoryObj; + +const Field = () => { + const { error, invalid, ...inputProps } = useField({ name: "test", autoFocus: true, defaultValue: "test" }); + + return ; +}; + +const schema = yup + .object() + .shape({ + test: yup.string().required(), + }) + .required(); + +export const Default: Story = { + args: { + onChange: console.log, + schema, + }, + render: (props) => ( +
+ + + ), +}; diff --git a/src/widgets/Form/Form.tsx b/src/widgets/Form/Form.tsx new file mode 100644 index 0000000..96e7f3d --- /dev/null +++ b/src/widgets/Form/Form.tsx @@ -0,0 +1,66 @@ +import { type PropsWithChildren, useEffect, HTMLProps } from "react"; +import { + type DefaultValues, + type Mode, + type SubmitHandler, + type DeepPartial, + FormProvider, + useForm, + Resolver, +} from "react-hook-form"; +import { yupResolver } from "@hookform/resolvers/yup"; +import { type ObjectSchema } from "yup"; +import { twJoin } from "tailwind-merge"; + +export interface FormProps extends PropsWithChildren { + className?: string; + name?: string; + mode?: Mode; + reValidateMode?: Exclude; + defaultValues?: DefaultValues; + schema?: ObjectSchema; + formProps?: HTMLProps; + onSubmit?: SubmitHandler; + onChange?: (data: DeepPartial) => void; +} + +export function Form({ + className, + name, + children, + mode = "onBlur", + reValidateMode = "onBlur", + defaultValues, + schema, + formProps, + onSubmit = () => null, + onChange, +}: FormProps) { + const methods = useForm({ + mode, + reValidateMode, + defaultValues, + resolver: schema ? (yupResolver(schema) as unknown as Resolver) : undefined, + }); + + useEffect(() => { + if (!onChange) return; + + const { unsubscribe } = methods.watch(onChange); + + return unsubscribe; + }, [onChange, methods.watch]); + + return ( + +
+ {children} +
+
+ ); +} diff --git a/src/widgets/Form/hooks.ts b/src/widgets/Form/hooks.ts new file mode 100644 index 0000000..f860a38 --- /dev/null +++ b/src/widgets/Form/hooks.ts @@ -0,0 +1,35 @@ +import { useEffect } from "react"; +import { useController, useFormContext } from "react-hook-form"; + +interface FieldProps { + name: string; + defaultValue?: V; + disabled?: boolean; + autoFocus?: boolean; + shouldUnregister?: boolean; +} + +export function useField({ + name, + defaultValue, + disabled = false, + autoFocus = false, + shouldUnregister = false, +}: FieldProps) { + const { setFocus } = useFormContext(); + const { field, fieldState } = useController({ name, defaultValue, disabled, shouldUnregister }); + const { invalid, isTouched, error } = fieldState; + + useEffect(() => { + if (autoFocus) { + setFocus(name); + } + }, [name]); + + return { + ...field, + value: field.value as V, + invalid: invalid && isTouched, + error: error?.message ?? "", + }; +} diff --git a/src/widgets/Form/index.tsx b/src/widgets/Form/index.tsx new file mode 100644 index 0000000..7d224cc --- /dev/null +++ b/src/widgets/Form/index.tsx @@ -0,0 +1,3 @@ +export { useFormContext, useFormState, useWatch } from "react-hook-form"; +export * from "./Form"; +export * from "./hooks"; diff --git a/vite.config.ts b/vite.config.ts index 17bcdd7..d1ad8b0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -19,7 +19,7 @@ export default defineConfig({ fileName: (format) => `index.${format}.js`, }, rollupOptions: { - external: ["react", "react-dom", "react/jsx-runtime", "tailwind-merge"], + external: ["react", "react-dom", "react/jsx-runtime", "tailwind-merge", "yup"], output: { sourcemapExcludeSources: true, },