diff --git a/.github/workflows/jan-electron-linter-and-test.yml b/.github/workflows/jan-electron-linter-and-test.yml index 69552f17e..90802baf7 100644 --- a/.github/workflows/jan-electron-linter-and-test.yml +++ b/.github/workflows/jan-electron-linter-and-test.yml @@ -4,29 +4,31 @@ on: branches: - main paths: - - 'electron/**' + - "electron/**" - .github/workflows/jan-electron-linter-and-test.yml - - 'web/**' - - 'package.json' - - 'node_modules/**' - - 'yarn.lock' + - "web/**" + - "uikit/**" + - "package.json" + - "node_modules/**" + - "yarn.lock" pull_request: branches: - main paths: - - 'electron/**' + - "electron/**" - .github/workflows/linter-and-test.yml - - 'web/**' - - 'package.json' - - 'node_modules/**' - - 'yarn.lock' + - "web/**" + - "uikit/**" + - "package.json" + - "node_modules/**" + - "yarn.lock" jobs: test-on-macos: runs-on: [self-hosted, macOS, macos-desktop] steps: - - name: 'Cleanup build folder' + - name: "Cleanup build folder" run: | ls -la ./ rm -rf ./* || true @@ -41,6 +43,12 @@ jobs: with: node-version: 20 + - name: Build uikit + run: | + cd uikit + yarn install + yarn build + - name: Linter and test run: | yarn config set network-timeout 300000 @@ -73,6 +81,12 @@ jobs: with: node-version: 20 + - name: Build uikit + run: | + cd uikit + yarn install + yarn build + - name: Linter and test run: | yarn config set network-timeout 300000 @@ -85,7 +99,7 @@ jobs: test-on-ubuntu: runs-on: [self-hosted, Linux, ubuntu-desktop] steps: - - name: 'Cleanup build folder' + - name: "Cleanup build folder" run: | ls -la ./ rm -rf ./* || true @@ -100,6 +114,12 @@ jobs: with: node-version: 20 + - name: Build uikit + run: | + cd uikit + yarn install + yarn build + - name: Linter and test run: | export DISPLAY=$(w -h | awk 'NR==1 {print $2}') @@ -109,4 +129,4 @@ jobs: yarn install yarn build:plugins yarn build:test-linux - yarn test \ No newline at end of file + yarn test diff --git a/electron/main.ts b/electron/main.ts index 8e175a62d..cecc90f42 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -53,11 +53,12 @@ app.on("quit", () => { function createMainWindow() { mainWindow = new BrowserWindow({ width: 1200, + minWidth: 800, height: 800, show: false, trafficLightPosition: { - x: 16, - y: 10, + x: 10, + y: 15, }, titleBarStyle: "hidden", vibrancy: "sidebar", diff --git a/electron/tests/explore.e2e.spec.ts b/electron/tests/explore.e2e.spec.ts index 6df823fc1..5a4412cb3 100644 --- a/electron/tests/explore.e2e.spec.ts +++ b/electron/tests/explore.e2e.spec.ts @@ -36,12 +36,6 @@ test.afterAll(async () => { test("explores models", async () => { await page.getByTestId("Explore Models").first().click(); - const header = await page - .getByRole("heading") - .filter({ hasText: "Explore Models" }) - .first() - .isDisabled(); - expect(header).toBe(false); - + await page.getByTestId("testid-explore-models").isVisible(); // More test cases here... }); diff --git a/electron/tests/my-models.e2e.spec.ts b/electron/tests/my-models.e2e.spec.ts index 788b80fc0..a3355fb33 100644 --- a/electron/tests/my-models.e2e.spec.ts +++ b/electron/tests/my-models.e2e.spec.ts @@ -36,6 +36,6 @@ test.afterAll(async () => { test("shows my models", async () => { await page.getByTestId("My Models").first().click(); - await page.getByTestId("testid-mymodels-header").isVisible(); + await page.getByTestId("testid-my-models").isVisible(); // More test cases here... }); diff --git a/package.json b/package.json index 2a489d9a0..7356a0fd5 100644 --- a/package.json +++ b/package.json @@ -3,11 +3,17 @@ "private": true, "workspaces": { "packages": [ + "uikit", + "core", "electron", "web", "server" ], "nohoist": [ + "uikit", + "uikit/*", + "core", + "core/*", "electron", "electron/**", "web", @@ -23,13 +29,15 @@ "dev:web": "yarn workspace jan-web dev", "dev": "concurrently --kill-others \"yarn dev:web\" \"wait-on http://localhost:3000 && yarn dev:electron\"", "test-local": "yarn lint && yarn build:test && yarn test", + "dev:uikit": "yarn workspace @janhq/uikit install && yarn workspace @janhq/uikit dev", + "build:uikit": "yarn workspace @janhq/uikit install && yarn workspace @janhq/uikit build", "build:core": "cd core && yarn install && yarn run build", "build:web": "yarn workspace jan-web build && cpx \"web/out/**\" \"electron/renderer/\"", "build:electron": "yarn workspace jan build", "build:electron:test": "yarn workspace jan build:test", "build:pull-plugins": "rimraf ./electron/core/pre-install/*.tgz && cd ./electron/core/pre-install && npm pack @janhq/inference-plugin @janhq/monitoring-plugin", - "build:plugins": "rimraf ./electron/core/pre-install/*.tgz && concurrently --kill-others-on-fail \"cd ./plugins/conversational-plugin && npm install && npm run postinstall && npm run build:publish\" \"cd ./plugins/inference-plugin && npm install --ignore-scripts && npm run postinstall:dev && npm run build:publish\" \"cd ./plugins/model-plugin && npm install && npm run postinstall && npm run build:publish\" \"cd ./plugins/monitoring-plugin && npm install && npm run postinstall && npm run build:publish\"", - "build:plugins-web": "rimraf ./electron/core/pre-install/*.tgz && concurrently --kill-others-on-fail \"cd ./plugins/conversational-plugin && npm install && npm run build:deps && npm run postinstall\" \"cd ./plugins/inference-plugin && npm install && npm run postinstall\" \"cd ./plugins/model-plugin && npm install && npm run postinstall\" \"cd ./plugins/monitoring-plugin && npm install && npm run postinstall\" && concurrently --kill-others-on-fail \"cd ./plugins/conversational-plugin && npm run build:publish\" \"cd ./plugins/inference-plugin && npm run build:publish\" \"cd ./plugins/model-plugin && npm run build:publish\" \"cd ./plugins/monitoring-plugin && npm run build:publish\"", + "build:plugins": "rimraf ./electron/core/pre-install/*.tgz && concurrently --kill-others-on-fail \"cd ./plugins/conversational-json && npm install && npm run postinstall && npm run build:publish\" \"cd ./plugins/inference-plugin && npm install --ignore-scripts && npm run postinstall:dev && npm run build:publish\" \"cd ./plugins/model-plugin && npm install && npm run postinstall && npm run build:publish\" \"cd ./plugins/monitoring-plugin && npm install && npm run postinstall && npm run build:publish\"", + "build:plugins-web": "rimraf ./electron/core/pre-install/*.tgz && concurrently --kill-others-on-fail \"cd ./plugins/conversational-json && npm install && npm run build:deps && npm run postinstall\" \"cd ./plugins/inference-plugin && npm install && npm run postinstall\" \"cd ./plugins/model-plugin && npm install && npm run postinstall\" \"cd ./plugins/monitoring-plugin && npm install && npm run postinstall\" && concurrently --kill-others-on-fail \"cd ./plugins/conversational-json && npm run build:publish\" \"cd ./plugins/inference-plugin && npm run build:publish\" \"cd ./plugins/model-plugin && npm run build:publish\" \"cd ./plugins/monitoring-plugin && npm run build:publish\"", "build": "yarn build:web && yarn build:electron", "build:test": "yarn build:web && yarn build:electron:test", "build:test-darwin": "yarn build:web && yarn workspace jan build:test-darwin", @@ -42,7 +50,7 @@ "build:publish-darwin": "yarn build:web && yarn workspace jan build:publish-darwin", "build:publish-win32": "yarn build:web && yarn workspace jan build:publish-win32", "build:publish-linux": "yarn build:web && yarn workspace jan build:publish-linux", - "build:web-plugins": "yarn build:web && yarn build:plugins-web && mkdir -p \"./web/out/plugins/conversational-plugin\" && cp \"./plugins/conversational-plugin/dist/index.js\" \"./web/out/plugins/conversational-plugin\" && mkdir -p \"./web/out/plugins/inference-plugin\" && cp \"./plugins/inference-plugin/dist/index.js\" \"./web/out/plugins/inference-plugin\" && mkdir -p \"./web/out/plugins/model-plugin\" && cp \"./plugins/model-plugin/dist/index.js\" \"./web/out/plugins/model-plugin\" && mkdir -p \"./web/out/plugins/monitoring-plugin\" && cp \"./plugins/monitoring-plugin/dist/index.js\" \"./web/out/plugins/monitoring-plugin\"", + "build:web-plugins": "yarn build:web && yarn build:plugins-web && mkdir -p \"./web/out/plugins/conversational-json\" && cp \"./plugins/conversational-json/dist/index.js\" \"./web/out/plugins/conversational-json\" && mkdir -p \"./web/out/plugins/inference-plugin\" && cp \"./plugins/inference-plugin/dist/index.js\" \"./web/out/plugins/inference-plugin\" && mkdir -p \"./web/out/plugins/model-plugin\" && cp \"./plugins/model-plugin/dist/index.js\" \"./web/out/plugins/model-plugin\" && mkdir -p \"./web/out/plugins/monitoring-plugin\" && cp \"./plugins/monitoring-plugin/dist/index.js\" \"./web/out/plugins/monitoring-plugin\"", "server:prod": "yarn workspace server build && yarn build:web-plugins && cpx \"web/out/**\" \"server/build/renderer/\" && mkdir -p ./server/build/@janhq && cp -r ./plugins/* ./server/build/@janhq", "start:server": "yarn server:prod && node server/build/main.js" }, @@ -52,8 +60,5 @@ "rimraf": "^3.0.2", "wait-on": "^7.0.1" }, - "version": "0.0.0", - "dependencies": { - "@janhq/core": "file:core" - } + "version": "0.0.0" } diff --git a/uikit/.prettierignore b/uikit/.prettierignore new file mode 100644 index 000000000..02d9145c1 --- /dev/null +++ b/uikit/.prettierignore @@ -0,0 +1,5 @@ +.next/ +node_modules/ +dist/ +*.hbs +*.mdx \ No newline at end of file diff --git a/uikit/.prettierrc b/uikit/.prettierrc new file mode 100644 index 000000000..933d88d62 --- /dev/null +++ b/uikit/.prettierrc @@ -0,0 +1,8 @@ +{ + "semi": false, + "singleQuote": true, + "quoteProps": "consistent", + "trailingComma": "es5", + "endOfLine": "lf", + "plugins": ["prettier-plugin-tailwindcss"] +} diff --git a/uikit/package.json b/uikit/package.json new file mode 100644 index 000000000..dd67be599 --- /dev/null +++ b/uikit/package.json @@ -0,0 +1,53 @@ +{ + "name": "@janhq/uikit", + "version": "0.1.0", + "license": "MIT", + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "files": [ + "dist/**" + ], + "scripts": { + "build:styles": "postcss src/main.scss -o dist/index.css --use postcss-import", + "build:react": "tsup src/index.{ts,tsx} --format cjs,esm --dts --external react react-dom --minify terser --splitting --sourcemap", + "dev:react": "tsup src/index.{ts,tsx} --format cjs,esm --watch --dts", + "dev:styles": "postcss src/main.scss -o dist/index.css -u postcss-import -w", + "build": "yarn build:styles && yarn build:react", + "dev": "concurrently --kill-others \"yarn dev:styles\" \"yarn dev:react\"" + }, + "dependencies": { + "@radix-ui/react-avatar": "^1.0.4", + "@radix-ui/react-context": "^1.0.1", + "@radix-ui/react-dialog": "^1.0.5", + "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-progress": "^1.0.3", + "@radix-ui/react-scroll-area": "^1.0.5", + "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-switch": "^1.0.3", + "@radix-ui/react-toast": "^1.1.5", + "@radix-ui/react-tooltip": "^1.0.7", + "autoprefixer": "^10.4.16", + "class-variance-authority": "^0.7.0", + "cmdk": "^0.2.0", + "lucide-react": "^0.292.0", + "postcss": "^8.4.31", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-hook-form": "^7.47.0", + "scss": "^0.2.4", + "tailwindcss": "^3.3.5" + }, + "devDependencies": { + "concurrently": "^8.2.2", + "postcss-cli": "^10.1.0", + "postcss-import": "^15.1.0", + "prejss-cli": "^0.3.3", + "prettier": "^3.0.3", + "prettier-plugin-tailwindcss": "^0.5.6", + "tailwind-merge": "^2.0.0", + "terser": "^5.24.0", + "tsup": "^7.2.0", + "typescript": "^5.2.2" + } +} diff --git a/uikit/postcss.config.js b/uikit/postcss.config.js new file mode 100644 index 000000000..a65a35628 --- /dev/null +++ b/uikit/postcss.config.js @@ -0,0 +1,8 @@ +module.exports = { + plugins: { + "tailwindcss/nesting": {}, + tailwindcss: {}, + autoprefixer: {}, + "postcss-import": {}, + }, +}; diff --git a/uikit/src/avatar/index.tsx b/uikit/src/avatar/index.tsx new file mode 100644 index 000000000..bdacbba4a --- /dev/null +++ b/uikit/src/avatar/index.tsx @@ -0,0 +1,43 @@ +'use client' + +import { forwardRef, ElementRef, ComponentPropsWithoutRef } from 'react' +import * as AvatarPrimitive from '@radix-ui/react-avatar' +import { twMerge } from 'tailwind-merge' + +const Avatar = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/uikit/src/avatar/styles.scss b/uikit/src/avatar/styles.scss new file mode 100644 index 000000000..dacae26b5 --- /dev/null +++ b/uikit/src/avatar/styles.scss @@ -0,0 +1,11 @@ +.avatar { + @apply relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full; + + &-image { + @apply aspect-square h-full w-full; + } + + &-fallback { + @apply bg-muted flex h-full w-full items-center justify-center rounded-full font-bold uppercase; + } +} diff --git a/uikit/src/badge/index.tsx b/uikit/src/badge/index.tsx new file mode 100644 index 000000000..a2eeaac2d --- /dev/null +++ b/uikit/src/badge/index.tsx @@ -0,0 +1,30 @@ +import * as React from 'react' +import { cva, type VariantProps } from 'class-variance-authority' +import { twMerge } from 'tailwind-merge' + +const badgeVariants = cva('badge', { + variants: { + themes: { + primary: 'badge-primary', + success: 'badge-success', + secondary: 'badge-secondary', + danger: 'badge-danger', + outline: 'badge-outline', + }, + }, + defaultVariants: { + themes: 'primary', + }, +}) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, themes, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/uikit/src/badge/styles.scss b/uikit/src/badge/styles.scss new file mode 100644 index 000000000..e5a783d88 --- /dev/null +++ b/uikit/src/badge/styles.scss @@ -0,0 +1,23 @@ +.badge { + @apply focus:ring-ring border-border inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2; + + &-primary { + @apply bg-primary text-primary-foreground hover:bg-primary/80 border-transparent; + } + + &-success { + @apply border-transparent bg-green-500 text-green-900 hover:bg-green-500/80; + } + + &-secondary { + @apply bg-secondary text-secondary-foreground hover:bg-secondary/80; + } + + &-danger { + @apply bg-danger text-danger-foreground hover:bg-danger/80 border-transparent; + } + + &-outline { + @apply text-foreground border-border border; + } +} diff --git a/uikit/src/button/index.tsx b/uikit/src/button/index.tsx new file mode 100644 index 000000000..943e2bc34 --- /dev/null +++ b/uikit/src/button/index.tsx @@ -0,0 +1,98 @@ +'use client' + +import { forwardRef, ButtonHTMLAttributes } from 'react' +import { Slot } from '@radix-ui/react-slot' +import { cva, type VariantProps } from 'class-variance-authority' + +import { twMerge } from 'tailwind-merge' + +const buttonVariants = cva('btn', { + variants: { + themes: { + primary: 'btn-primary', + danger: 'btn-danger', + outline: 'btn-outline', + secondary: 'btn-secondary', + ghost: 'btn-ghost', + }, + size: { + sm: 'btn-sm', + md: 'btn-md', + lg: 'btn-lg', + }, + block: { + true: 'w-full', + }, + loading: { + true: 'btn-loading', + }, + }, + defaultVariants: { + themes: 'primary', + size: 'md', + }, +}) + +export interface ButtonProps + extends ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = forwardRef( + ( + { + className, + themes, + size, + block, + loading, + asChild = false, + children, + ...props + }, + ref + ) => { + const Comp = asChild ? Slot : 'button' + return ( + + {loading ? ( + <> + + {children} + + ) : ( + children + )} + + ) + } +) +Button.displayName = 'Button' + +export { Button, buttonVariants } diff --git a/uikit/src/button/styles.scss b/uikit/src/button/styles.scss new file mode 100644 index 000000000..08b59537f --- /dev/null +++ b/uikit/src/button/styles.scss @@ -0,0 +1,64 @@ +.btn { + @apply inline-flex items-center justify-center whitespace-nowrap rounded-md font-semibold transition-colors; + @apply focus-visible:ring-ring cursor-pointer focus-visible:outline-none focus-visible:ring-1; + @apply disabled:pointer-events-none disabled:opacity-50; + + &-primary { + @apply bg-primary hover:bg-primary/90 text-white; + } + + &-danger { + @apply bg-danger text-danger-foreground hover:bg-danger/90; + } + + &-outline { + @apply border-input hover:bg-accent hover:text-accent-foreground border bg-transparent; + } + + &-secondary { + @apply bg-secondary text-secondary-foreground hover:bg-secondary/80; + } + + &-ghost { + @apply hover:bg-accent hover:text-accent-foreground; + } + + &-sm { + @apply h-7 rounded-md px-3 text-xs; + } + + &-md { + @apply h-9 px-4 py-2; + } + + &-lg { + @apply h-10 rounded-md px-8; + } + + &-loading { + @apply pointer-events-none opacity-50; + &-circle { + @apply mr-2 h-4 animate-spin opacity-50; + > circle { + opacity: 0.25; + } + > path { + opacity: 0.75; + } + } + } +} + +[type='button'], +[type='reset'], +[type='submit'] { + &.btn-primary { + @apply bg-primary hover:bg-primary/90; + } + &.btn-secondary { + @apply bg-secondary hover:bg-secondary/80; + } + &.btn-danger { + @apply bg-danger hover:bg-danger/90; + } +} diff --git a/uikit/src/command/index.tsx b/uikit/src/command/index.tsx new file mode 100644 index 000000000..0d87d72c9 --- /dev/null +++ b/uikit/src/command/index.tsx @@ -0,0 +1,132 @@ +'use client' + +import * as React from 'react' +import { DialogProps } from '@radix-ui/react-dialog' +import { Command as CommandPrimitive } from 'cmdk' +import { Search } from 'lucide-react' + +import { Modal, ModalContent } from '../modal' + +import { twMerge } from 'tailwind-merge' + +const Command = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Command.displayName = CommandPrimitive.displayName + +interface CommandModalProps extends DialogProps {} + +const CommandModal = ({ children, ...props }: CommandModalProps) => { + return ( + + + + {children} + + + + ) +} + +const CommandInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ + +
+)) + +CommandInput.displayName = CommandPrimitive.Input.displayName + +const CommandList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandList.displayName = CommandPrimitive.List.displayName + +const CommandEmpty = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + +)) + +CommandEmpty.displayName = CommandPrimitive.Empty.displayName + +const CommandGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandGroup.displayName = CommandPrimitive.Group.displayName + +const CommandSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +CommandSeparator.displayName = CommandPrimitive.Separator.displayName + +const CommandItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandItem.displayName = CommandPrimitive.Item.displayName + +const CommandShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return +} +CommandShortcut.displayName = 'CommandShortcut' + +export { + Command, + CommandModal, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +} diff --git a/uikit/src/command/styles.scss b/uikit/src/command/styles.scss new file mode 100644 index 000000000..cbcbbf4d6 --- /dev/null +++ b/uikit/src/command/styles.scss @@ -0,0 +1,45 @@ +.command { + @apply bg-background/80 text-muted-foreground flex h-full w-full flex-col overflow-hidden rounded-md text-left; + + &-modal-content { + @apply overflow-hidden p-0; + > .modal-close { + top: 12px; + } + } + + &-input-wrapper { + @apply border-border flex items-center border-b px-3; + } + + &-input { + @apply placeholder:text-muted-foreground flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50; + } + + &-search-icon { + @apply mr-2 h-4 w-4 shrink-0 opacity-50; + } + + &-list { + @apply max-h-[300px] overflow-y-auto overflow-x-hidden py-2; + } + + &-list-item { + @apply text-foreground aria-selected:bg-accent relative flex cursor-pointer select-none items-center rounded-md px-2 py-2 text-sm outline-none; + } + + &-empty { + @apply py-6 text-center text-sm; + } + + &-group { + @apply text-muted-foreground overflow-hidden p-1 px-2 py-1.5 text-xs font-medium; + > [cmdk-group-heading] { + @apply mb-2 pl-2; + } + } + + &-sc { + @apply text-muted-foreground ml-auto text-xs tracking-widest; + } +} diff --git a/uikit/src/form/index.tsx b/uikit/src/form/index.tsx new file mode 100644 index 000000000..1a4abdb6d --- /dev/null +++ b/uikit/src/form/index.tsx @@ -0,0 +1,175 @@ +import * as React from 'react' +import * as LabelPrimitive from '@radix-ui/react-label' +import { Slot } from '@radix-ui/react-slot' +import { + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext, +} from 'react-hook-form' +import { twMerge } from 'tailwind-merge' + +const Form = FormProvider + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + name: TName +} + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue +) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ) +} + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState, formState } = useFormContext() + + const fieldState = getFieldState(fieldContext.name, formState) + + if (!fieldContext) { + throw new Error('useFormField should be used within ') + } + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + } +} + +type FormItemContextValue = { + id: string +} + +const FormItemContext = React.createContext( + {} as FormItemContextValue +) + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId() + + return ( + +
+ + ) +}) +FormItem.displayName = 'FormItem' + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField() + + return ( +