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/messages/en.json b/messages/en.json index 4e2a872..8128340 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1,5 +1,9 @@ { "$schema": "https://inlang.com/schema/inlang-message-format", "app_name": "Devsantara Kit", - "app_description": "The blueprint for your next big idea" + "app_description": "The blueprint for your next big idea", + + "common_error_something_went_wrong": "Something went wrong", + "common_error_form_validation_title": "There is something wrong with the form", + "common_error_form_validation_description": "Please review the form and correct them to continue." } diff --git a/messages/id.json b/messages/id.json index 3f79448..e8ec8b5 100644 --- a/messages/id.json +++ b/messages/id.json @@ -1,5 +1,9 @@ { "$schema": "https://inlang.com/schema/inlang-message-format", "app_name": "Devsantara Kit", - "app_description": "Rancangan dasar untuk ide besar kamu berikutnya" + "app_description": "Rancangan dasar untuk ide besar kamu berikutnya", + + "common_error_something_went_wrong": "Terjadi kesalahan", + "common_error_form_validation_title": "Ada kesalahan pada formulir", + "common_error_form_validation_description": "Silakan periksa kembali formulir dan perbaiki untuk melanjutkan." } diff --git a/messages/zh-CN.json b/messages/zh-CN.json index 095667c..b681e84 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -1,5 +1,9 @@ { "$schema": "https://inlang.com/schema/inlang-message-format", "app_name": "Devsantara 工具包", - "app_description": "您下一个伟大构想的基石计划" + "app_description": "您下一个伟大构想的基石计划", + + "common_error_something_went_wrong": "出现了一些问题", + "common_error_form_validation_title": "表单存在问题", + "common_error_form_validation_description": "请检查表单并更正后继续。" } diff --git a/package.json b/package.json index 7019869..904e75c 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,9 @@ "@posthog/react": "^1.5.2", "@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 19416bb..6791c98 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,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) @@ -3090,6 +3099,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'} @@ -3102,10 +3132,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'} @@ -3115,6 +3157,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'} @@ -3228,6 +3293,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==} @@ -3899,6 +3967,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'} @@ -3911,6 +3982,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: @@ -3963,6 +4037,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'} @@ -9053,6 +9130,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 @@ -9087,8 +9175,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) @@ -9102,6 +9214,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) @@ -9299,6 +9444,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': {} @@ -10005,12 +10152,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: {} @@ -10047,6 +10198,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/src/lib/form/components/form-checkbox-multiple.tsx b/src/lib/form/components/form-checkbox-multiple.tsx new file mode 100644 index 0000000..79e8e01 --- /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]; +} 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..faf0c9a --- /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 = 'default', + ...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]; +} diff --git a/src/lib/form/components/form-error.tsx b/src/lib/form/components/form-error.tsx new file mode 100644 index 0000000..d3a69b0 --- /dev/null +++ b/src/lib/form/components/form-error.tsx @@ -0,0 +1,43 @@ +import { AlertCircleIcon } from 'lucide-react'; + +import { useFormContext } from '~/lib/form/form.context'; +import { checkIsFormError } from '~/lib/form/form.utils'; +import { m } from '~/lib/i18n/messages'; +import { + Alert, + AlertDescription, + AlertTitle, +} from '~/ui/components/core/alert'; + +export function FormError() { + const form = useFormContext(); + + return ( + state.errorMap.onSubmit}> + {(formSubmitError) => { + if (!formSubmitError) return null; + + // On form submission, errors can take two shapes: + // 1. Validation errors - an object with field keys mapped to their validation error messages + // 2. Form-level errors - manually set errors, typically used when form submission fails + const isFormError = checkIsFormError(formSubmitError); + + const title = isFormError + ? formSubmitError.title || m.common_error_something_went_wrong() + : m.common_error_form_validation_title(); + + const message = isFormError + ? formSubmitError.message || null + : m.common_error_form_validation_description(); + + return ( + + + {title} + {message && {message}} + + ); + }} + + ); +} 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-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..7acdf66 --- /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]; +} 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-select.tsx b/src/lib/form/components/form-select.tsx new file mode 100644 index 0000000..cce118c --- /dev/null +++ b/src/lib/form/components/form-select.tsx @@ -0,0 +1,73 @@ +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; + disabled?: boolean; +} + +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-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/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 ( +