From 84b4274ad14e5a0ad580093fe8609ab43d7c0b2e Mon Sep 17 00:00:00 2001 From: Edwin Tantawi Date: Tue, 20 Jan 2026 10:47:01 +0700 Subject: [PATCH 01/16] feat: setup tanstack form --- .oxlintrc.json | 3 + package.json | 3 + pnpm-lock.yaml | 153 +++++++++++++++++++++++++++++++ src/devtools/form-devtools.tsx | 13 +++ src/devtools/router-devtools.tsx | 1 + vite.config.ts | 2 +- 6 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 src/devtools/form-devtools.tsx diff --git a/.oxlintrc.json b/.oxlintrc.json index cf6e13a..f907117 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -27,6 +27,9 @@ "import" ], "rules": { + // Correctness + "react/no-children-prop": ["off"], + // Perf "no-await-in-loop": "error", "no-useless-call": "error", diff --git a/package.json b/package.json index bb34b4e..911a032 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,9 @@ "@radix-ui/react-tooltip": "^1.2.8", "@t3-oss/env-core": "^0.13.10", "@tailwindcss/vite": "^4.1.18", + "@tanstack/react-form": "^1.27.7", + "@tanstack/react-form-devtools": "^0.2.12", + "@tanstack/react-form-start": "^1.27.7", "@tanstack/react-router": "^1.144.0", "@tanstack/react-start": "^1.145.5", "class-variance-authority": "^0.7.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4165d7b..353274e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -105,6 +105,15 @@ importers: '@tailwindcss/vite': specifier: ^4.1.18 version: 4.1.18(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) + '@tanstack/react-form': + specifier: ^1.27.7 + version: 1.27.7(@tanstack/react-start@1.145.5(crossws@0.4.1(srvx@0.10.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tanstack/react-form-devtools': + specifier: ^0.2.12 + version: 0.2.12(@types/react@19.2.7)(csstype@3.2.3)(preact@10.28.1)(react@19.2.3)(solid-js@1.9.10) + '@tanstack/react-form-start': + specifier: ^1.27.7 + version: 1.27.7(@tanstack/react-start@1.145.5(crossws@0.4.1(srvx@0.10.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-router': specifier: ^1.144.0 version: 1.144.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -3113,6 +3122,27 @@ packages: peerDependencies: solid-js: '>=1.9.7' + '@tanstack/devtools-utils@0.3.0': + resolution: {integrity: sha512-JgApXVrgtgSLIPrm/QWHx0u6c9Ji0MNMDWhwujapj8eMzux5aOfi+2Ycwzj0A0qITXA12SEPYV3HC568mDtYmQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/react': '>=17.0.0' + preact: '>=10.0.0' + react: '>=17.0.0' + solid-js: '>=1.9.7' + vue: '>=3.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + preact: + optional: true + react: + optional: true + solid-js: + optional: true + vue: + optional: true + '@tanstack/devtools-vite@0.4.0': resolution: {integrity: sha512-vZ5SsjcLSLC+lBb4N6QDJEdrsrORs8OtIcwQafexAR7aJOv6SxGNoqERujEbTzfWY+PAypa1oYxPqtEAOcitDw==} engines: {node: '>=18'} @@ -3125,10 +3155,22 @@ packages: peerDependencies: solid-js: '>=1.9.7' + '@tanstack/form-core@1.27.7': + resolution: {integrity: sha512-nvogpyE98fhb0NDw1Bf2YaCH+L7ZIUgEpqO9TkHucDn6zg3ni521boUpv0i8HKIrmmFwDYjWZoCnrgY4HYWTkw==} + + '@tanstack/form-devtools@0.2.12': + resolution: {integrity: sha512-+X4i4aKszU04G5ID3Q/lslKpmop6QfV9To8MdEzEGGGBakKPtilFzKq+xSpcqd/DPtq2+LtbCSZWQP9CJhInnA==} + peerDependencies: + solid-js: '>=1.9.9' + '@tanstack/history@1.141.0': resolution: {integrity: sha512-LS54XNyxyTs5m/pl1lkwlg7uZM3lvsv2FIIV1rsJgnfwVCnI+n4ZGZ2CcjNT13BPu/3hPP+iHmliBSscJxW5FQ==} engines: {node: '>=12'} + '@tanstack/pacer-lite@0.1.1': + resolution: {integrity: sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w==} + engines: {node: '>=18'} + '@tanstack/react-devtools@0.9.0': resolution: {integrity: sha512-Lq0svXOTG5N61SHgx8F0on6zz2GB0kmFjN/yyfNLrJyRgJ+U3jYFRd9ti3uBPABsXzHQMHYYujnTXrOYp/OaUg==} engines: {node: '>=18'} @@ -3138,6 +3180,29 @@ packages: react: '>=16.8' react-dom: '>=16.8' + '@tanstack/react-form-devtools@0.2.12': + resolution: {integrity: sha512-6m95ZKJyfER5mUp7DR7/FtsDoVmgHS8NgOkh3Z/pr1tGEnomK+HULuZZJd7lfT3r9tCDuC4rjPNZYLpzq3kdxA==} + peerDependencies: + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/react-form-start@1.27.7': + resolution: {integrity: sha512-9JTxhCTCnrYK35xaJ78KUhq7Ek9SXyuErQ3WMDJtzc31XZtAEDkIigh/aSEtL+lkc/5ZDgo20jXxLbdtsfcotA==} + peerDependencies: + '@tanstack/react-start': ^1.134.9 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@tanstack/react-start': + optional: true + + '@tanstack/react-form@1.27.7': + resolution: {integrity: sha512-xTg4qrUY0fuLaSnkATLZcK3BWlnwLp7IuAb6UTbZKngiDEvvDCNTvVvHgPlgef1O2qN4klZxInRyRY6oEkXZ2A==} + peerDependencies: + '@tanstack/react-start': '*' + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@tanstack/react-start': + optional: true + '@tanstack/react-router-devtools@1.144.0': resolution: {integrity: sha512-nstjZvZbOM4U0/Hzi82rtsP1DsR2tfigBidK+WuaDRVVstBsnwVor3DQXTGY5CcfgIiMI3eKzI17VOy3SQDDoQ==} engines: {node: '>=12'} @@ -3251,6 +3316,9 @@ packages: resolution: {integrity: sha512-DuUx5CXfLNettyJlsHDQp66y5haeqzXJkUor7kp5p10SVv24p76dTYqBOpw+wQz//RfJlOciIZFVBcKezXXY0w==} engines: {node: '>=22.12.0'} + '@tanstack/store@0.7.7': + resolution: {integrity: sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ==} + '@tanstack/store@0.8.0': resolution: {integrity: sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ==} @@ -3925,6 +3993,9 @@ packages: date-fns@4.1.0: resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + dayjs@1.11.19: + resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -3937,6 +4008,9 @@ packages: decimal.js-light@2.5.1: resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decode-formdata@0.9.0: + resolution: {integrity: sha512-q5uwOjR3Um5YD+ZWPOF/1sGHVW9A5rCrRwITQChRXlmPkxDFBqCm4jNTIVdGHNH9OnR+V9MoZVgRhsFb+ARbUw==} + dedent@1.5.1: resolution: {integrity: sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg==} peerDependencies: @@ -3989,6 +4063,9 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + devalue@5.6.2: + resolution: {integrity: sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==} + diff@8.0.2: resolution: {integrity: sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==} engines: {node: '>=0.3.1'} @@ -8988,6 +9065,17 @@ snapshots: transitivePeerDependencies: - csstype + '@tanstack/devtools-utils@0.3.0(@types/react@19.2.7)(csstype@3.2.3)(preact@10.28.1)(react@19.2.3)(solid-js@1.9.10)': + dependencies: + '@tanstack/devtools-ui': 0.4.4(csstype@3.2.3)(solid-js@1.9.10) + optionalDependencies: + '@types/react': 19.2.7 + preact: 10.28.1 + react: 19.2.3 + solid-js: 1.9.10 + transitivePeerDependencies: + - csstype + '@tanstack/devtools-vite@0.4.0(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@babel/core': 7.28.5 @@ -9022,8 +9110,32 @@ snapshots: - csstype - utf-8-validate + '@tanstack/form-core@1.27.7': + dependencies: + '@tanstack/devtools-event-client': 0.4.0 + '@tanstack/pacer-lite': 0.1.1 + '@tanstack/store': 0.7.7 + + '@tanstack/form-devtools@0.2.12(@types/react@19.2.7)(csstype@3.2.3)(preact@10.28.1)(react@19.2.3)(solid-js@1.9.10)': + dependencies: + '@tanstack/devtools-ui': 0.4.4(csstype@3.2.3)(solid-js@1.9.10) + '@tanstack/devtools-utils': 0.3.0(@types/react@19.2.7)(csstype@3.2.3)(preact@10.28.1)(react@19.2.3)(solid-js@1.9.10) + '@tanstack/form-core': 1.27.7 + clsx: 2.1.1 + dayjs: 1.11.19 + goober: 2.1.18(csstype@3.2.3) + solid-js: 1.9.10 + transitivePeerDependencies: + - '@types/react' + - csstype + - preact + - react + - vue + '@tanstack/history@1.141.0': {} + '@tanstack/pacer-lite@0.1.1': {} + '@tanstack/react-devtools@0.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)': dependencies: '@tanstack/devtools': 0.10.1(csstype@3.2.3)(solid-js@1.9.10) @@ -9037,6 +9149,39 @@ snapshots: - solid-js - utf-8-validate + '@tanstack/react-form-devtools@0.2.12(@types/react@19.2.7)(csstype@3.2.3)(preact@10.28.1)(react@19.2.3)(solid-js@1.9.10)': + dependencies: + '@tanstack/devtools-utils': 0.3.0(@types/react@19.2.7)(csstype@3.2.3)(preact@10.28.1)(react@19.2.3)(solid-js@1.9.10) + '@tanstack/form-devtools': 0.2.12(@types/react@19.2.7)(csstype@3.2.3)(preact@10.28.1)(react@19.2.3)(solid-js@1.9.10) + react: 19.2.3 + transitivePeerDependencies: + - '@types/react' + - csstype + - preact + - solid-js + - vue + + '@tanstack/react-form-start@1.27.7(@tanstack/react-start@1.145.5(crossws@0.4.1(srvx@0.10.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@tanstack/react-form': 1.27.7(@tanstack/react-start@1.145.5(crossws@0.4.1(srvx@0.10.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + decode-formdata: 0.9.0 + devalue: 5.6.2 + react: 19.2.3 + optionalDependencies: + '@tanstack/react-start': 1.145.5(crossws@0.4.1(srvx@0.10.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) + transitivePeerDependencies: + - react-dom + + '@tanstack/react-form@1.27.7(@tanstack/react-start@1.145.5(crossws@0.4.1(srvx@0.10.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@tanstack/form-core': 1.27.7 + '@tanstack/react-store': 0.8.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + optionalDependencies: + '@tanstack/react-start': 1.145.5(crossws@0.4.1(srvx@0.10.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) + transitivePeerDependencies: + - react-dom + '@tanstack/react-router-devtools@1.144.0(@tanstack/react-router@1.144.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.144.0)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)': dependencies: '@tanstack/react-router': 1.144.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -9234,6 +9379,8 @@ snapshots: dependencies: '@tanstack/router-core': 1.144.0 + '@tanstack/store@0.7.7': {} + '@tanstack/store@0.8.0': {} '@tanstack/virtual-file-routes@1.145.4': {} @@ -9942,12 +10089,16 @@ snapshots: date-fns@4.1.0: {} + dayjs@1.11.19: {} + debug@4.4.3: dependencies: ms: 2.1.3 decimal.js-light@2.5.1: {} + decode-formdata@0.9.0: {} + dedent@1.5.1: {} deep-eql@5.0.2: {} @@ -9984,6 +10135,8 @@ snapshots: detect-node-es@1.1.0: {} + devalue@5.6.2: {} + diff@8.0.2: {} doctrine@3.0.0: diff --git a/src/devtools/form-devtools.tsx b/src/devtools/form-devtools.tsx new file mode 100644 index 0000000..6ca2da3 --- /dev/null +++ b/src/devtools/form-devtools.tsx @@ -0,0 +1,13 @@ +import type { TanStackDevtoolsReactPlugin } from '@tanstack/react-devtools'; +import { FormDevtoolsPanel } from '@tanstack/react-form-devtools'; + +/** + * Currently this cause error during dependency optimization when using Cloudflare. + * @see {@link https://github.com/TanStack/devtools/issues/91} + * @see {@link https://github.com/TanStack/devtools/issues/187} + * @see {@link https://github.com/TanStack/devtools/issues/289} + */ +export const tanstackFormDevtools: TanStackDevtoolsReactPlugin = { + name: 'TanStack Form', + render: , +}; diff --git a/src/devtools/router-devtools.tsx b/src/devtools/router-devtools.tsx index 36092a8..a682af9 100644 --- a/src/devtools/router-devtools.tsx +++ b/src/devtools/router-devtools.tsx @@ -4,4 +4,5 @@ import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'; export const tanstackRouterDevtools: TanStackDevtoolsReactPlugin = { name: 'TanStack Router', render: , + defaultOpen: true, }; diff --git a/vite.config.ts b/vite.config.ts index 8d6776e..003e4bf 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -39,8 +39,8 @@ export default async function viteConfig({ mode }: ConfigEnv) { }, }, plugins: [ - alchemy({ viteEnvironment: { name: 'ssr' } }), devtools(), + alchemy({ viteEnvironment: { name: 'ssr' } }), tailwindcss(), tsConfigPaths({ projects: ['./tsconfig.json'] }), tanstackStart({ srcDirectory: 'src', router: { routeToken: 'layout' } }), From 6366fc0afc4df017fc67416ec85cae4736c12420 Mon Sep 17 00:00:00 2001 From: Edwin Tantawi Date: Thu, 22 Jan 2026 11:14:13 +0700 Subject: [PATCH 02/16] fix(ui): invalid switch disable selector --- src/ui/components/core/switch.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/components/core/switch.tsx b/src/ui/components/core/switch.tsx index f67484c..314a468 100644 --- a/src/ui/components/core/switch.tsx +++ b/src/ui/components/core/switch.tsx @@ -15,7 +15,7 @@ function Switch({ data-slot="switch" data-size={size} className={cn( - 'peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 data-disabled:cursor-not-allowed data-disabled:opacity-50 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 dark:data-[state=unchecked]:bg-input/80', + 'peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 dark:data-[state=unchecked]:bg-input/80', className, )} {...props} From 3cd2900cf8eafa1c8eb413ee272966ccac0329e9 Mon Sep 17 00:00:00 2001 From: Edwin Tantawi Date: Thu, 22 Jan 2026 14:02:53 +0700 Subject: [PATCH 03/16] feat(form): add base composition form and field components --- src/lib/form/components/form-error.tsx | 27 ++++++++++++++++ src/lib/form/components/form-field-error.tsx | 15 +++++++++ src/lib/form/components/form-field-label.tsx | 13 ++++++++ src/lib/form/components/form-field.tsx | 14 +++++++++ src/lib/form/components/form-fieldset.tsx | 22 +++++++++++++ src/lib/form/components/form-reset.tsx | 33 ++++++++++++++++++++ src/lib/form/components/form-root.tsx | 21 +++++++++++++ src/lib/form/components/form-submit.tsx | 27 ++++++++++++++++ src/lib/form/form.context.tsx | 4 +++ src/lib/form/form.utils.ts | 6 ++++ src/lib/form/index.tsx | 29 +++++++++++++++++ 11 files changed, 211 insertions(+) create mode 100644 src/lib/form/components/form-error.tsx create mode 100644 src/lib/form/components/form-field-error.tsx create mode 100644 src/lib/form/components/form-field-label.tsx create mode 100644 src/lib/form/components/form-field.tsx create mode 100644 src/lib/form/components/form-fieldset.tsx create mode 100644 src/lib/form/components/form-reset.tsx create mode 100644 src/lib/form/components/form-root.tsx create mode 100644 src/lib/form/components/form-submit.tsx create mode 100644 src/lib/form/form.context.tsx create mode 100644 src/lib/form/form.utils.ts create mode 100644 src/lib/form/index.tsx diff --git a/src/lib/form/components/form-error.tsx b/src/lib/form/components/form-error.tsx new file mode 100644 index 0000000..fd30efc --- /dev/null +++ b/src/lib/form/components/form-error.tsx @@ -0,0 +1,27 @@ +import { AlertCircleIcon } from 'lucide-react'; + +import { useFormContext } from '~/lib/form/form.context'; +import { + Alert, + AlertDescription, + AlertTitle, +} from '~/ui/components/core/alert'; + +export function FormError({ title }: { title?: string }) { + const form = useFormContext(); + + return ( + state.errorMap.onSubmit}> + {(formSubmitError) => { + if (!formSubmitError) return null; + return ( + + + {title || 'Something went wrong'} + {formSubmitError} + + ); + }} + + ); +} diff --git a/src/lib/form/components/form-field-error.tsx b/src/lib/form/components/form-field-error.tsx new file mode 100644 index 0000000..0e396de --- /dev/null +++ b/src/lib/form/components/form-field-error.tsx @@ -0,0 +1,15 @@ +import * as React from 'react'; + +import { useFieldContext } from '~/lib/form/form.context'; +import { checkIsInvalidField } from '~/lib/form/form.utils'; +import { FieldError } from '~/ui/components/core/field'; + +export function FormFieldError({ + ...props +}: Omit, 'errors'>) { + const field = useFieldContext(); + const isInvalid = checkIsInvalidField(field); + + if (!isInvalid) return null; + return ; +} diff --git a/src/lib/form/components/form-field-label.tsx b/src/lib/form/components/form-field-label.tsx new file mode 100644 index 0000000..25409b4 --- /dev/null +++ b/src/lib/form/components/form-field-label.tsx @@ -0,0 +1,13 @@ +import * as React from 'react'; + +import { useFieldContext } from '~/lib/form/form.context'; +import { FieldLabel } from '~/ui/components/core/field'; + +export function FormFieldLabel({ + htmlFor, + ...props +}: React.ComponentPropsWithRef) { + const field = useFieldContext(); + + return ; +} diff --git a/src/lib/form/components/form-field.tsx b/src/lib/form/components/form-field.tsx new file mode 100644 index 0000000..776ab25 --- /dev/null +++ b/src/lib/form/components/form-field.tsx @@ -0,0 +1,14 @@ +import * as React from 'react'; + +import { useFieldContext } from '~/lib/form/form.context'; +import { checkIsInvalidField } from '~/lib/form/form.utils'; +import { Field } from '~/ui/components/core/field'; + +export function FormField({ + ...props +}: React.ComponentPropsWithRef) { + const field = useFieldContext(); + const isInvalid = checkIsInvalidField(field); + + return ; +} diff --git a/src/lib/form/components/form-fieldset.tsx b/src/lib/form/components/form-fieldset.tsx new file mode 100644 index 0000000..3a0950b --- /dev/null +++ b/src/lib/form/components/form-fieldset.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; + +import { useFormContext } from '~/lib/form/form.context'; +import { FieldSet } from '~/ui/components/core/field'; + +export function FormFieldSet({ + children, + disabled, + ...props +}: React.ComponentPropsWithRef) { + const form = useFormContext(); + + return ( + state.isSubmitting}> + {(isSubmitting) => ( +
+ {children} +
+ )} +
+ ); +} diff --git a/src/lib/form/components/form-reset.tsx b/src/lib/form/components/form-reset.tsx new file mode 100644 index 0000000..40c4ab6 --- /dev/null +++ b/src/lib/form/components/form-reset.tsx @@ -0,0 +1,33 @@ +import * as React from 'react'; + +import { useFormContext } from '~/lib/form/form.context'; +import { Button } from '~/ui/components/core/button'; + +export function FormReset({ + children, + ...props +}: React.ComponentPropsWithRef) { + const form = useFormContext(); + + function handleReset(event: React.MouseEvent) { + event.preventDefault(); + form.reset(); + } + + return ( + state.isSubmitting}> + {(isSubmitting) => ( + + )} + + ); +} diff --git a/src/lib/form/components/form-root.tsx b/src/lib/form/components/form-root.tsx new file mode 100644 index 0000000..7488772 --- /dev/null +++ b/src/lib/form/components/form-root.tsx @@ -0,0 +1,21 @@ +import * as React from 'react'; + +import { useFormContext } from '~/lib/form/form.context'; + +export function FormRoot({ + children, + ...props +}: React.ComponentPropsWithRef<'form'>) { + const form = useFormContext(); + + function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + form.handleSubmit(); + } + + return ( +
+ {children} +
+ ); +} diff --git a/src/lib/form/components/form-submit.tsx b/src/lib/form/components/form-submit.tsx new file mode 100644 index 0000000..b880ef7 --- /dev/null +++ b/src/lib/form/components/form-submit.tsx @@ -0,0 +1,27 @@ +import { Loader2Icon } from 'lucide-react'; +import * as React from 'react'; + +import { useFormContext } from '~/lib/form/form.context'; +import { Button } from '~/ui/components/core/button'; + +export function FormSubmit({ + children, + ...props +}: React.ComponentPropsWithRef) { + const form = useFormContext(); + + return ( + state.isSubmitting}> + {(isSubmitting) => ( + + )} + + ); +} diff --git a/src/lib/form/form.context.tsx b/src/lib/form/form.context.tsx new file mode 100644 index 0000000..a6705ea --- /dev/null +++ b/src/lib/form/form.context.tsx @@ -0,0 +1,4 @@ +import { createFormHookContexts } from '@tanstack/react-form'; + +export const { fieldContext, formContext, useFieldContext, useFormContext } = + createFormHookContexts(); diff --git a/src/lib/form/form.utils.ts b/src/lib/form/form.utils.ts new file mode 100644 index 0000000..34649b2 --- /dev/null +++ b/src/lib/form/form.utils.ts @@ -0,0 +1,6 @@ +import type { AnyFieldApi } from '@tanstack/react-form'; + +export function checkIsInvalidField(field: AnyFieldApi) { + const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid; + return isInvalid; +} diff --git a/src/lib/form/index.tsx b/src/lib/form/index.tsx new file mode 100644 index 0000000..6aff742 --- /dev/null +++ b/src/lib/form/index.tsx @@ -0,0 +1,29 @@ +import { createFormHook } from '@tanstack/react-form'; + +import { FormError } from '~/lib/form/components/form-error'; +import { FormField } from '~/lib/form/components/form-field'; +import { FormFieldError } from '~/lib/form/components/form-field-error'; +import { FormFieldLabel } from '~/lib/form/components/form-field-label'; +import { FormFieldSet } from '~/lib/form/components/form-fieldset'; +import { FormReset } from '~/lib/form/components/form-reset'; +import { FormRoot } from '~/lib/form/components/form-root'; +import { FormSubmit } from '~/lib/form/components/form-submit'; +import { fieldContext, formContext } from '~/lib/form/form.context'; + +export const { useAppForm } = createFormHook({ + fieldContext, + formContext, + fieldComponents: { + Field: FormField, + FieldSet: FormFieldSet, + Label: FormFieldLabel, + Error: FormFieldError, + }, + formComponents: { + Root: FormRoot, + FieldSet: FormFieldSet, + Error: FormError, + SubmitButton: FormSubmit, + ResetButton: FormReset, + }, +}); From 59d0151642fd843ff37774abfe4c2129019d713d Mon Sep 17 00:00:00 2001 From: Edwin Tantawi Date: Thu, 22 Jan 2026 14:08:52 +0700 Subject: [PATCH 04/16] style(form): add prefix type --- src/lib/form/index.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/lib/form/index.tsx b/src/lib/form/index.tsx index 6aff742..8d41282 100644 --- a/src/lib/form/index.tsx +++ b/src/lib/form/index.tsx @@ -16,14 +16,14 @@ export const { useAppForm } = createFormHook({ fieldComponents: { Field: FormField, FieldSet: FormFieldSet, - Label: FormFieldLabel, - Error: FormFieldError, + FieldLabel: FormFieldLabel, + FieldError: FormFieldError, }, formComponents: { Root: FormRoot, FieldSet: FormFieldSet, - Error: FormError, - SubmitButton: FormSubmit, - ResetButton: FormReset, + FormError: FormError, + FormSubmit: FormSubmit, + FormReset: FormReset, }, }); From 45de82f5ad0a28a4f832ba49cce0233907c60873 Mon Sep 17 00:00:00 2001 From: Edwin Tantawi Date: Thu, 22 Jan 2026 14:18:35 +0700 Subject: [PATCH 05/16] feat(form): add composition input field components --- .../components/form-checkbox-multiple.tsx | 122 +++++++++++++++++ .../form/components/form-checkbox-single.tsx | 105 +++++++++++++++ src/lib/form/components/form-input.tsx | 30 +++++ src/lib/form/components/form-radio-group.tsx | 123 ++++++++++++++++++ src/lib/form/components/form-select.tsx | 68 ++++++++++ src/lib/form/components/form-switch.tsx | 30 +++++ src/lib/form/components/form-textarea.tsx | 31 +++++ src/lib/form/index.tsx | 17 +++ 8 files changed, 526 insertions(+) create mode 100644 src/lib/form/components/form-checkbox-multiple.tsx create mode 100644 src/lib/form/components/form-checkbox-single.tsx create mode 100644 src/lib/form/components/form-input.tsx create mode 100644 src/lib/form/components/form-radio-group.tsx create mode 100644 src/lib/form/components/form-select.tsx create mode 100644 src/lib/form/components/form-switch.tsx create mode 100644 src/lib/form/components/form-textarea.tsx diff --git a/src/lib/form/components/form-checkbox-multiple.tsx b/src/lib/form/components/form-checkbox-multiple.tsx new file mode 100644 index 0000000..138bc4a --- /dev/null +++ b/src/lib/form/components/form-checkbox-multiple.tsx @@ -0,0 +1,122 @@ +import { useFieldContext } from '~/lib/form/form.context'; +import { checkIsInvalidField } from '~/lib/form/form.utils'; +import { Checkbox } from '~/ui/components/core/checkbox'; +import { + Field, + FieldContent, + FieldDescription, + FieldGroup, + FieldLabel, + FieldTitle, + useFieldSet, +} from '~/ui/components/core/field'; + +interface Option { + label: string; + value: string; + description?: string; + disabled?: boolean; +} + +type Variant = 'default' | 'card'; + +export function FormCheckboxMultiple({ + options, + variant = 'default', + ...props +}: React.ComponentPropsWithRef & { + options: Option[]; + variant?: Variant; +}) { + return ( + + {options.map((option) => { + return ( + + ); + })} + + ); +} + +function FormCheckboxMultipleItem({ + variant, + option, + ...props +}: React.ComponentPropsWithRef & { + option: Option; + variant?: Variant; +}) { + const field = useFieldContext(); + const fieldSet = useFieldSet(); + const isInvalid = checkIsInvalidField(field); + + const id = `${field.name}-${option.value}`; + const isDisabled = fieldSet?.disabled || option.disabled; + + function handleCheckedChange(checked: boolean) { + if (checked) { + field.pushValue(option.value); + } else { + const index = field.state.value.indexOf(option.value); + if (index > -1) { + field.removeValue(index); + } + } + } + + const checkbox = ( + + ); + + const components: Record = { + default: ( + + {checkbox} + + {option.label} + {option.description && ( + {option.description} + )} + + + ), + card: ( + + + {checkbox} + + {option.label} + {option.description && ( + {option.description} + )} + + + + ), + }; + + return components[variant ?? 'default']; +} diff --git a/src/lib/form/components/form-checkbox-single.tsx b/src/lib/form/components/form-checkbox-single.tsx new file mode 100644 index 0000000..7c900c7 --- /dev/null +++ b/src/lib/form/components/form-checkbox-single.tsx @@ -0,0 +1,105 @@ +import { useFieldContext } from '~/lib/form/form.context'; +import { checkIsInvalidField } from '~/lib/form/form.utils'; +import { Checkbox } from '~/ui/components/core/checkbox'; +import { + Field, + FieldContent, + FieldDescription, + FieldGroup, + FieldLabel, + FieldTitle, + useFieldSet, +} from '~/ui/components/core/field'; + +interface Option { + label: string; + description?: string; +} + +type Variant = 'default' | 'card'; + +export function FormCheckboxSingle({ + option, + variant, + ...props +}: React.ComponentPropsWithRef & { + option: Option; + variant?: Variant; +}) { + return ( + + + + ); +} + +function FormCheckboxSingleItem({ + variant, + option, + disabled, + ...props +}: React.ComponentPropsWithRef & { + option: Option; + variant?: Variant; +}) { + const field = useFieldContext(); + const fieldSet = useFieldSet(); + const isInvalid = checkIsInvalidField(field); + + const id = field.name; + const isDisabled = fieldSet?.disabled || disabled; + + function handleCheckedChange(checked: boolean) { + field.handleChange(checked === true); + } + + const checkbox = ( + + ); + + const components: Record = { + default: ( + + {checkbox} + + {option.label} + {option.description && ( + {option.description} + )} + + + ), + card: ( + + + {checkbox} + + {option.label} + {option.description && ( + {option.description} + )} + + + + ), + }; + + return components[variant ?? 'default']; +} diff --git a/src/lib/form/components/form-input.tsx b/src/lib/form/components/form-input.tsx new file mode 100644 index 0000000..73eb030 --- /dev/null +++ b/src/lib/form/components/form-input.tsx @@ -0,0 +1,30 @@ +import { useFieldContext } from '~/lib/form/form.context'; +import { checkIsInvalidField } from '~/lib/form/form.utils'; +import { Input } from '~/ui/components/core/input'; + +export function FormInput({ + className, + ...props +}: React.ComponentPropsWithRef) { + const field = useFieldContext(); + const isInvalid = checkIsInvalidField(field); + + const id = field.name; + + function handleChange(event: React.ChangeEvent) { + field.handleChange(event.target.value); + } + + return ( + + ); +} diff --git a/src/lib/form/components/form-radio-group.tsx b/src/lib/form/components/form-radio-group.tsx new file mode 100644 index 0000000..04cd4ba --- /dev/null +++ b/src/lib/form/components/form-radio-group.tsx @@ -0,0 +1,123 @@ +import * as React from 'react'; + +import { useFieldContext } from '~/lib/form/form.context'; +import { checkIsInvalidField } from '~/lib/form/form.utils'; +import { + Field, + FieldContent, + FieldDescription, + FieldLabel, + FieldTitle, + useFieldSet, +} from '~/ui/components/core/field'; +import { RadioGroup, RadioGroupItem } from '~/ui/components/core/radio-group'; + +interface Option { + label: string; + value: string; + description?: string; + disabled?: boolean; +} + +type Variant = 'default' | 'card'; + +export function FormRadioGroup({ + options, + variant = 'default', + ...props +}: React.ComponentPropsWithRef & { + options: Option[]; + variant?: Variant; +}) { + const field = useFieldContext(); + const fieldSet = useFieldSet(); + + const disabled = fieldSet?.disabled; + + function handleValueChange(value: string) { + field.handleChange(value); + } + + return ( + + {options.map((option) => { + return ( + + ); + })} + + ); +} + +function FormRadioGroupItem({ + variant, + option, + ...props +}: Omit, 'value'> & { + option: Option; + variant?: Variant; +}) { + const field = useFieldContext(); + const fieldSet = useFieldSet(); + const isInvalid = checkIsInvalidField(field); + + const id = `${field.name}-${option.value}`; + const isDisabled = fieldSet?.disabled || option.disabled; + + const radioGroup = ( + + ); + + const components: Record = { + default: ( + + {radioGroup} + + {option.label} + {option.description && ( + {option.description} + )} + + + ), + card: ( + + + + {option.label} + {option.description && ( + {option.description} + )} + + {radioGroup} + + + ), + }; + + return components[variant ?? 'default']; +} diff --git a/src/lib/form/components/form-select.tsx b/src/lib/form/components/form-select.tsx new file mode 100644 index 0000000..655dbe4 --- /dev/null +++ b/src/lib/form/components/form-select.tsx @@ -0,0 +1,68 @@ +import { useFieldContext } from '~/lib/form/form.context'; +import { checkIsInvalidField } from '~/lib/form/form.utils'; +import { useFieldSet } from '~/ui/components/core/field'; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from '~/ui/components/core/select'; +import { cn } from '~/ui/utils'; + +interface Option { + label: string; + value: string; +} + +export function FormSelect({ + className, + placeholder, + options, + disabled, + ...props +}: React.ComponentPropsWithRef & { + placeholder?: string; + options: Option[]; +}) { + const field = useFieldContext(); + const fieldSet = useFieldSet(); + const isInvalid = checkIsInvalidField(field); + + const id = field.name; + const isDisabled = fieldSet?.disabled || disabled; + + function handleValueChange(value: string) { + field.handleChange(value); + } + + return ( + + ); +} diff --git a/src/lib/form/components/form-switch.tsx b/src/lib/form/components/form-switch.tsx new file mode 100644 index 0000000..2cc3173 --- /dev/null +++ b/src/lib/form/components/form-switch.tsx @@ -0,0 +1,30 @@ +import { useFieldContext } from '~/lib/form/form.context'; +import { checkIsInvalidField } from '~/lib/form/form.utils'; +import { Switch } from '~/ui/components/core/switch'; + +export function FormSwitch({ + className, + ...props +}: React.ComponentPropsWithRef) { + const field = useFieldContext(); + const isInvalid = checkIsInvalidField(field); + + const id = field.name; + + function handleCheckedChange(checked: boolean) { + field.handleChange(checked); + } + + return ( + + ); +} diff --git a/src/lib/form/components/form-textarea.tsx b/src/lib/form/components/form-textarea.tsx new file mode 100644 index 0000000..fd542ce --- /dev/null +++ b/src/lib/form/components/form-textarea.tsx @@ -0,0 +1,31 @@ +import { useFieldContext } from '~/lib/form/form.context'; +import { checkIsInvalidField } from '~/lib/form/form.utils'; +import { Textarea } from '~/ui/components/core/textarea'; +import { cn } from '~/ui/utils'; + +export function FormTextarea({ + className, + ...props +}: React.ComponentPropsWithRef) { + const field = useFieldContext(); + const isInvalid = checkIsInvalidField(field); + + const id = field.name; + + function handleChange(event: React.ChangeEvent) { + field.handleChange(event.target.value); + } + + return ( +