├── .changeset
└── config.json
├── .eslintignore
├── .eslintrc.js
├── .github
└── workflows
│ ├── main.yml
│ └── release.yml
├── .gitignore
├── .npmrc
├── .prettierrc.js
├── .vscode
└── settings.json
├── LICENSE
├── README.md
├── jest.config.js
├── package.json
├── rollup.config.js
├── rollup
├── createRollupConfig.js
├── pascalcase.js
├── safePackageName.js
└── writeCjsEntryFile.js
├── src
├── __snapshots__
│ └── useTypedController.test.tsx.snap
├── index.ts
├── logic
│ ├── formatName.test.ts
│ └── formatName.ts
├── types.ts
├── useTypedController.test.tsx
└── useTypedController.tsx
├── tsconfig.json
└── yarn.lock
/.changeset/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://unpkg.com/@changesets/config@1.3.0/schema.json",
3 | "changelog": [
4 | "@changesets/changelog-github",
5 | { "repo": "react-hook-form/strictly-typed" }
6 | ],
7 | "commit": false,
8 | "linked": [],
9 | "access": "public",
10 | "baseBranch": "master",
11 | "ignore": []
12 | }
13 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | /node_modules
2 | /dist
3 | /esm
4 | /coverage
5 | !.*.js
6 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | parser: '@typescript-eslint/parser',
3 | plugins: ['@typescript-eslint', 'react-hooks'],
4 | extends: [
5 | 'plugin:react/recommended',
6 | 'plugin:@typescript-eslint/recommended',
7 | 'prettier/@typescript-eslint',
8 | 'plugin:prettier/recommended',
9 | ],
10 | parser: '@typescript-eslint/parser',
11 | parserOptions: {
12 | ecmaVersion: 2020,
13 | sourceType: 'module',
14 | ecmaFeatures: {
15 | jsx: true,
16 | },
17 | },
18 | rules: {
19 | '@typescript-eslint/no-explicit-any': 'off',
20 | '@typescript-eslint/ban-types': 'off',
21 | '@typescript-eslint/explicit-module-boundary-types': 'off',
22 | 'react-hooks/rules-of-hooks': 'error',
23 | 'react-hooks/exhaustive-deps': 'warn',
24 | },
25 | settings: {
26 | react: {
27 | version: 'detect',
28 | },
29 | },
30 | };
31 |
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 | on:
3 | push:
4 | branches:
5 | - master
6 | pull_request:
7 | branches:
8 | - master
9 | paths-ignore:
10 | - ".gitignore"
11 | - ".npmignore"
12 | - "*.md"
13 | jobs:
14 | build:
15 | runs-on: ubuntu-latest
16 | strategy:
17 | matrix:
18 | node-version: [12.x]
19 | steps:
20 | - uses: actions/checkout@v2
21 | - name: Use Node.js ${{ matrix.node-version }}
22 | uses: actions/setup-node@v1
23 | with:
24 | node-version: ${{ matrix.node-version }}
25 | - name: Install dependencies
26 | run: yarn install --frozen-lockfile
27 | - name: Lint
28 | run: |
29 | npm run lint:types
30 | npm run lint
31 | - name: Test
32 | run: npm test
33 | env:
34 | CI: true
35 | - name: Build
36 | run: npm run build
37 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 |
8 | jobs:
9 | release:
10 | runs-on: ubuntu-latest
11 |
12 | steps:
13 | - uses: actions/checkout@v2
14 | with:
15 | fetch-depth: 0
16 |
17 | - name: Use Node.js 12.x
18 | uses: actions/setup-node@v1
19 | with:
20 | version: 12.x
21 |
22 | - name: Install Dependencies
23 | run: npm i
24 |
25 | - name: Create Release Pull Request or Publish to npm
26 | uses: changesets/action@master
27 | with:
28 | publish: npm run release
29 | env:
30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
31 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
32 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | ### https://raw.github.com/github/gitignore/6c87d249af5f2b3f8ab65ae0a2648682ee4e8a2d/Global/macOS.gitignore
2 |
3 | # General
4 | .DS_Store
5 | .AppleDouble
6 | .LSOverride
7 | .idea
8 |
9 | # Icon must end with two \r
10 | Icon
11 |
12 |
13 | # Thumbnails
14 | ._*
15 |
16 | # Files that might appear in the root of a volume
17 | .DocumentRevisions-V100
18 | .fseventsd
19 | .Spotlight-V100
20 | .TemporaryItems
21 | .Trashes
22 | .VolumeIcon.icns
23 | .com.apple.timemachine.donotpresent
24 |
25 | # Directories potentially created on remote AFP share
26 | .AppleDB
27 | .AppleDesktop
28 | Network Trash Folder
29 | Temporary Items
30 | .apdisk
31 |
32 |
33 | ### https://raw.github.com/github/gitignore/6c87d249af5f2b3f8ab65ae0a2648682ee4e8a2d/Global/Windows.gitignore
34 |
35 | # Windows thumbnail cache files
36 | Thumbs.db
37 | Thumbs.db:encryptable
38 | ehthumbs.db
39 | ehthumbs_vista.db
40 |
41 | # Dump file
42 | *.stackdump
43 |
44 | # Folder config file
45 | [Dd]esktop.ini
46 |
47 | # Recycle Bin used on file shares
48 | $RECYCLE.BIN/
49 |
50 | # Windows Installer files
51 | *.cab
52 | *.msi
53 | *.msix
54 | *.msm
55 | *.msp
56 |
57 | # Windows shortcuts
58 | *.lnk
59 |
60 |
61 | ### https://raw.github.com/github/gitignore/6c87d249af5f2b3f8ab65ae0a2648682ee4e8a2d/Global/Linux.gitignore
62 |
63 | *~
64 |
65 | # temporary files which can be created if a process still has a handle open of a deleted file
66 | .fuse_hidden*
67 |
68 | # KDE directory preferences
69 | .directory
70 |
71 | # Linux trash folder which might appear on any partition or disk
72 | .Trash-*
73 |
74 | # .nfs files are created when an open file is removed but is still being accessed
75 | .nfs*
76 |
77 |
78 | ### https://raw.github.com/github/gitignore/6c87d249af5f2b3f8ab65ae0a2648682ee4e8a2d/Global/VisualStudioCode.gitignore
79 |
80 | .vscode/*
81 | !.vscode/settings.json
82 | !.vscode/tasks.json
83 | !.vscode/launch.json
84 | !.vscode/extensions.json
85 | *.code-workspace
86 |
87 |
88 | ### https://raw.github.com/github/gitignore/6c87d249af5f2b3f8ab65ae0a2648682ee4e8a2d/Node.gitignore
89 |
90 | # Logs
91 | logs
92 | *.log
93 | npm-debug.log*
94 | yarn-debug.log*
95 | yarn-error.log*
96 | lerna-debug.log*
97 |
98 | # Diagnostic reports (https://nodejs.org/api/report.html)
99 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
100 |
101 | # Runtime data
102 | pids
103 | *.pid
104 | *.seed
105 | *.pid.lock
106 |
107 | # Directory for instrumented libs generated by jscoverage/JSCover
108 | lib-cov
109 |
110 | # Coverage directory used by tools like istanbul
111 | coverage
112 | *.lcov
113 |
114 | # nyc test coverage
115 | .nyc_output
116 |
117 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
118 | .grunt
119 |
120 | # Bower dependency directory (https://bower.io/)
121 | bower_components
122 |
123 | # node-waf configuration
124 | .lock-wscript
125 |
126 | # Compiled binary addons (https://nodejs.org/api/addons.html)
127 | build/Release
128 |
129 | # Dependency directories
130 | node_modules/
131 | jspm_packages/
132 |
133 | # TypeScript v1 declaration files
134 | typings/
135 |
136 | # TypeScript cache
137 | *.tsbuildinfo
138 |
139 | # Optional npm cache directory
140 | .npm
141 |
142 | # Optional eslint cache
143 | .eslintcache
144 |
145 | # Microbundle cache
146 | .rpt2_cache/
147 | .rts2_cache_cjs/
148 | .rts2_cache_es/
149 | .rts2_cache_umd/
150 |
151 | # Optional REPL history
152 | .node_repl_history
153 |
154 | # Output of 'npm pack'
155 | *.tgz
156 |
157 | # Yarn Integrity file
158 | .yarn-integrity
159 |
160 | # dotenv environment variables file
161 | .env
162 | .env.test
163 |
164 | # parcel-bundler cache (https://parceljs.org/)
165 | .cache
166 |
167 | # Next.js build output
168 | .next
169 |
170 | # Nuxt.js build / generate output
171 | .nuxt
172 | dist
173 |
174 | # Gatsby files
175 | .cache/
176 | # Comment in the public line in if your project uses Gatsby and not Next.js
177 | # https://nextjs.org/blog/next-9-1#public-directory-support
178 | # public
179 |
180 | # vuepress build output
181 | .vuepress/dist
182 |
183 | # Serverless directories
184 | .serverless/
185 |
186 | # FuseBox cache
187 | .fusebox/
188 |
189 | # DynamoDB Local files
190 | .dynamodb/
191 |
192 | # TernJS port file
193 | .tern-port
194 |
195 | # Stores VSCode versions used for testing VSCode extensions
196 | .vscode-test
197 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | package-lock=false
--------------------------------------------------------------------------------
/.prettierrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | singleQuote: true,
3 | trailingComma: 'all',
4 | };
5 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.codeActionsOnSave": {
3 | "source.fixAll.eslint": true
4 | },
5 | "typescript.tsdk": "node_modules/typescript/lib"
6 | }
7 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Kotaro Sugawara
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
8 |
9 | Performant, flexible and extensible forms with easy to use validation.
10 |
11 |
12 |
13 | [](https://www.npmjs.com/package/@hookform/strictly-typed)
14 | [](https://www.npmjs.com/package/@hookform/strictly-typed)
15 | [](https://bundlephobia.com/result?p=@hookform/strictly-typed)
16 |
17 |
18 |
19 | ## Goal
20 |
21 | React Hook Form strictly typed custom hooks.
22 |
23 | ## Install
24 |
25 | ```
26 | $ npm install @hookform/strictly-typed
27 | ```
28 |
29 | ## Quickstart
30 |
31 | ```tsx
32 | import { useTypedController } from '@hookform/strictly-typed';
33 | import { useForm } from 'react-hook-form';
34 | import { TextField, Checkbox } from '@material-ui/core';
35 |
36 | type FormValues = {
37 | flat: string;
38 | nested: {
39 | object: { test: string };
40 | array: { test: boolean }[];
41 | };
42 | };
43 |
44 | export default function App() {
45 | const { control, handleSubmit } = useForm();
46 | const TypedController = useTypedController({ control });
47 |
48 | const onSubmit = handleSubmit((data) => console.log(data));
49 |
50 | return (
51 |
86 | );
87 | }
88 | ```
89 |
90 | [](https://codesandbox.io/s/react-hook-form-usetypedcontroller-23qv1?fontsize=14&hidenavigation=1&theme=dark)
91 |
92 | ## Name Reference
93 |
94 | | Field Path | Field Name |
95 | | :------------------ | :----------- |
96 | | `foo` | `foo` |
97 | | `['foo', 'bar']` | `foo.bar` |
98 | | `['foo', 0]` | `foo[0]` |
99 | | `['foo', '0']` | `foo.0` |
100 | | `['foo', 1]` | `foo[1]` |
101 | | `['foo', 0, 'bar']` | `foo[0].bar` |
102 | | `['foo']` | `foo` |
103 | | `['foo', 'bar']` | `foo.bar` |
104 | | `['foo', 'bar', 0]` | `foo.bar[0]` |
105 |
106 | ## API
107 |
108 | - useTypedController
109 |
110 | | Name | Type | Required |
111 | | :-------- | :------- | :------- |
112 | | `control` | `Object` | |
113 |
114 | - TypedController
115 |
116 | | Name | Type | Required |
117 | | :------------- | :-------------------------------------------- | :------- |
118 | | `name` | `string \| [string, ...(string \| number)[]]` | ✓ |
119 | | `as` | `'input' \| 'select' \| 'textarea'` | |
120 | | `render` | `Function` | |
121 | | `defaultValue` | `DeepPathValue` | |
122 | | `rules` | `Object` | |
123 | | `onFocus` | `() => void` | |
124 |
125 | ## Backers
126 |
127 | Thanks goes to all our backers! [[Become a backer](https://opencollective.com/react-hook-form#backer)].
128 |
129 |
130 |
131 |
132 |
133 | ## Organizations
134 |
135 | Thanks goes to these wonderful organizations! [[Contribute](https://opencollective.com/react-hook-form/contribute)].
136 |
137 |
138 |
139 |
140 |
141 | ## Contributors
142 |
143 | Thanks goes to these wonderful people! [[Become a contributor](CONTRIBUTING.md)].
144 |
145 |
146 |
147 |
148 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | preset: 'ts-jest',
3 | clearMocks: true,
4 | testMatch: ['/src/**/*.test.(ts|tsx)'],
5 | };
6 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@hookform/strictly-typed",
3 | "version": "0.0.4",
4 | "description": "React Hook Form strictly typed custom hooks.",
5 | "main": "dist/index.js",
6 | "module": "dist/index.esm.js",
7 | "umd:main": "dist/index.umd.production.min.js",
8 | "unpkg": "dist/index.umd.production.min.js",
9 | "jsdelivr": "dist/index.umd.production.min.js",
10 | "jsnext:main": "dist/index.esm.js",
11 | "source": "src/index.ts",
12 | "types": "dist/index.d.ts",
13 | "sideEffects": false,
14 | "files": [
15 | "dist"
16 | ],
17 | "publishConfig": {
18 | "access": "public"
19 | },
20 | "scripts": {
21 | "clean": "rimraf dist",
22 | "prebuild": "npm run clean",
23 | "build": "node rollup/writeCjsEntryFile.js && rollup -c",
24 | "lint": "eslint '**/*.{js,ts,tsx}'",
25 | "lint:fix": "npm run lint -- --fix",
26 | "lint:types": "tsc --noEmit",
27 | "test": "jest",
28 | "test:watch": "jest --watch",
29 | "test:coverage": "jest --coverage",
30 | "postversion": "git push && git push origin v$npm_package_version",
31 | "prepublishOnly": "npm run lint && npm run lint:types && npm test && npm run build",
32 | "changeset": "changeset",
33 | "release": "changeset publish"
34 | },
35 | "keywords": [
36 | "react",
37 | "react-component",
38 | "hooks",
39 | "react-hooks",
40 | "form",
41 | "forms",
42 | "form-validation",
43 | "validation",
44 | "hookform",
45 | "react-hook-form",
46 | "typescript",
47 | "typesafe"
48 | ],
49 | "repository": {
50 | "type": "git",
51 | "url": "git+https://github.com/react-hook-form/strictly-typed.git"
52 | },
53 | "author": "Kotaro Sugawara ",
54 | "license": "MIT",
55 | "bugs": {
56 | "url": "https://github.com/react-hook-form/strictly-typed/issues"
57 | },
58 | "homepage": "https://react-hook-form.com",
59 | "devDependencies": {
60 | "@changesets/changelog-github": "^0.2.7",
61 | "@changesets/cli": "^2.10.2",
62 | "@rollup/plugin-commonjs": "^13.0.0",
63 | "@rollup/plugin-json": "^4.1.0",
64 | "@rollup/plugin-node-resolve": "^8.0.1",
65 | "@rollup/plugin-replace": "^2.3.3",
66 | "@testing-library/react": "^10.3.0",
67 | "@testing-library/react-hooks": "^3.3.0",
68 | "@types/jest": "^26.0.0",
69 | "@types/react": "^16.9.38",
70 | "@types/react-dom": "^16.9.8",
71 | "@typescript-eslint/eslint-plugin": "^3.3.0",
72 | "@typescript-eslint/parser": "^3.3.0",
73 | "eslint": "^7.2.0",
74 | "eslint-config-prettier": "^6.11.0",
75 | "eslint-plugin-prettier": "^3.1.4",
76 | "eslint-plugin-react": "^7.20.0",
77 | "eslint-plugin-react-hooks": "^4.0.4",
78 | "husky": "^4.2.5",
79 | "jest": "^26.0.1",
80 | "lint-staged": "^10.2.11",
81 | "prettier": "^2.0.5",
82 | "react": "^16.13.1",
83 | "react-dom": "^16.13.1",
84 | "react-hook-form": "^6.7.0",
85 | "react-test-renderer": "^16.13.1",
86 | "rimraf": "^3.0.2",
87 | "rollup": "^2.17.0",
88 | "rollup-plugin-peer-deps-external": "^2.2.2",
89 | "rollup-plugin-sourcemaps": "^0.6.2",
90 | "rollup-plugin-terser": "^6.1.0",
91 | "rollup-plugin-typescript2": "^0.27.0",
92 | "semantic-release": "^17.0.7",
93 | "ts-jest": "^26.1.0",
94 | "typescript": "^3.9.5"
95 | },
96 | "peerDependencies": {
97 | "react": ">=16.8.0",
98 | "react-dom": ">=16.8.0",
99 | "react-hook-form": ">=6.6.0",
100 | "typescript": ">=3.0.1"
101 | },
102 | "husky": {
103 | "hooks": {
104 | "pre-commit": "npm run lint:types && lint-staged"
105 | }
106 | },
107 | "lint-staged": {
108 | "*.{js,ts,tsx}": [
109 | "npm run lint:fix"
110 | ],
111 | "*.{md,json,yml}": [
112 | "prettier --write"
113 | ]
114 | }
115 | }
116 |
--------------------------------------------------------------------------------
/rollup.config.js:
--------------------------------------------------------------------------------
1 | import { createRollupConfig } from './rollup/createRollupConfig';
2 | import pkg from './package.json';
3 |
4 | const name = 'index';
5 | const umdName = 'ReactHookFormStrictTyped';
6 | const options = [
7 | {
8 | name,
9 | umdName,
10 | format: 'cjs',
11 | env: 'development',
12 | input: pkg.source,
13 | },
14 | {
15 | name,
16 | umdName,
17 | format: 'cjs',
18 | env: 'production',
19 | input: pkg.source,
20 | },
21 | {
22 | name,
23 | umdName,
24 | format: 'esm',
25 | input: pkg.source,
26 | },
27 | {
28 | name,
29 | umdName,
30 | format: 'umd',
31 | env: 'development',
32 | input: pkg.source,
33 | },
34 | {
35 | name,
36 | umdName,
37 | format: 'umd',
38 | env: 'production',
39 | input: pkg.source,
40 | },
41 | ];
42 |
43 | export default options.map((option) => createRollupConfig(option));
44 |
--------------------------------------------------------------------------------
/rollup/createRollupConfig.js:
--------------------------------------------------------------------------------
1 | import ts from 'typescript';
2 | import path from 'path';
3 | import external from 'rollup-plugin-peer-deps-external';
4 | import json from '@rollup/plugin-json';
5 | import replace from '@rollup/plugin-replace';
6 | import typescript from 'rollup-plugin-typescript2';
7 | import resolve from '@rollup/plugin-node-resolve';
8 | import commonjs from '@rollup/plugin-commonjs';
9 | import sourcemaps from 'rollup-plugin-sourcemaps';
10 | import { terser } from 'rollup-plugin-terser';
11 | import { safePackageName } from './safePackageName';
12 | import { pascalcase } from './pascalcase';
13 | import pkg from '../package.json';
14 |
15 | export function createRollupConfig(options) {
16 | const name = options.name || safePackageName(pkg.name);
17 | const umdName = options.umdName || pascalcase(safePackageName(pkg.name));
18 | const shouldMinify = options.minify || options.env === 'production';
19 | const tsconfigPath = options.tsconfig || 'tsconfig.json';
20 | const tsconfigJSON = ts.readConfigFile(tsconfigPath, ts.sys.readFile).config;
21 | const tsCompilerOptions = ts.parseJsonConfigFileContent(
22 | tsconfigJSON,
23 | ts.sys,
24 | './',
25 | ).options;
26 |
27 | const outputName = [
28 | path.join(tsCompilerOptions.outDir, name),
29 | options.formatName || options.format,
30 | options.env,
31 | shouldMinify ? 'min' : '',
32 | 'js',
33 | ]
34 | .filter(Boolean)
35 | .join('.');
36 |
37 | return {
38 | input: options.input,
39 | output: {
40 | file: outputName,
41 | format: options.format,
42 | name: umdName,
43 | sourcemap: true,
44 | globals: {
45 | react: 'React',
46 | 'react-dom': 'ReactDOM',
47 | 'react-hook-form': 'ReactHookForm',
48 | },
49 | exports: 'named',
50 | },
51 | plugins: [
52 | external({
53 | includeDependencies: options.format !== 'umd',
54 | }),
55 | json(),
56 | typescript({
57 | tsconfig: options.tsconfig,
58 | clean: true,
59 | }),
60 | resolve(),
61 | options.format === 'umd' &&
62 | commonjs({
63 | include: /\/node_modules\//,
64 | }),
65 | options.env !== undefined &&
66 | replace({
67 | 'process.env.NODE_ENV': JSON.stringify(options.env),
68 | }),
69 | sourcemaps(),
70 | shouldMinify &&
71 | terser({
72 | output: { comments: false },
73 | compress: {
74 | drop_console: true,
75 | },
76 | }),
77 | options.plugins,
78 | ],
79 | };
80 | }
81 |
--------------------------------------------------------------------------------
/rollup/pascalcase.js:
--------------------------------------------------------------------------------
1 | const titlecase = (input) => input[0].toLocaleUpperCase() + input.slice(1);
2 |
3 | export const pascalcase = (value) => {
4 | if (value === null || value === void 0) {
5 | return '';
6 | }
7 | if (typeof value.toString !== 'function') {
8 | return '';
9 | }
10 |
11 | let input = value.toString().trim();
12 | if (input === '') {
13 | return '';
14 | }
15 | if (input.length === 1) {
16 | return input.toLocaleUpperCase();
17 | }
18 |
19 | let match = input.match(/[a-zA-Z0-9]+/g);
20 | if (match) {
21 | return match.map((m) => titlecase(m)).join('');
22 | }
23 |
24 | return input;
25 | };
26 |
--------------------------------------------------------------------------------
/rollup/safePackageName.js:
--------------------------------------------------------------------------------
1 | export const safePackageName = (name) =>
2 | name
3 | .toLowerCase()
4 | .replace(/(^@.*\/)|((^[^a-zA-Z]+)|[^\w.-])|([^a-zA-Z0-9]+$)/g, '');
5 |
--------------------------------------------------------------------------------
/rollup/writeCjsEntryFile.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-var-requires */
2 | const ts = require('typescript');
3 | const fs = require('fs-extra');
4 | const path = require('path');
5 | const pkg = require('../package.json');
6 |
7 | function writeCjsEntryFile(
8 | name = pkg.name,
9 | formatName = 'cjs',
10 | tsconfig = 'tsconfig.json',
11 | ) {
12 | const baseLine = `module.exports = require('./${name}`;
13 | const contents = `
14 | 'use strict'
15 |
16 | if (process.env.NODE_ENV === 'production') {
17 | ${baseLine}.${formatName}.production.min.js')
18 | } else {
19 | ${baseLine}.${formatName}.development.js')
20 | }
21 | `;
22 |
23 | const tsconfigJSON = ts.readConfigFile(tsconfig, ts.sys.readFile).config;
24 | const tsCompilerOptions = ts.parseJsonConfigFileContent(
25 | tsconfigJSON,
26 | ts.sys,
27 | './',
28 | ).options;
29 |
30 | const filename =
31 | formatName === 'cjs'
32 | ? [name, 'js'].join('.')
33 | : [name, formatName, 'js'].join('.');
34 |
35 | return fs.outputFile(path.join(tsCompilerOptions.outDir, filename), contents);
36 | }
37 |
38 | writeCjsEntryFile('index');
39 |
--------------------------------------------------------------------------------
/src/__snapshots__/useTypedController.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`useTypedField should render correctly when as is input and name is string 1`] = `
4 |
5 |
9 |
10 | `;
11 |
12 | exports[`useTypedField should render correctly when as is textarea and name is array 1`] = `
13 |
14 |
19 |
20 | `;
21 |
22 | exports[`useTypedField should render correctly when name is array and render is input 1`] = `
23 |
24 |
28 |
29 | `;
30 |
31 | exports[`useTypedField should render correctly when name is string and render is textarea 1`] = `
32 |
33 |
38 |
39 | `;
40 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from './useTypedController';
2 | export * from './types';
3 |
--------------------------------------------------------------------------------
/src/logic/formatName.test.ts:
--------------------------------------------------------------------------------
1 | import { formatName } from './formatName';
2 |
3 | describe('formatName', () => {
4 | const tests: [string, string | [string, ...(string | number)[]], string][] = [
5 | ['should format correctly when name is string', 'test', 'test'],
6 | [
7 | 'should combine array to string like object if name is array when contain one item',
8 | ['test1'],
9 | 'test1',
10 | ],
11 | [
12 | 'should combine array to string like object if name is array',
13 | ['test1', 'test2', 'test3'],
14 | 'test1.test2.test3',
15 | ],
16 | [
17 | 'should combine array to string like array that contain object, if name is array when contain one item',
18 | ['test1', 0],
19 | 'test1[0]',
20 | ],
21 | [
22 | 'should combine array to string like array that contain object, if name is array',
23 | ['test1', 0, 'test2', 1, 'test3'],
24 | 'test1[0].test2[1].test3',
25 | ],
26 | ];
27 |
28 | test.each(tests)('%s', (_, input, result) => {
29 | expect(formatName(input)).toBe(result);
30 | });
31 | });
32 |
--------------------------------------------------------------------------------
/src/logic/formatName.ts:
--------------------------------------------------------------------------------
1 | const isNumber = (value: unknown): value is number => typeof value === 'number';
2 |
3 | export const formatName = (
4 | name: string | [string, ...(string | number)[]],
5 | ): string => {
6 | if (Array.isArray(name)) {
7 | return name
8 | .reduce((accumulator: string[], currentValue) => {
9 | if (isNumber(currentValue)) {
10 | const lastIndex = accumulator.length - 1;
11 | accumulator[lastIndex] = `${accumulator[lastIndex]}[${currentValue}]`;
12 | return accumulator;
13 | }
14 | return [...accumulator, currentValue];
15 | }, [])
16 | .join('.');
17 | }
18 | return name;
19 | };
20 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | import {
2 | FieldValues,
3 | Control,
4 | Message,
5 | ValidationRule,
6 | ValidateResult,
7 | } from 'react-hook-form';
8 |
9 | export type ArrayWithLength = { [K in N]: any };
10 |
11 | export interface DeepPathArray
12 | extends ReadonlyArray {
13 | ['0']: keyof TValues;
14 | ['1']?: TPath extends {
15 | ['0']: infer K0;
16 | }
17 | ? K0 extends keyof TValues
18 | ? keyof TValues[K0]
19 | : never
20 | : never;
21 | ['2']?: TPath extends {
22 | ['0']: infer K0;
23 | ['1']: infer K1;
24 | }
25 | ? K0 extends keyof TValues
26 | ? K1 extends keyof TValues[K0]
27 | ? keyof TValues[K0][K1]
28 | : never
29 | : never
30 | : never;
31 | ['3']?: TPath extends {
32 | ['0']: infer K0;
33 | ['1']: infer K1;
34 | ['2']: infer K2;
35 | }
36 | ? K0 extends keyof TValues
37 | ? K1 extends keyof TValues[K0]
38 | ? K2 extends keyof TValues[K0][K1]
39 | ? keyof TValues[K0][K1][K2]
40 | : never
41 | : never
42 | : never
43 | : never;
44 | ['4']?: TPath extends {
45 | ['0']: infer K0;
46 | ['1']: infer K1;
47 | ['2']: infer K2;
48 | ['3']: infer K3;
49 | }
50 | ? K0 extends keyof TValues
51 | ? K1 extends keyof TValues[K0]
52 | ? K2 extends keyof TValues[K0][K1]
53 | ? K3 extends keyof TValues[K0][K1][K2]
54 | ? keyof TValues[K0][K1][K2][K3]
55 | : never
56 | : never
57 | : never
58 | : never
59 | : never;
60 | ['5']?: TPath extends {
61 | ['0']: infer K0;
62 | ['1']: infer K1;
63 | ['2']: infer K2;
64 | ['3']: infer K3;
65 | ['4']: infer K4;
66 | }
67 | ? K0 extends keyof TValues
68 | ? K1 extends keyof TValues[K0]
69 | ? K2 extends keyof TValues[K0][K1]
70 | ? K3 extends keyof TValues[K0][K1][K2]
71 | ? K4 extends keyof TValues[K0][K1][K2][K3]
72 | ? keyof TValues[K0][K1][K2][K3][K4]
73 | : never
74 | : never
75 | : never
76 | : never
77 | : never
78 | : never;
79 | }
80 |
81 | export type DeepPathArrayValue<
82 | TValues extends FieldValues,
83 | TPath extends DeepPathArray
84 | > = TPath extends ArrayWithLength<0 | 1 | 2 | 3 | 4 | 5 | 6>
85 | ? any
86 | : TPath extends ArrayWithLength<0 | 1 | 2 | 3 | 4 | 5>
87 | ? TValues[TPath[0]][TPath[1]][TPath[2]][TPath[3]][TPath[4]][TPath[5]]
88 | : TPath extends ArrayWithLength<0 | 1 | 2 | 3 | 4>
89 | ? TValues[TPath[0]][TPath[1]][TPath[2]][TPath[3]][TPath[4]]
90 | : TPath extends ArrayWithLength<0 | 1 | 2 | 3>
91 | ? TValues[TPath[0]][TPath[1]][TPath[2]][TPath[3]]
92 | : TPath extends ArrayWithLength<0 | 1 | 2>
93 | ? TValues[TPath[0]][TPath[1]][TPath[2]]
94 | : TPath extends ArrayWithLength<0 | 1>
95 | ? TValues[TPath[0]][TPath[1]]
96 | : TPath extends ArrayWithLength<0>
97 | ? TValues[TPath[0]]
98 | : never;
99 |
100 | export type DeepPath =
101 | | DeepPathArray
102 | | keyof TValues;
103 |
104 | export type DeepPathValue<
105 | TValues extends FieldValues,
106 | TPath extends DeepPath
107 | > = TPath extends DeepPathArray
108 | ? DeepPathArrayValue
109 | : TPath extends keyof TValues
110 | ? TValues[TPath]
111 | : any;
112 |
113 | export type Options = {
114 | control?: TControl;
115 | };
116 |
117 | export type Validate = (
118 | data: TFieldValue,
119 | ) => ValidateResult | Promise;
120 |
121 | export type ValidationRules = Partial<{
122 | required: Message | ValidationRule;
123 | min: ValidationRule;
124 | max: ValidationRule;
125 | maxLength: ValidationRule;
126 | minLength: ValidationRule;
127 | pattern: ValidationRule;
128 | validate: Validate | Record>;
129 | }>;
130 |
131 | export type Assign = TValues &
132 | Omit;
133 |
134 | export type ControllerProps<
135 | TFieldValues extends FieldValues,
136 | TFieldName extends DeepPath,
137 | TAs extends 'input' | 'select' | 'textarea'
138 | > = Assign<
139 | {
140 | name: TFieldName;
141 | as?: TAs;
142 | rules?: ValidationRules>;
143 | onFocus?: () => void;
144 | defaultValue?: DeepPathValue;
145 | render?: (props: {
146 | onChange: (...event: any[]) => void;
147 | onBlur: () => void;
148 | value: DeepPathValue;
149 | }) => React.ReactElement;
150 | },
151 | JSX.IntrinsicElements[TAs]
152 | >;
153 |
--------------------------------------------------------------------------------
/src/useTypedController.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Control } from 'react-hook-form';
3 | import { render } from '@testing-library/react';
4 | import { renderHook } from '@testing-library/react-hooks';
5 | import { useTypedController } from './useTypedController';
6 |
7 | export const reconfigureControl = (
8 | controlOverrides: Partial = {},
9 | ): Control => ({
10 | unmountFieldsStateRef: {
11 | current: {},
12 | },
13 | defaultValuesRef: {
14 | current: {},
15 | },
16 | isWatchAllRef: {
17 | current: false,
18 | },
19 | validFieldsRef: {
20 | current: new Set(),
21 | },
22 | fieldsWithValidationRef: {
23 | current: new Set(),
24 | },
25 | fieldArrayDefaultValues: {
26 | current: {},
27 | },
28 | watchFieldsRef: {
29 | current: new Set(),
30 | },
31 | watchFieldsHookRef: {
32 | current: {},
33 | },
34 | watchFieldsHookRenderRef: {
35 | current: {},
36 | },
37 | watchInternal: jest.fn(),
38 | validateResolver: jest.fn(),
39 | setValue: jest.fn(),
40 | getValues: jest.fn(),
41 | register: jest.fn(),
42 | unregister: jest.fn(),
43 | trigger: jest.fn(),
44 | removeFieldEventListener: jest.fn(),
45 | mode: {
46 | isOnSubmit: false,
47 | isOnBlur: false,
48 | isOnChange: false,
49 | isOnTouch: false,
50 | isOnAll: false,
51 | },
52 | reValidateMode: {
53 | isReValidateOnBlur: false,
54 | isReValidateOnChange: false,
55 | },
56 | formStateRef: {
57 | current: {
58 | errors: {},
59 | isDirty: false,
60 | isSubmitted: false,
61 | dirtyFields: {},
62 | submitCount: 0,
63 | touched: {},
64 | isSubmitting: false,
65 | isValid: false,
66 | },
67 | },
68 | fieldsRef: {
69 | current: {},
70 | },
71 | resetFieldArrayFunctionRef: {
72 | current: {},
73 | },
74 | fieldArrayNamesRef: {
75 | current: new Set(),
76 | },
77 | // eslint-disable-next-line @typescript-eslint/no-empty-function
78 | updateFormState: () => {},
79 | readFormStateRef: {
80 | current: {
81 | isDirty: true,
82 | errors: true,
83 | isSubmitted: false,
84 | submitCount: false,
85 | touched: false,
86 | isSubmitting: false,
87 | isValid: false,
88 | dirtyFields: false,
89 | },
90 | },
91 | // eslint-disable-next-line @typescript-eslint/no-empty-function
92 | renderWatchedInputs: () => {},
93 | ...controlOverrides,
94 | });
95 |
96 | type FormValues = {
97 | flat: string;
98 | nested: {
99 | object: { test: string };
100 | array: { test: string }[];
101 | };
102 | };
103 |
104 | describe('useTypedField', () => {
105 | it('should render correctly when as is input and name is string', () => {
106 | const control = reconfigureControl();
107 | const { result } = renderHook(() =>
108 | useTypedController({
109 | control,
110 | }),
111 | );
112 | const TypedController = result.current;
113 |
114 | const { asFragment } = render(
115 | ,
116 | );
117 |
118 | expect(asFragment()).toMatchSnapshot();
119 | });
120 |
121 | it('should render correctly when name is string and render is textarea', () => {
122 | const control = reconfigureControl();
123 | const { result } = renderHook(() =>
124 | useTypedController({
125 | control,
126 | }),
127 | );
128 | const TypedController = result.current;
129 |
130 | const { asFragment } = render(
131 | }
135 | />,
136 | );
137 |
138 | expect(asFragment()).toMatchSnapshot();
139 | });
140 |
141 | it('should render correctly when as is textarea and name is array', () => {
142 | const control = reconfigureControl();
143 | const { result } = renderHook(() =>
144 | useTypedController({
145 | control,
146 | }),
147 | );
148 | const TypedController = result.current;
149 |
150 | const { asFragment } = render(
151 | ,
156 | );
157 |
158 | expect(asFragment()).toMatchSnapshot();
159 | });
160 |
161 | it('should render correctly when name is array and render is input', () => {
162 | const control = reconfigureControl();
163 | const { result } = renderHook(() =>
164 | useTypedController({
165 | control,
166 | }),
167 | );
168 | const TypedController = result.current;
169 |
170 | const { asFragment } = render(
171 | }
175 | />,
176 | );
177 |
178 | expect(asFragment()).toMatchSnapshot();
179 | });
180 |
181 | it('should render correctly when name is string', () => {
182 | const control = reconfigureControl();
183 | const { result } = renderHook(() =>
184 | useTypedController({
185 | control,
186 | }),
187 | );
188 | const TypedController = result.current;
189 |
190 | render(
191 | }
195 | />,
196 | );
197 |
198 | expect(control.register).toHaveBeenCalledWith(
199 | {
200 | focus: undefined,
201 | name: 'flat',
202 | },
203 | undefined,
204 | );
205 | });
206 |
207 | it('should format name correctly when name is array (dot syntax)', () => {
208 | const control = reconfigureControl();
209 | const { result } = renderHook(() =>
210 | useTypedController({
211 | control,
212 | }),
213 | );
214 | const TypedController = result.current;
215 |
216 | render(
217 | }
221 | />,
222 | );
223 |
224 | expect(control.register).toHaveBeenCalledWith(
225 | {
226 | focus: undefined,
227 | name: 'nested.object.test',
228 | },
229 | undefined,
230 | );
231 | });
232 |
233 | it('should format name correctly when name is array (dot-bracket syntax)', () => {
234 | const control = reconfigureControl();
235 | const { result } = renderHook(() =>
236 | useTypedController({
237 | control,
238 | }),
239 | );
240 | const TypedController = result.current;
241 |
242 | render(
243 | }
247 | />,
248 | );
249 |
250 | expect(control.register).toHaveBeenCalledWith(
251 | {
252 | focus: undefined,
253 | name: 'nested.array[0].test',
254 | },
255 | undefined,
256 | );
257 | });
258 | });
259 |
--------------------------------------------------------------------------------
/src/useTypedController.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {
3 | Controller,
4 | UnpackNestedValue,
5 | FieldValuesFromControl,
6 | Control,
7 | } from 'react-hook-form';
8 | import { formatName } from './logic/formatName';
9 | import { DeepPath, Options, ControllerProps } from './types';
10 |
11 | export const useTypedController = <
12 | TFieldValues extends UnpackNestedValue>,
13 | TControl extends Control = Control
14 | >({
15 | control,
16 | }: Options) => {
17 | const controlRef = React.useRef(control);
18 | controlRef.current = control;
19 |
20 | const TypedController = React.useCallback(
21 | <
22 | UFieldValues extends TFieldValues,
23 | TFieldName extends DeepPath,
24 | TAs extends 'input' | 'select' | 'textarea'
25 | >({
26 | name,
27 | ...rest
28 | }: ControllerProps) => {
29 | const formattedName = formatName(name as any);
30 | return (
31 |
36 | );
37 | },
38 | [],
39 | );
40 |
41 | return TypedController;
42 | };
43 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "module": "esnext",
5 | "lib": ["dom", "esnext"],
6 | "jsx": "react",
7 | "declaration": true,
8 | "outDir": "dist",
9 | "strict": true,
10 | "noUnusedLocals": true,
11 | "noUnusedParameters": true,
12 | "noImplicitReturns": true,
13 | "noFallthroughCasesInSwitch": true,
14 | "moduleResolution": "node",
15 | "esModuleInterop": true,
16 | "forceConsistentCasingInFileNames": true
17 | },
18 | "include": ["src"],
19 | "exclude": ["src/**/*.test.js", "src/**/*.test.ts", "src/**/*.test.tsx"]
20 | }
21 |
--------------------------------------------------------------------------------