diff --git a/.changeset/soft-berries-obey.md b/.changeset/soft-berries-obey.md new file mode 100644 index 0000000000..bf46541737 --- /dev/null +++ b/.changeset/soft-berries-obey.md @@ -0,0 +1,8 @@ +--- +'@leafygreen-ui/hooks': major +--- + +- Extends `useControlledValue` to accept any type. + - Adds `initialValue` argument. Used for setting the initial value for uncontrolled components. Without this we may encounter a React error for switching between controlled/uncontrolled inputs + - Changes signature of `onChange` argument to be a simple setter function (`(value: T) => void`) + - Changes return object to include only `isControlled`, `value` and an `updateValue` setter function diff --git a/packages/hooks/package.json b/packages/hooks/package.json index f4011a9b8a..2a3905ab20 100644 --- a/packages/hooks/package.json +++ b/packages/hooks/package.json @@ -22,6 +22,7 @@ "access": "public" }, "dependencies": { + "@leafygreen-ui/lib": "^11.0.0", "lodash": "^4.17.21" }, "gitHead": "dd71a2d404218ccec2e657df9c0263dc1c15b9e0", diff --git a/packages/hooks/src/useControlledValue/useControlledValue.spec.ts b/packages/hooks/src/useControlledValue/useControlledValue.spec.ts deleted file mode 100644 index 8ebc6f789a..0000000000 --- a/packages/hooks/src/useControlledValue/useControlledValue.spec.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { ChangeEvent } from 'react'; -import { act } from 'react-test-renderer'; -import { renderHook } from '@testing-library/react-hooks'; - -import { useControlledValue } from './useControlledValue'; - -describe('packages/lib/useControlledValue', () => { - test('with controlled component', async () => { - const value = 'apple'; - const handler = jest.fn(); - const { - result: { current }, - } = renderHook(() => useControlledValue(value as string, handler)); - expect(current.isControlled).toBe(true); - expect(current.value).toBe(value); - - act(() => { - current.handleChange({ target: { value: 'banana' } } as ChangeEvent); - current.setUncontrolledValue('banana'); - }); - expect(handler).toHaveBeenCalledWith( - expect.objectContaining({ target: { value: 'banana' } }), - ); - expect(current.value).toBe('apple'); - }); - - test('with uncontrolled component', async () => { - const value = undefined; - const handler = jest.fn(); - const { - result: { current }, - } = renderHook(() => useControlledValue(value, handler)); - expect(current.isControlled).toBe(false); - expect(current.value).toBe(''); - - act(() => { - current.handleChange({ target: { value: 'apple' } } as ChangeEvent); - expect(handler).toHaveBeenCalledWith( - expect.objectContaining({ target: { value: 'apple' } }), - ); - }); - }); -}); diff --git a/packages/hooks/src/useControlledValue/useControlledValue.spec.tsx b/packages/hooks/src/useControlledValue/useControlledValue.spec.tsx new file mode 100644 index 0000000000..7f3ad040d2 --- /dev/null +++ b/packages/hooks/src/useControlledValue/useControlledValue.spec.tsx @@ -0,0 +1,264 @@ +import React from 'react'; +import { ChangeEventHandler } from 'react'; +import { render } from '@testing-library/react'; +import { renderHook, RenderHookResult } from '@testing-library/react-hooks'; +import userEvent from '@testing-library/user-event'; + +import { useControlledValue } from './useControlledValue'; + +const errorSpy = jest.spyOn(console, 'error'); + +const renderUseControlledValueHook = ( + ...[valueProp, callback, initial]: Parameters> +): RenderHookResult>> => { + const result = renderHook(v => useControlledValue(v, callback, initial), { + initialProps: valueProp, + }); + + return { ...result }; +}; + +describe('packages/hooks/useControlledValue', () => { + beforeEach(() => { + errorSpy.mockImplementation(() => {}); + }); + + afterEach(() => { + errorSpy.mockReset(); + }); + + test('rendering without any arguments sets hook to uncontrolled', () => { + const { result } = renderUseControlledValueHook(); + expect(result.current.isControlled).toEqual(false); + }); + + describe('accepts various value types', () => { + test('accepts number values', () => { + const { result } = renderUseControlledValueHook(5); + expect(result.current.value).toBe(5); + }); + + test('accepts boolean values', () => { + const { result } = renderUseControlledValueHook(false); + expect(result.current.value).toBe(false); + }); + + test('accepts array values', () => { + const arr = ['foo', 'bar']; + const { result } = renderUseControlledValueHook(arr); + expect(result.current.value).toBe(arr); + }); + + test('accepts object values', () => { + const obj = { foo: 'foo', bar: 'bar' }; + const { result } = renderUseControlledValueHook(obj); + expect(result.current.value).toBe(obj); + }); + + test('accepts date values', () => { + const date = new Date('2023-08-23'); + const { result } = renderUseControlledValueHook(date); + expect(result.current.value).toBe(date); + }); + + test('accepts multiple/union types', () => { + const { result, rerender } = renderUseControlledValueHook< + string | number + >(5); + expect(result.current.value).toBe(5); + rerender('foo'); + expect(result.current.value).toBe('foo'); + }); + }); + + describe('Controlled', () => { + test('rendering with a value sets value and isControlled', () => { + const { result } = renderUseControlledValueHook('apple'); + expect(result.current.isControlled).toBe(true); + expect(result.current.value).toBe('apple'); + }); + + test('rerendering from initial undefined sets value and isControlled', async () => { + const { rerender, result } = renderUseControlledValueHook(); + rerender('apple'); + expect(result.current.isControlled).toBe(true); + expect(result.current.value).toEqual('apple'); + }); + + test('rerendering with a new value changes the value', () => { + const { rerender, result } = renderUseControlledValueHook('apple'); + expect(result.current.value).toBe('apple'); + rerender('banana'); + expect(result.current.value).toBe('banana'); + }); + + test('provided handler is called within `updateValue`', () => { + const handler = jest.fn(); + const { result } = renderUseControlledValueHook('apple', handler); + result.current.updateValue('banana'); + expect(handler).toHaveBeenCalledWith('banana'); + }); + + test('hook value does not change when `updateValue` is called', () => { + const { result } = renderUseControlledValueHook('apple'); + result.current.updateValue('banana'); + // value doesn't change unless we explicitly change it + expect(result.current.value).toBe('apple'); + }); + + test('setting value to undefined should keep the component controlled', () => { + const { rerender, result } = renderUseControlledValueHook('apple'); + expect(result.current.isControlled).toBe(true); + rerender(undefined); + expect(result.current.isControlled).toBe(true); + }); + + test('initial value is ignored when controlled', () => { + const { result } = renderUseControlledValueHook( + 'apple', + () => {}, + 'banana', + ); + expect(result.current.value).toBe('apple'); + }); + }); + + describe('Uncontrolled', () => { + test('calling without a value sets value to `initialValue`', () => { + const { + result: { current }, + } = renderUseControlledValueHook(undefined, () => {}, 'apple'); + + expect(current.isControlled).toBe(false); + expect(current.value).toBe('apple'); + }); + + test('provided handler is called within `updateValue`', () => { + const handler = jest.fn(); + const { + result: { current }, + } = renderUseControlledValueHook(undefined, handler); + + current.updateValue('apple'); + expect(handler).toHaveBeenCalledWith('apple'); + }); + + test('updateValue updates the value', () => { + const { result } = renderUseControlledValueHook(undefined); + result.current.updateValue('banana'); + expect(result.current.value).toBe('banana'); + }); + }); + + describe('Within test component', () => { + const TestComponent = ({ + valueProp, + handlerProp, + }: { + valueProp?: string; + handlerProp?: (val?: string) => void; + }) => { + const initialVal = ''; + // eslint-disable-next-line react-hooks/rules-of-hooks + const { value, updateValue } = useControlledValue( + valueProp, + handlerProp, + initialVal, + ); + + const handleChange: ChangeEventHandler = e => { + updateValue(e.target.value); + }; + + return ( + <> + +