From 6ce808fec4f2e099996cd5e8ada5534fe7863b76 Mon Sep 17 00:00:00 2001 From: Brandon Clark <98107867+bclark-p44@users.noreply.github.com> Date: Wed, 25 Jan 2023 14:20:10 -0600 Subject: [PATCH] feat(react-hooks): adding use controlled state hook (#201) --- .changeset/chatty-mugs-vanish.md | 5 ++ packages/use-controlled-state/LICENSE | 18 ++++++ packages/use-controlled-state/README.md | 4 ++ packages/use-controlled-state/jest.config.js | 4 ++ packages/use-controlled-state/moon.yml | 7 ++ packages/use-controlled-state/package.json | 55 ++++++++++++++++ packages/use-controlled-state/src/index.ts | 1 + .../src/useControlledState.ts | 45 +++++++++++++ .../tests/useControlledState.test.tsx | 64 +++++++++++++++++++ .../use-controlled-state/tsconfig.build.json | 11 ++++ packages/use-controlled-state/tsconfig.json | 15 +++++ tsconfig.json | 9 ++- yarn.lock | 12 ++++ 13 files changed, 247 insertions(+), 3 deletions(-) create mode 100644 .changeset/chatty-mugs-vanish.md create mode 100644 packages/use-controlled-state/LICENSE create mode 100644 packages/use-controlled-state/README.md create mode 100644 packages/use-controlled-state/jest.config.js create mode 100644 packages/use-controlled-state/moon.yml create mode 100644 packages/use-controlled-state/package.json create mode 100644 packages/use-controlled-state/src/index.ts create mode 100644 packages/use-controlled-state/src/useControlledState.ts create mode 100644 packages/use-controlled-state/tests/useControlledState.test.tsx create mode 100644 packages/use-controlled-state/tsconfig.build.json create mode 100644 packages/use-controlled-state/tsconfig.json diff --git a/.changeset/chatty-mugs-vanish.md b/.changeset/chatty-mugs-vanish.md new file mode 100644 index 00000000..6d56b335 --- /dev/null +++ b/.changeset/chatty-mugs-vanish.md @@ -0,0 +1,5 @@ +--- +'@project44-manifest/use-controlled-state': minor +--- + +Adding useControlledState hook package diff --git a/packages/use-controlled-state/LICENSE b/packages/use-controlled-state/LICENSE new file mode 100644 index 00000000..accb6d33 --- /dev/null +++ b/packages/use-controlled-state/LICENSE @@ -0,0 +1,18 @@ +MIT License + +Copyright (c) 2021 project44, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT +NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES +OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/use-controlled-state/README.md b/packages/use-controlled-state/README.md new file mode 100644 index 00000000..7158ce20 --- /dev/null +++ b/packages/use-controlled-state/README.md @@ -0,0 +1,4 @@ +# @project44-react/react-use-controlled-state + +This package is part of [Manifest Design System](https://github.com/project44/manifest). Please see +the repo for more details. diff --git a/packages/use-controlled-state/jest.config.js b/packages/use-controlled-state/jest.config.js new file mode 100644 index 00000000..aa8a2394 --- /dev/null +++ b/packages/use-controlled-state/jest.config.js @@ -0,0 +1,4 @@ +module.exports = { + preset: 'jest-preset-manifest', + testEnvironment: 'jest-environment-jsdom', +}; diff --git a/packages/use-controlled-state/moon.yml b/packages/use-controlled-state/moon.yml new file mode 100644 index 00000000..52ab7b67 --- /dev/null +++ b/packages/use-controlled-state/moon.yml @@ -0,0 +1,7 @@ +type: 'library' + +tasks: + build: + outputs: + - 'esm' + - 'lib' diff --git a/packages/use-controlled-state/package.json b/packages/use-controlled-state/package.json new file mode 100644 index 00000000..f1c592d2 --- /dev/null +++ b/packages/use-controlled-state/package.json @@ -0,0 +1,55 @@ +{ + "name": "@project44-manifest/use-controlled-state", + "version": "0.0.0", + "description": "Manifest Design System react hook for controlled state", + "license": "MIT", + "author": "project44", + "keywords": [ + "manifest", + "design", + "system", + "react", + "hooks" + ], + "sideEffects": false, + "main": "./lib/index.js", + "module": "./esm/index.js", + "types": "./dts/index.d.ts", + "files": [ + "dts/**/*.d.ts", + "esm/**/*.{js,map}", + "lib/**/*.{js,map}", + "src/**/*.{ts,tsx,json}" + ], + "repository": { + "type": "git", + "url": "git@github.com:project-44/manifest.git", + "directory": "packages/use-controlled-state" + }, + "publishConfig": { + "access": "public" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0" + }, + "devDependencies": { + "react": "^18.1.0", + "react-dom": "^18.1.0" + }, + "packemon": { + "platform": "browser" + }, + "exports": { + "./package.json": "./package.json", + ".": { + "types": "./dts/index.d.ts", + "browser": { + "module": "./esm/index.js", + "import": "./esm/index.js", + "default": "./lib/index.js" + }, + "default": "./lib/index.js" + } + } +} diff --git a/packages/use-controlled-state/src/index.ts b/packages/use-controlled-state/src/index.ts new file mode 100644 index 00000000..3a9d887d --- /dev/null +++ b/packages/use-controlled-state/src/index.ts @@ -0,0 +1 @@ +export * from './useControlledState'; diff --git a/packages/use-controlled-state/src/useControlledState.ts b/packages/use-controlled-state/src/useControlledState.ts new file mode 100644 index 00000000..563315f2 --- /dev/null +++ b/packages/use-controlled-state/src/useControlledState.ts @@ -0,0 +1,45 @@ +import * as React from 'react'; + +/** + * A react hook used to manage controlled and uncontrolled states. + */ +export function useControlledState( + value: T, + defaultValue: T, + onChange: (value: T, ...args: any[]) => void, +): [T, (value: T, ...args: any[]) => void] { + const [stateValue, setStateValue] = React.useState(defaultValue); + + const { current: isControlled } = React.useRef(value !== undefined); + const prevValueRef = React.useRef(stateValue); + + const handleChange = React.useCallback( + (nextValue: T, ...args: any[]) => { + if (prevValueRef.current !== nextValue) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + onChange?.(nextValue, ...args); + } + + if (!isControlled) { + prevValueRef.current = nextValue; + } + }, + [isControlled, onChange, prevValueRef], + ); + + const handleSetValue = React.useCallback( + (nextValue: T, ...args: any[]) => { + if (!isControlled) setStateValue(nextValue); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + handleChange(nextValue, ...args); + }, + [handleChange, isControlled], + ); + + if (isControlled) { + prevValueRef.current = value; + } + + return [isControlled ? value : stateValue, handleSetValue]; +} diff --git a/packages/use-controlled-state/tests/useControlledState.test.tsx b/packages/use-controlled-state/tests/useControlledState.test.tsx new file mode 100644 index 00000000..cfe18df6 --- /dev/null +++ b/packages/use-controlled-state/tests/useControlledState.test.tsx @@ -0,0 +1,64 @@ +import { act, renderHook } from '@testing-library/react'; +import { useControlledState } from '../src'; + +describe('useControlledState', () => { + it('should handle uncontrolled values', () => { + const onChange = jest.fn(); + + const { result } = renderHook(() => + useControlledState(undefined, 'defaultValue', onChange), + ); + + let [value, setValue] = result.current; + + expect(value).toBe('defaultValue'); + expect(onChange).not.toHaveBeenCalled(); + + act(() => void setValue('newValue')); + + [value, setValue] = result.current; + + expect(value).toBe('newValue'); + expect(onChange).toHaveBeenLastCalledWith('newValue'); + + onChange.mockClear(); + + // Should not call onChange if value is the same. + act(() => void setValue('newValue')); + + [value, setValue] = result.current; + + expect(value).toBe('newValue'); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('should handle controlled values', () => { + const onChange = jest.fn(); + + const { result } = renderHook(() => + useControlledState('value', 'defaultValue', onChange), + ); + + let [value, setValue] = result.current; + + expect(value).toBe('value'); + expect(onChange).not.toHaveBeenCalled(); + + act(() => void setValue('newValue')); + + [value, setValue] = result.current; + + expect(value).toBe('value'); + expect(onChange).toHaveBeenLastCalledWith('newValue'); + + onChange.mockClear(); + + act(() => void setValue('value')); + + [value, setValue] = result.current; + + // Should not call onChange if value is the same. + expect(value).toBe('value'); + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/use-controlled-state/tsconfig.build.json b/packages/use-controlled-state/tsconfig.build.json new file mode 100644 index 00000000..9fd924cb --- /dev/null +++ b/packages/use-controlled-state/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.options.json", + "compilerOptions": { + "outDir": "dts", + "rootDir": "src" + }, + "include": [ + "src/**/*" + ], + "references": [] +} diff --git a/packages/use-controlled-state/tsconfig.json b/packages/use-controlled-state/tsconfig.json new file mode 100644 index 00000000..e4179528 --- /dev/null +++ b/packages/use-controlled-state/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.options.json", + "compilerOptions": { + "outDir": "../../.moon/cache/types/packages/use-controlled-state" + }, + "include": [ + "src/**/*", + "tests/**/*" + ], + "references": [ + { + "path": "./tsconfig.build.json" + } + ] +} diff --git a/tsconfig.json b/tsconfig.json index 507bcce9..07e322a4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,15 +2,15 @@ "extends": "./tsconfig.options.json", "files": [], "references": [ - { - "path": "packages/design-tokens" - }, { "path": "packages/button" }, { "path": "packages/css-baseline" }, + { + "path": "packages/design-tokens" + }, { "path": "packages/layout" }, @@ -41,6 +41,9 @@ { "path": "packages/typography" }, + { + "path": "packages/use-controlled-state" + }, { "path": "packages/utils" }, diff --git a/yarn.lock b/yarn.lock index 143e7690..1976c34a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3495,6 +3495,18 @@ __metadata: languageName: unknown linkType: soft +"@project44-manifest/use-controlled-state@workspace:packages/use-controlled-state": + version: 0.0.0-use.local + resolution: "@project44-manifest/use-controlled-state@workspace:packages/use-controlled-state" + dependencies: + react: ^18.1.0 + react-dom: ^18.1.0 + peerDependencies: + react: ^17.0.0 || ^18.0.0 + react-dom: ^17.0.0 || ^18.0.0 + languageName: unknown + linkType: soft + "@project44-manifest/utils@npm:^1.0.1": version: 1.0.1 resolution: "@project44-manifest/utils@npm:1.0.1"