Skip to content

Commit

Permalink
feat: Form widget (#58)
Browse files Browse the repository at this point in the history
  • Loading branch information
totraev authored Dec 11, 2024
1 parent a8049c4 commit 1246647
Show file tree
Hide file tree
Showing 9 changed files with 215 additions and 6 deletions.
5 changes: 5 additions & 0 deletions .changeset/green-days-exist.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@babylonlabs-io/bbn-core-ui": minor
---

add Form widget
63 changes: 59 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
}
}
2 changes: 2 additions & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
40 changes: 40 additions & 0 deletions src/widgets/Form/Form.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof Form> = {
component: Form,
tags: ["autodocs"],
};

export default meta;

type Story = StoryObj<typeof meta>;

const Field = () => {
const { error, invalid, ...inputProps } = useField({ name: "test", autoFocus: true, defaultValue: "test" });

return <Input {...inputProps} state={invalid ? "error" : "default"} stateText={error} />;

Check failure on line 20 in src/widgets/Form/Form.stories.tsx

View workflow job for this annotation

GitHub Actions / lint_test / build

Type '{ state: "default" | "error"; stateText: string; value: string; onChange: (...event: any[]) => void; onBlur: Noop; disabled?: boolean | undefined; name: string; ref: RefCallBack; }' is not assignable to type 'IntrinsicAttributes & Omit<InputProps, "ref"> & RefAttributes<HTMLInputElement>'.
};

const schema = yup
.object()
.shape({
test: yup.string().required(),
})
.required();

export const Default: Story = {
args: {
onChange: console.log,
schema,
},
render: (props) => (
<Form {...props}>
<Field />
</Form>
),
};
66 changes: 66 additions & 0 deletions src/widgets/Form/Form.tsx
Original file line number Diff line number Diff line change
@@ -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<V extends object> extends PropsWithChildren {
className?: string;
name?: string;
mode?: Mode;
reValidateMode?: Exclude<Mode, "onTouched" | "all">;
defaultValues?: DefaultValues<V>;
schema?: ObjectSchema<V>;
formProps?: HTMLProps<HTMLFormElement>;
onSubmit?: SubmitHandler<V>;
onChange?: (data: DeepPartial<V>) => void;
}

export function Form<V extends object>({
className,
name,
children,
mode = "onBlur",
reValidateMode = "onBlur",
defaultValues,
schema,
formProps,
onSubmit = () => null,
onChange,
}: FormProps<V>) {
const methods = useForm({
mode,
reValidateMode,
defaultValues,
resolver: schema ? (yupResolver(schema) as unknown as Resolver<V>) : undefined,
});

useEffect(() => {
if (!onChange) return;

const { unsubscribe } = methods.watch(onChange);

return unsubscribe;
}, [onChange, methods.watch]);

return (
<FormProvider {...methods}>
<form
className={twJoin("bbn-form", className)}
name={name}
onSubmit={methods.handleSubmit(onSubmit)}
{...formProps}
>
{children}
</form>
</FormProvider>
);
}
35 changes: 35 additions & 0 deletions src/widgets/Form/hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { useEffect } from "react";
import { useController, useFormContext } from "react-hook-form";

interface FieldProps<V> {
name: string;
defaultValue?: V;
disabled?: boolean;
autoFocus?: boolean;
shouldUnregister?: boolean;
}

export function useField<V = string>({
name,
defaultValue,
disabled = false,
autoFocus = false,
shouldUnregister = false,
}: FieldProps<V>) {
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 ?? "",
};
}
3 changes: 3 additions & 0 deletions src/widgets/Form/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { useFormContext, useFormState, useWatch } from "react-hook-form";
export * from "./Form";
export * from "./hooks";
2 changes: 1 addition & 1 deletion vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down

0 comments on commit 1246647

Please sign in to comment.