diff --git a/src/hooks/__tests__/utils.test.js b/src/hooks/__tests__/utils.test.js index 791f0601a..8d257948e 100644 --- a/src/hooks/__tests__/utils.test.js +++ b/src/hooks/__tests__/utils.test.js @@ -1,6 +1,6 @@ import {renderHook} from '@testing-library/react' import {useMouseAndTouchTracker, isDropdownsStateEqual} from '../utils' -import {getInitialValue, getDefaultValue, getItemAndIndex} from '../utils-ts' +import {getItemAndIndex} from '../utils-ts' import {dropdownDefaultProps} from '../utils.dropdown' describe('utils', () => { @@ -43,39 +43,6 @@ describe('utils', () => { }) }) - test('getInitialValue will not return undefined as initial value', () => { - const defaults = {bogusValue: 'hello'} - const value = getInitialValue( - {initialBogusValue: undefined}, - 'bogusValue', - defaults, - ) - - expect(value).toEqual(defaults.bogusValue) - }) - - test('getInitialValue will not return undefined as value', () => { - const defaults = {bogusValue: 'hello'} - const value = getInitialValue( - {bogusValue: undefined}, - 'bogusValue', - defaults, - ) - - expect(value).toEqual(defaults.bogusValue) - }) - - test('getDefaultValue will not return undefined as value', () => { - const defaults = {bogusValue: 'hello'} - const value = getDefaultValue( - {defaultBogusValue: undefined}, - 'bogusValue', - defaults, - ) - - expect(value).toEqual(defaults.bogusValue) - }) - describe('useMouseAndTouchTracker', () => { test('renders without error', () => { expect(() => { diff --git a/src/hooks/reducer.js b/src/hooks/reducer.js index c1ec2f8e5..67a0f78e9 100644 --- a/src/hooks/reducer.js +++ b/src/hooks/reducer.js @@ -67,16 +67,17 @@ export default function downshiftCommonReducer( case stateChangeTypes.FunctionReset: changes = { highlightedIndex: getDefaultHighlightedIndex(props), - isOpen: getDefaultValue(props, 'isOpen', dropdownDefaultStateValues), + isOpen: getDefaultValue( + props.defaultIsOpen, + dropdownDefaultStateValues.isOpen, + ), selectedItem: getDefaultValue( - props, - 'selectedItem', - dropdownDefaultStateValues, + props.defaultSelectedItem, + dropdownDefaultStateValues.selectedItem, ), inputValue: getDefaultValue( - props, - 'inputValue', - dropdownDefaultStateValues, + props.defaultInputValue, + dropdownDefaultStateValues.inputValue, ), } diff --git a/src/hooks/testUtils.js b/src/hooks/testUtils.js index 6aa10f9fe..89dc3deb1 100644 --- a/src/hooks/testUtils.js +++ b/src/hooks/testUtils.js @@ -114,27 +114,27 @@ export async function tab(shiftKey = false) { // format is: [initialIsOpen, defaultIsOpen, props.isOpen] export const initialFocusAndOpenTestCases = [ - [undefined, undefined, true, true], - [true, true, true, true], - [true, false, true, true], - [false, true, true, true], - [false, false, true, true], - [true, undefined, undefined, true], - [true, false, undefined, true], - [true, true, undefined, true], - [undefined, true, undefined, true], + [undefined, undefined, true], + [true, true, true], + [true, false, true], + [false, true, true], + [false, false, true], + [true, undefined, undefined], + [true, false, undefined], + [true, true, undefined], + [undefined, true, undefined], ] // format is: [initialIsOpen, defaultIsOpen, props.isOpen] export const initialNoFocusOrOpenTestCases = [ - [undefined, undefined, undefined, false], - [undefined, undefined, false, false], - [true, true, false, false], - [true, false, false, false], - [false, true, false, false], - [false, false, false, false], - [false, undefined, undefined, false], - [false, false, undefined, false], - [false, true, undefined, false], - [undefined, false, undefined, false], + [undefined, undefined, undefined], + [undefined, undefined, false], + [true, true, false], + [true, false, false], + [false, true, false], + [false, false, false], + [false, undefined, undefined], + [false, false, undefined], + [false, true, undefined], + [undefined, false, undefined], ] diff --git a/src/hooks/useCombobox/index.js b/src/hooks/useCombobox/index.js index a0055d88d..069cf83d6 100644 --- a/src/hooks/useCombobox/index.js +++ b/src/hooks/useCombobox/index.js @@ -16,6 +16,7 @@ import { useA11yMessageStatus, } from '../utils-ts' import {defaultStateValues} from '../utils.dropdown/defaultStateValues' +import {useElementIds} from '../utils.dropdown/useElementIds' import { getInitialState, defaultProps, @@ -24,7 +25,6 @@ import { } from './utils' import downshiftUseComboboxReducer from './reducer' import * as stateChangeTypes from './stateChangeTypes' -import {useElementIds} from '../utils.dropdown/useElementIds' useCombobox.stateChangeTypes = stateChangeTypes @@ -87,7 +87,12 @@ function useCombobox(userProps = {}) { }) // Focus the input on first render if required. useEffect(() => { - const focusOnOpen = getInitialValue(props, 'isOpen', defaultStateValues) + const focusOnOpen = getInitialValue( + props.isOpen, + props.initialIsOpen, + props.defaultIsOpen, + defaultStateValues.isOpen, + ) if (focusOnOpen && inputRef.current) { inputRef.current.focus() diff --git a/src/hooks/useCombobox/reducer.js b/src/hooks/useCombobox/reducer.js index 3e97c3633..ce95f64cf 100644 --- a/src/hooks/useCombobox/reducer.js +++ b/src/hooks/useCombobox/reducer.js @@ -10,14 +10,17 @@ import {dropdownDefaultStateValues} from '../utils.dropdown' import * as stateChangeTypes from './stateChangeTypes' /* eslint-disable complexity */ -export default function downshiftUseComboboxReducer(state, props, action) { - const {type, altKey} = action +export default function downshiftUseComboboxReducer(state, action) { + const {type, props, altKey} = action let changes switch (type) { case stateChangeTypes.ItemClick: changes = { - isOpen: getDefaultValue(props, 'isOpen', dropdownDefaultStateValues), + isOpen: getDefaultValue( + props.defaultIsOpen, + dropdownDefaultStateValues.isOpen, + ), highlightedIndex: getDefaultHighlightedIndex(props), selectedItem: props.items[action.index], inputValue: props.itemToString(props.items[action.index]), diff --git a/src/hooks/useMultipleSelection/reducer.js b/src/hooks/useMultipleSelection/reducer.js index 8c22cdd04..45f2f64c7 100644 --- a/src/hooks/useMultipleSelection/reducer.js +++ b/src/hooks/useMultipleSelection/reducer.js @@ -1,13 +1,10 @@ -import {getDefaultValue} from './utils' +import {getDefaultValue} from '../utils-ts' +import {defaultStateValues} from './utils' import * as stateChangeTypes from './stateChangeTypes' /* eslint-disable complexity */ -export default function downshiftMultipleSelectionReducer( - state, - props, - action, -) { - const {type, index, selectedItem} = action +export default function downshiftMultipleSelectionReducer(state, action) { + const {type, index, selectedItem, props} = action const {activeIndex, selectedItems} = state let changes @@ -118,8 +115,14 @@ export default function downshiftMultipleSelectionReducer( } case stateChangeTypes.FunctionReset: changes = { - activeIndex: getDefaultValue(props, 'activeIndex'), - selectedItems: getDefaultValue(props, 'selectedItems'), + activeIndex: getDefaultValue( + props.defaultActiveIndex, + defaultStateValues.activeIndex, + ), + selectedItems: getDefaultValue( + props.defaultSelectedItems, + defaultStateValues.selectedItems, + ), } break default: diff --git a/src/hooks/useMultipleSelection/utils.js b/src/hooks/useMultipleSelection/utils.js index 3deb27e45..2973a5db6 100644 --- a/src/hooks/useMultipleSelection/utils.js +++ b/src/hooks/useMultipleSelection/utils.js @@ -1,42 +1,14 @@ import PropTypes from 'prop-types' import {noop} from '../../utils-ts' -import { - getInitialValue as getInitialValueCommon, - getDefaultValue as getDefaultValueCommon, -} from '../utils-ts' +import {getInitialValue} from '../utils-ts' import {dropdownDefaultProps, dropdownPropTypes} from '../utils.dropdown' -const defaultStateValues = { +export const defaultStateValues = { activeIndex: -1, selectedItems: [], } -/** - * Returns the initial value for a state key in the following order: - * 1. controlled prop, 2. initial prop, 3. default prop, 4. default - * value from Downshift. - * - * @param {Object} props Props passed to the hook. - * @param {string} propKey Props key to generate the value for. - * @returns {any} The initial value for that prop. - */ -function getInitialValue(props, propKey) { - return getInitialValueCommon(props, propKey, defaultStateValues) -} - -/** - * Returns the default value for a state key in the following order: - * 1. controlled prop, 2. default prop, 3. default value from Downshift. - * - * @param {Object} props Props passed to the hook. - * @param {string} propKey Props key to generate the value for. - * @returns {any} The initial value for that prop. - */ -function getDefaultValue(props, propKey) { - return getDefaultValueCommon(props, propKey, defaultStateValues) -} - /** * Gets the initial state based on the provided props. It uses initial, default * and controlled props related to state in order to compute the initial value. @@ -45,8 +17,18 @@ function getDefaultValue(props, propKey) { * @returns {Object} The initial state. */ function getInitialState(props) { - const activeIndex = getInitialValue(props, 'activeIndex') - const selectedItems = getInitialValue(props, 'selectedItems') + const activeIndex = getInitialValue( + props.activeIndex, + props.initialActiveIndex, + props.defaultActiveIndex, + defaultStateValues.activeIndex, + ) + const selectedItems = getInitialValue( + props.selectedItems, + props.initialSelectedItems, + props.defaultSelectedItems, + defaultStateValues.selectedItems, + ) return { activeIndex, @@ -133,7 +115,6 @@ if (process.env.NODE_ENV !== 'production') { export { validatePropTypes, - getDefaultValue, getInitialState, isKeyDownOperationPermitted, isStateEqual, diff --git a/src/hooks/useSelect/__tests__/utils.test.ts b/src/hooks/useSelect/__tests__/utils.test.ts index b12b6e598..e6bf063ff 100644 --- a/src/hooks/useSelect/__tests__/utils.test.ts +++ b/src/hooks/useSelect/__tests__/utils.test.ts @@ -59,6 +59,6 @@ describe('getItemIndexByCharacterKey', () => { test('reducer throws error if called without proper action type', () => { expect(() => { - reducer({}, {}, {type: 'super-bogus'}) + reducer({}, {type: 'super-bogus'}) }).toThrow('Reducer called without proper action type.') }) diff --git a/src/hooks/useSelect/index.js b/src/hooks/useSelect/index.js index 6dcd082a8..f49bfe41a 100644 --- a/src/hooks/useSelect/index.js +++ b/src/hooks/useSelect/index.js @@ -22,11 +22,11 @@ import { useA11yMessageStatus, } from '../utils-ts' import {defaultStateValues} from '../utils.dropdown/defaultStateValues' +import {useElementIds} from '../utils.dropdown/useElementIds' import {isReactNative, isReactNativeWeb} from '../../is.macro' import downshiftSelectReducer from './reducer' import {defaultProps, propTypes} from './utils' import * as stateChangeTypes from './stateChangeTypes' -import { useElementIds } from '../utils.dropdown/useElementIds' useSelect.stateChangeTypes = stateChangeTypes @@ -114,7 +114,12 @@ function useSelect(userProps = {}) { }) // Focus the toggle button on first render if required. useEffect(() => { - const focusOnOpen = getInitialValue(props, 'isOpen', defaultStateValues) + const focusOnOpen = getInitialValue( + props.isOpen, + props.initialIsOpen, + props.defaultIsOpen, + defaultStateValues.isOpen, + ) if (focusOnOpen && toggleButtonRef.current) { toggleButtonRef.current.focus() diff --git a/src/hooks/useSelect/reducer.js b/src/hooks/useSelect/reducer.js index 8203ac4c9..c21383b1a 100644 --- a/src/hooks/useSelect/reducer.js +++ b/src/hooks/useSelect/reducer.js @@ -11,14 +11,14 @@ import {getItemIndexByCharacterKey} from './utils' import * as stateChangeTypes from './stateChangeTypes' /* eslint-disable complexity */ -export default function downshiftSelectReducer(state, props, action) { - const {type, altKey} = action +export default function downshiftSelectReducer(state, action) { + const {type, props, altKey} = action let changes switch (type) { case stateChangeTypes.ItemClick: changes = { - isOpen: getDefaultValue(props, 'isOpen', defaultStateValues), + isOpen: getDefaultValue(props.defaultIsOpen, defaultStateValues.isOpen), highlightedIndex: getDefaultHighlightedIndex(props), selectedItem: props.items[action.index], } diff --git a/src/hooks/useTagGroup/__tests__/getTagGroupProps.test.ts b/src/hooks/useTagGroup/__tests__/getTagGroupProps.test.ts index 9865eecde..02794ad38 100644 --- a/src/hooks/useTagGroup/__tests__/getTagGroupProps.test.ts +++ b/src/hooks/useTagGroup/__tests__/getTagGroupProps.test.ts @@ -191,7 +191,7 @@ describe('getTagGroupProps', () => { expect(queryByRole('tag')).not.toBeInTheDocument() }) - test('backspace removes the active item', async () => { + test('backspace removes the active item', async () => { const {clickOnTag, user, getTags} = renderTagGroup() const tagsCount = getTags().length @@ -234,9 +234,7 @@ describe('getTagGroupProps', () => { }) test('any other key does nothing', async () => { - const {clickOnTag, user, getTags} = renderTagGroup({ - defaultActiveIndex: 2, - }) + const {clickOnTag, user, getTags} = renderTagGroup() await clickOnTag(2) await user.keyboard('{Space}') diff --git a/src/hooks/useTagGroup/__tests__/props.test.ts b/src/hooks/useTagGroup/__tests__/props.test.ts index 3ae05588c..5a5796130 100644 --- a/src/hooks/useTagGroup/__tests__/props.test.ts +++ b/src/hooks/useTagGroup/__tests__/props.test.ts @@ -137,6 +137,12 @@ describe('props', () => { index: undefined, item: 'test', type: useTagGroup.stateChangeTypes.FunctionAddItem, + props: { + environment: window, + initialItems: defaultProps.initialItems, + removeElementDescription: 'Press Delete or Backspace to remove tag.', + stateReducer, + }, }, ) }) @@ -165,6 +171,12 @@ describe('props', () => { }, index: 3, type: useTagGroup.stateChangeTypes.TagClick, + props: { + environment: window, + initialItems: defaultProps.initialItems, + removeElementDescription: 'Press Delete or Backspace to remove tag.', + stateReducer, + }, }, ) @@ -183,6 +195,12 @@ describe('props', () => { items: defaultProps.initialItems, }, type: useTagGroup.stateChangeTypes.TagGroupKeyDownArrowLeft, + props: { + environment: window, + initialItems: defaultProps.initialItems, + removeElementDescription: 'Press Delete or Backspace to remove tag.', + stateReducer, + }, }, ) @@ -201,6 +219,12 @@ describe('props', () => { items: defaultProps.initialItems, }, type: useTagGroup.stateChangeTypes.TagGroupKeyDownArrowRight, + props: { + environment: window, + initialItems: defaultProps.initialItems, + removeElementDescription: 'Press Delete or Backspace to remove tag.', + stateReducer, + }, }, ) @@ -224,6 +248,12 @@ describe('props', () => { items: newItemsAfterBackspace, }, type: useTagGroup.stateChangeTypes.TagGroupKeyDownBackspace, + props: { + environment: window, + initialItems: defaultProps.initialItems, + removeElementDescription: 'Press Delete or Backspace to remove tag.', + stateReducer, + }, }, ) @@ -247,6 +277,12 @@ describe('props', () => { items: newItemsAfterDelete, }, type: useTagGroup.stateChangeTypes.TagGroupKeyDownDelete, + props: { + environment: window, + initialItems: defaultProps.initialItems, + removeElementDescription: 'Press Delete or Backspace to remove tag.', + stateReducer, + }, }, ) @@ -271,6 +307,12 @@ describe('props', () => { }, index: 0, type: useTagGroup.stateChangeTypes.TagRemoveClick, + props: { + environment: window, + initialItems: defaultProps.initialItems, + removeElementDescription: 'Press Delete or Backspace to remove tag.', + stateReducer, + }, }, ) }) @@ -313,13 +355,17 @@ describe('props', () => { }) expect(getByText(removeElementDescription)).toBeInTheDocument() - expect(queryByText('Press Delete or Backspace to remove tag.')).not.toBeInTheDocument() + expect( + queryByText('Press Delete or Backspace to remove tag.'), + ).not.toBeInTheDocument() }) test('removeElementDescription has a default options', () => { const {getByText} = renderTagGroup() - expect(getByText('Press Delete or Backspace to remove tag.')).toBeInTheDocument() + expect( + getByText('Press Delete or Backspace to remove tag.'), + ).toBeInTheDocument() }) test('onStateChange is called after adding an item', () => { diff --git a/src/hooks/useTagGroup/__tests__/reducer.test.ts b/src/hooks/useTagGroup/__tests__/reducer.test.ts index 41a9bc77e..66b6d240c 100644 --- a/src/hooks/useTagGroup/__tests__/reducer.test.ts +++ b/src/hooks/useTagGroup/__tests__/reducer.test.ts @@ -1,17 +1,15 @@ -import {UseTagGroupReducerAction} from '../index.types' +import {type Action} from '../../utils-ts' +import { + UseTagGroupReducerAction, + UseTagGroupState, + UseTagGroupStateChangeTypes, +} from '../index.types' import {useTagGroupReducer} from '../reducer' test('reducer throws error if called without proper action type', () => { expect(() => { - useTagGroupReducer( - {activeIndex: 0, items: []}, - { - stateReducer(state) { - return state - }, - removeElementDescription: 'bla bla', - }, - {type: 'super-bogus'} as unknown as UseTagGroupReducerAction, - ) + useTagGroupReducer({activeIndex: 0, items: []}, { + type: 'super-bogus' as UseTagGroupStateChangeTypes, + } as Action, UseTagGroupReducerAction>) }).toThrow('Invalid useTagGroup reducer action.') }) diff --git a/src/hooks/useTagGroup/index.ts b/src/hooks/useTagGroup/index.ts index aab6533f3..db7e213d6 100644 --- a/src/hooks/useTagGroup/index.ts +++ b/src/hooks/useTagGroup/index.ts @@ -14,11 +14,9 @@ import { GetTagRemovePropsOptions, UseTagGroupInterface, UseTagGroupProps, - UseTagGroupMergedProps, UseTagGroupReducerAction, UseTagGroupReturnValue, UseTagGroupState, - UseTagGroupStateChangeTypes, GetTagRemovePropsReturnValue, GetTagPropsReturnValue, GetTagGroupPropsReturnValue, @@ -46,8 +44,6 @@ const useTagGroup: UseTagGroupInterface = ( const [state, dispatch] = useControlledReducer< UseTagGroupState, - UseTagGroupMergedProps, - UseTagGroupStateChangeTypes, UseTagGroupReducerAction >(useTagGroupReducer, props, getInitialState, isStateEqual) diff --git a/src/hooks/useTagGroup/index.types.ts b/src/hooks/useTagGroup/index.types.ts index b2f000cc1..9533d90d0 100644 --- a/src/hooks/useTagGroup/index.types.ts +++ b/src/hooks/useTagGroup/index.types.ts @@ -1,6 +1,6 @@ -import {Action, State} from '../../utils-ts' +import {type Action} from '../utils-ts' -export interface UseTagGroupState extends State { +export interface UseTagGroupState { activeIndex: number items: Item[] } @@ -44,7 +44,10 @@ export interface UseTagGroupProps extends Partial< removeElementDescription?: string stateReducer?( state: UseTagGroupState, - actionAndChanges: Action & { + actionAndChanges: Action< + UseTagGroupState, + UseTagGroupReducerAction + > & { changes: Partial> }, ): Partial> diff --git a/src/hooks/useTagGroup/reducer.ts b/src/hooks/useTagGroup/reducer.ts index 5f05201d0..95f6cc91e 100644 --- a/src/hooks/useTagGroup/reducer.ts +++ b/src/hooks/useTagGroup/reducer.ts @@ -1,15 +1,14 @@ -import { - UseTagGroupProps, - UseTagGroupReducerAction, - UseTagGroupState, -} from './index.types' +import {type Action} from '../utils-ts' +import {UseTagGroupReducerAction, UseTagGroupState} from './index.types' import * as stateChangeTypes from './stateChangeTypes' export function useTagGroupReducer( state: UseTagGroupState, - _props: UseTagGroupProps, - action: UseTagGroupReducerAction, -): UseTagGroupState { + action: Action< + UseTagGroupState, + UseTagGroupReducerAction + >, +): Partial> { const {type} = action let changes @@ -46,8 +45,8 @@ export function useTagGroupReducer( newItems.length === 0 ? -1 : newItems.length === state.activeIndex - ? state.activeIndex - 1 - : state.activeIndex + ? state.activeIndex - 1 + : state.activeIndex changes = { items: [ ...state.items.slice(0, state.activeIndex), @@ -67,8 +66,8 @@ export function useTagGroupReducer( newItems.length === 0 ? -1 : newItems.length === action.index - ? action.index - 1 - : action.index + ? action.index - 1 + : action.index changes = { items: newItems, activeIndex: newActiveIndex, diff --git a/src/hooks/utils-ts/__tests__/callOnChangeProps.test.ts b/src/hooks/utils-ts/__tests__/callOnChangeProps.test.ts new file mode 100644 index 000000000..46133fb8c --- /dev/null +++ b/src/hooks/utils-ts/__tests__/callOnChangeProps.test.ts @@ -0,0 +1,151 @@ +import {callOnChangeProps} from '../callOnChangeProps' + +test('callOnChangeProps calls onStateChange with changed properties', () => { + const onStateChange = jest.fn() + const props = {stateReducer: () => ({}), onStateChange} + const action = {type: 'test', props} + const state = {count: 0} + const newState = {count: 1} + + callOnChangeProps(action, props, state, newState) + + expect(onStateChange).toHaveBeenCalledTimes(1) + expect(onStateChange).toHaveBeenCalledWith({type: 'test', count: 1}) +}) + +test('callOnChangeProps does not call onStateChange if there are no changes', () => { + const onStateChange = jest.fn() + const props = {stateReducer: () => ({}), onStateChange} + const action = {type: 'test', props} + const state = {count: 0} + const newState = {count: 0} + + callOnChangeProps(action, props, state, newState) + + expect(onStateChange).not.toHaveBeenCalled() +}) + +test('callOnChangeProps does not call onStateChange if onStateChange is not provided', () => { + const props = {stateReducer: () => ({})} + const action = {type: 'test', props} + const state = {count: 0} + const newState = {count: 1} + + callOnChangeProps(action, props, state, newState) + + expect(() => callOnChangeProps(action, props, state, newState)).not.toThrow() +}) + +test('callOnChangeProps does not call onStateChange if onStateChange is not a function', () => { + const props = { + stateReducer: () => ({}), + onStateChange: 'not a function' as unknown as () => void, + } + const action = {type: 'test', props} + const state = {count: 0} + const newState = {count: 1} + + expect(() => callOnChangeProps(action, props, state, newState)).not.toThrow() +}) + +test('callOnChangeProps calls specific on[Key]Change handlers', () => { + const onCountChange = jest.fn() + const props = {stateReducer: () => ({}), onCountChange} + const action = {type: 'test', props} + const state = {count: 0} + const newState = {count: 1} + + callOnChangeProps(action, props, state, newState) + + expect(onCountChange).toHaveBeenCalledTimes(1) + expect(onCountChange).toHaveBeenCalledWith({type: 'test', count: 1}) +}) + +test('callOnChangeProps does not call on[Key]Change handlers if the value did not change', () => { + const onCountChange = jest.fn() + const props = {stateReducer: () => ({}), onCountChange} + const action = {type: 'test', props} + const state = {count: 0} + const newState = {count: 0} + + callOnChangeProps(action, props, state, newState) + + expect(onCountChange).not.toHaveBeenCalled() +}) + +test('callOnChangeProps does not call on[Key]Change handlers if the handler is not a function', () => { + const props = { + stateReducer: () => ({}), + onCountChange: 'not a function' as unknown as () => void, + } + const action = {type: 'test', props} + const state = {count: 0} + const newState = {count: 1} + + expect(() => callOnChangeProps(action, props, state, newState)).not.toThrow() +}) + +test('callOnChangeProps does not call on[Key]Change handlers if the handler is not provided', () => { + const props = {stateReducer: () => ({})} + const action = {type: 'test', props} + const state = {count: 0} + const newState = {count: 1} + + expect(() => callOnChangeProps(action, props, state, newState)).not.toThrow() +}) + +test('callOnChangeProps calls multiple on[Key]Change handlers if multiple values changed', () => { + const onCountChange = jest.fn() + const onOtherChange = jest.fn() + const onStateChange = jest.fn() + const props = {stateReducer: () => ({}), onCountChange, onOtherChange, onStateChange} + const action = {type: 'test', props} + const state = {count: 0, other: 'a'} + const newState = {count: 1, other: 'b'} + + callOnChangeProps(action, props, state, newState) + + expect(onCountChange).toHaveBeenCalledTimes(1) + expect(onCountChange).toHaveBeenCalledWith({ + type: 'test', + count: 1, + other: 'b', + }) + expect(onOtherChange).toHaveBeenCalledTimes(1) + expect(onOtherChange).toHaveBeenCalledWith({ + type: 'test', + count: 1, + other: 'b', + }) + expect(onStateChange).toHaveBeenCalledTimes(1) + expect(onStateChange).toHaveBeenCalledWith({type: 'test', count: 1, other: 'b'}) +}) + +test('callOnChangeProps calls onStateChange with all changes even if some on[Key]Change handlers are missing', () => { + const onCountChange = jest.fn() + const onStateChange = jest.fn() + const props = { + stateReducer: () => ({}), + onCountChange, + onOtherChange: undefined, + onStateChange, + } + const action = {type: 'test', props} + const state = {count: 0, other: 'a'} + const newState = {count: 1, other: 'b'} + + callOnChangeProps(action, props, state, newState) + + expect(onCountChange).toHaveBeenCalledTimes(1) + expect(onCountChange).toHaveBeenCalledWith({ + type: 'test', + count: 1, + other: 'b', + }) + expect(onStateChange).toHaveBeenCalledTimes(1) + expect(onStateChange).toHaveBeenCalledWith({ + type: 'test', + count: 1, + other: 'b', + }) +}) \ No newline at end of file diff --git a/src/hooks/utils-ts/__tests__/capitalizeString.test.ts b/src/hooks/utils-ts/__tests__/capitalizeString.test.ts new file mode 100644 index 000000000..e5af3a152 --- /dev/null +++ b/src/hooks/utils-ts/__tests__/capitalizeString.test.ts @@ -0,0 +1,13 @@ +import {capitalizeString} from '../capitalizeString' + +test('capitalizeString capitalizes the first letter of a string', () => { + expect(capitalizeString('downshift')).toBe('Downshift') +}) + +test('capitalizeString does not modify the rest of the string', () => { + expect(capitalizeString('dOWNsHIFT')).toBe('DOWNsHIFT') +}) + +test('capitalizeString returns an empty string if input is empty', () => { + expect(capitalizeString('')).toBe('') +}) diff --git a/src/hooks/utils-ts/__tests__/getDefaultValue.test.ts b/src/hooks/utils-ts/__tests__/getDefaultValue.test.ts new file mode 100644 index 000000000..963d5fda9 --- /dev/null +++ b/src/hooks/utils-ts/__tests__/getDefaultValue.test.ts @@ -0,0 +1,25 @@ +import {getDefaultValue} from '../getDefaultValue' + +test('getDefaultValue returns defaultStateValue when defaultProp is undefined', () => { + const defaultStateValue = 'defaultState' + const result = getDefaultValue(undefined, defaultStateValue) + expect(result).toBe(defaultStateValue) +}) + +test('getDefaultValue returns defaultProp when it is defined', () => { + const defaultProp = 'defaultProp' + const defaultStateValue = 'defaultState' + const result = getDefaultValue(defaultProp, defaultStateValue) + expect(result).toBe(defaultProp) +}) + +test('getDefaultValue returns defaultProp even when is null', () => { + const defaultStateValue = 'defaultState' + const result = getDefaultValue(null, defaultStateValue) + expect(result).toBe(null) +}) + +test('getDefaultValue returns defaultStateValue even when null', () => { + const result = getDefaultValue(undefined, null) + expect(result).toBe(null) +}) diff --git a/src/hooks/utils-ts/__tests__/getInitialValue.test.ts b/src/hooks/utils-ts/__tests__/getInitialValue.test.ts new file mode 100644 index 000000000..8d109c8e2 --- /dev/null +++ b/src/hooks/utils-ts/__tests__/getInitialValue.test.ts @@ -0,0 +1,79 @@ +import {getInitialValue} from '../getInitialValue' + +test('getInitialValue will return the controlled value if it is not undefined', () => { + const value = getInitialValue( + 'value', + 'initialValue', + 'defaultValue', + 'defaultStateValue', + ) + + expect(value).toBe('value') +}) + +test('getInitialValue will return the initialValue if value is undefined', () => { + const value = getInitialValue( + undefined, + 'initialValue', + 'defaultValue', + 'defaultStateValue', + ) + + expect(value).toBe('initialValue') +}) + +test('getInitialValue will return the defaultValue if value and initialValue are undefined', () => { + const value = getInitialValue( + undefined, + undefined, + 'defaultValue', + 'defaultStateValue', + ) + + expect(value).toBe('defaultValue') +}) + +test('getInitialValue will return the defaultStateValue if value, initialValue and defaultValue are undefined', () => { + const value = getInitialValue( + undefined, + undefined, + undefined, + 'defaultStateValue', + ) + + expect(value).toBe('defaultStateValue') +}) + +test("getInitialValue will return the controlled value even if it's null", () => { + const value = getInitialValue( + null, + 'initialValue', + 'defaultValue', + 'defaultStateValue', + ) + + expect(value).toBe(null) +}) + +test('getInitialValue will return the initialValue if value is null', () => { + const value = getInitialValue( + undefined, + null, + 'defaultValue', + 'defaultStateValue', + ) + + expect(value).toBe(null) +}) + +test('getInitialValue will return the defaultValue if value and initialValue are null', () => { + const value = getInitialValue(undefined, undefined, null, 'defaultStateValue') + + expect(value).toBe(null) +}) + +test('getInitialValue will return the defaultStateValue if value, initialValue and defaultValue are null', () => { + const value = getInitialValue(undefined, undefined, undefined, null) + + expect(value).toBe(null) +}) diff --git a/src/hooks/utils-ts/__tests__/getItemAndIndex.test.ts b/src/hooks/utils-ts/__tests__/getItemAndIndex.test.ts index f89f3769b..2b5dd518f 100644 --- a/src/hooks/utils-ts/__tests__/getItemAndIndex.test.ts +++ b/src/hooks/utils-ts/__tests__/getItemAndIndex.test.ts @@ -2,7 +2,7 @@ import {getItemAndIndex} from '../getItemAndIndex' test('returns the props if both are passed', () => { const item = {hi: 'hello'} - const index = 5 + const index = 0 expect(getItemAndIndex(item, index, [item], 'bla')).toEqual([item, index]) }) @@ -26,9 +26,9 @@ test('throws error when item is not passed and item is not found in the array', const item = {hi: 'hello'} const errorMessage = 'no item found at index' - expect(() => - getItemAndIndex(undefined, 1, [item], errorMessage), - ).toThrow(errorMessage) + expect(() => getItemAndIndex(undefined, 1, [item], errorMessage)).toThrow( + errorMessage, + ) }) test('returns the index and the item found', () => { @@ -40,8 +40,26 @@ test('returns the index and the item found', () => { test('throws error when both index and item are not passed', () => { const errorMessage = 'it is all wrong' - + expect(() => getItemAndIndex(undefined, undefined, [{item: 'bla'}], errorMessage), ).toThrow(errorMessage) }) + +test('throws error when index is passed but does not match', () => { + const index = 5 + const errorMessage = 'item and index do not match' + + expect(() => + getItemAndIndex(undefined, index, [{item: 'bla'}], errorMessage), + ).toThrow(errorMessage) +}) + +test('throws error when item is passed but does not match', () => { + const item = {hi: 'hello'} + const errorMessage = 'item and index do not match' + + expect(() => + getItemAndIndex(item, undefined, [{hi: 'bla'}], errorMessage), + ).toThrow(errorMessage) +}) diff --git a/src/hooks/utils-ts/__tests__/useA11yMessageStatus.test.ts b/src/hooks/utils-ts/__tests__/useA11yMessageStatus.test.ts new file mode 100644 index 000000000..82163ca35 --- /dev/null +++ b/src/hooks/utils-ts/__tests__/useA11yMessageStatus.test.ts @@ -0,0 +1,110 @@ +import {renderHook} from '@testing-library/react' +import {useA11yMessageStatus} from '../useA11yMessageStatus' +import {setStatus, cleanupStatusDiv} from '../../../utils-ts' + +// eslint-disable-next-line no-var +var cancelMock: jest.Mock + +jest.mock('../../../utils-ts', () => { + return { + ...jest.requireActual('../../../utils-ts'), + debounce: (fn: Function) => { + const debouncedFn = (...args: unknown[]) => fn(...args) + debouncedFn.cancel = jest.fn() + cancelMock = debouncedFn.cancel + return debouncedFn + }, + setStatus: jest.fn(), + cleanupStatusDiv: jest.fn(), + } +}) + +afterEach(() => { + jest.clearAllMocks() +}) + +test('useA11yMessageStatus returns the correct status message', () => { + const getA11yStatusMessage = ({name}: {name: string}) => `Hello, ${name}!` + const props = {name: 'test'} + const {rerender} = renderHook( + (currentProps: {name: string}) => + useA11yMessageStatus( + getA11yStatusMessage, + currentProps, + [currentProps.name], + { + document: window.document, + }, + ), + {initialProps: props}, + ) + + expect(setStatus).not.toHaveBeenCalled() + + props.name = 'billy' + rerender({name: 'billy'}) + + expect(setStatus).toHaveBeenCalledWith('Hello, billy!', window.document) +}) + +test('useA11yMessageStatus does not set status message if getA11yStatusMessage is not provided', () => { + const props = {name: 'test'} + const {rerender} = renderHook( + (currentProps: {name: string}) => + useA11yMessageStatus(undefined, currentProps, [currentProps.name], { + document: window.document, + }), + {initialProps: props}, + ) + + expect(setStatus).not.toHaveBeenCalled() + + props.name = 'billy' + rerender({name: 'billy'}) + + expect(setStatus).not.toHaveBeenCalled() +}) + +test('useA11yMessageStatus does not set status message if document is not provided', () => { + const props = {name: 'test'} + const {rerender} = renderHook( + (currentProps: {name: string}) => + useA11yMessageStatus( + undefined, + currentProps, + [currentProps.name], + undefined, + ), + {initialProps: props}, + ) + + expect(setStatus).not.toHaveBeenCalled() + + props.name = 'billy' + rerender({name: 'billy'}) + + expect(setStatus).not.toHaveBeenCalled() +}) + +test('useA11yMessageStatus cancels debounced status update and cleans up status div on unmount', () => { + const getA11yStatusMessage = ({name}: {name: string}) => `Hello, ${name}!` + const props = {name: 'test'} + const {unmount} = renderHook( + (currentProps: {name: string}) => + useA11yMessageStatus( + getA11yStatusMessage, + currentProps, + [currentProps.name], + { + document: window.document, + }, + ), + {initialProps: props}, + ) + + unmount() + + expect(cancelMock).toHaveBeenCalledTimes(1) + expect(cleanupStatusDiv).toHaveBeenCalledTimes(1) + expect(cleanupStatusDiv).toHaveBeenCalledWith(window.document) +}) diff --git a/src/hooks/utils-ts/__tests__/useControlledReducer.test.ts b/src/hooks/utils-ts/__tests__/useControlledReducer.test.ts new file mode 100644 index 000000000..70e155d01 --- /dev/null +++ b/src/hooks/utils-ts/__tests__/useControlledReducer.test.ts @@ -0,0 +1,41 @@ +import * as React from 'react' +import {renderHook} from '@testing-library/react' + +import {useControlledReducer} from '../useControlledReducer' + +test('useControlledReducer merges state changes with controlled values', () => { + const reducer = (state: {count: number}, action: {type: string}) => { + // eslint-disable-next-line jest/no-conditional-in-test + switch (action.type) { + case 'increment': + return {count: state.count + 1} + default: + return state + } + } + + const initialState = () => ({count: 0}) + const controlledProps = { + count: 5, + stateReducer: (state: {count: number}) => state, + } + + const {result} = renderHook(() => + useControlledReducer( + reducer, + controlledProps, + initialState, + (prev, next) => prev.count === next.count, + ), + ) + + const [state, dispatch] = result.current + + expect(state.count).toBe(5) + + React.act(() => { + dispatch({type: 'increment'}) + }) + + expect(state.count).toBe(5) +}) diff --git a/src/hooks/utils-ts/__tests__/useEnhancedReducer.test.ts b/src/hooks/utils-ts/__tests__/useEnhancedReducer.test.ts new file mode 100644 index 000000000..1e198b1a6 --- /dev/null +++ b/src/hooks/utils-ts/__tests__/useEnhancedReducer.test.ts @@ -0,0 +1,340 @@ +/* eslint-disable jest/no-conditional-in-test */ +import * as React from 'react' +import {renderHook} from '@testing-library/react' +import {useEnhancedReducer} from '../useEnhancedReducer' +import {callOnChangeProps} from '../callOnChangeProps' + +jest.mock('../callOnChangeProps', () => { + const originalModule = jest.requireActual< + typeof import('../callOnChangeProps') + >('../callOnChangeProps') + + return { + callOnChangeProps: jest + .fn() + .mockImplementation(originalModule.callOnChangeProps), + } +}) + +type ReducerProps = { + stateReducer: ( + state: {count: number}, + actionAndChanges: {changes: Partial<{count: number}>}, + ) => Partial<{count: number}> + onStateChange?: (changes: {type: string} & Partial<{count: number}>) => void + onCountChange?: (changes: {type: string} & Partial<{count: number}>) => void + count?: number +} + +type ReducerState = {count: number} +type ReducerAction = {type: string; add?: number} + +const defaultProps: ReducerProps = { + stateReducer: jest + .fn() + .mockImplementation( + ( + _state: ReducerState, + actionAndChanges: {changes: Partial}, + ) => actionAndChanges.changes, + ), +} + +function renderReducer(propsOverrides: Partial = {}) { + const reducer = ( + state: ReducerState, + action: ReducerAction, + ) => { + switch (action.type) { + case 'increment': + return {count: state.count + 1} + case 'add': + return {count: state.count + (action.add ?? 0)} + default: + return state + } + } + + const props = { + ...defaultProps, + ...propsOverrides, + } + + const createInitialState = jest.fn(() => ({count: 0})) + const isStateEqual = (prev: {count: number}, next: {count: number}) => + prev.count === next.count + + const utils = renderHook( + (currentProps: ReducerProps) => + useEnhancedReducer( + reducer, + currentProps, + createInitialState, + isStateEqual, + ), + {initialProps: props}, + ) + + const rerender = (newProps: Partial = {}) => { + const mergedProps = {...props, ...newProps} + utils.rerender(mergedProps) + } + + return {...utils, rerender, createInitialState} +} + +describe('useEnhancedReducer', () => { + afterEach(() => { + jest.clearAllMocks() + }) + + test('should update state after dispatch', () => { + const {result} = renderReducer() + const [state, dispatch] = result.current + + expect(state.count).toBe(0) + React.act(() => { + dispatch({type: 'increment'}) + }) + const [newState] = result.current + expect(newState.count).toBe(1) + }) + + test('should call onStateChange and onCountChange when state changes', () => { + const onStateChange = jest.fn() + const onCountChange = jest.fn() + const {result} = renderReducer({onStateChange, onCountChange}) + const [, dispatch] = result.current + + React.act(() => { + dispatch({type: 'increment'}) + }) + + expect(callOnChangeProps).toHaveBeenCalledTimes(1) + expect(callOnChangeProps).toHaveBeenCalledWith( + expect.objectContaining({type: 'increment'}), + expect.objectContaining({ + stateReducer: defaultProps.stateReducer, + onStateChange, + onCountChange, + }), + {count: 0}, + {count: 1}, + ) + expect(onStateChange).toHaveBeenCalledTimes(1) + expect(onStateChange).toHaveBeenCalledWith({type: 'increment', count: 1}) + expect(onCountChange).toHaveBeenCalledTimes(1) + expect(onCountChange).toHaveBeenCalledWith({type: 'increment', count: 1}) + }) + + test('should not call onStateChange and onCountChange when state does not change', () => { + const onStateChange = jest.fn() + const onCountChange = jest.fn() + const {result} = renderReducer({onStateChange, onCountChange}) + const [, dispatch] = result.current + + React.act(() => { + dispatch({type: 'add', add: 0}) + }) + + expect(callOnChangeProps).not.toHaveBeenCalled() + expect(onStateChange).not.toHaveBeenCalled() + }) + + test('should allow custom stateReducer to override changes', () => { + const customStateReducer = ( + _state: {count: number}, + actionAndChanges: {changes: Partial<{count: number}>}, + ) => ({count: (actionAndChanges.changes.count ?? 0) + 10}) + + const {result} = renderReducer({stateReducer: customStateReducer}) + const [, dispatch] = result.current + + React.act(() => { + dispatch({type: 'increment'}) + }) + const [newState] = result.current + expect(newState.count).toBe(11) + }) + + test('should use latest props in dispatch', () => { + const onStateChange = jest.fn() + const {result, rerender} = renderReducer() + const [, dispatch] = result.current + + React.act(() => { + dispatch({type: 'increment'}) + }) + + rerender({ + ...defaultProps, + onStateChange, + }) + + React.act(() => { + dispatch({type: 'increment'}) + }) + + expect(result.current[0].count).toBe(2) + expect(onStateChange).toHaveBeenCalledTimes(1) + }) + + test('should add the props to action when dispatching', () => { + const {result} = renderReducer() + const [, dispatch] = result.current + + React.act(() => { + dispatch({type: 'increment'}) + }) + + expect(defaultProps.stateReducer).toHaveBeenCalledTimes(1) + expect(defaultProps.stateReducer).toHaveBeenCalledWith( + expect.objectContaining({count: 0}), + expect.objectContaining({ + changes: {count: 1}, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + props: expect.objectContaining>(defaultProps), + }), + ) + }) + + test('reducer is called with controlled state', () => { + const {result} = renderReducer({count: 5}) + const [, dispatch] = result.current + + React.act(() => { + dispatch({type: 'increment'}) + }) + + expect(defaultProps.stateReducer).toHaveBeenCalledTimes(1) + expect(defaultProps.stateReducer).toHaveBeenCalledWith( + expect.objectContaining({count: 5}), + expect.objectContaining({ + changes: {count: 6}, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + props: expect.objectContaining>({...defaultProps, count: 5}), + }), + ) + }) + + test('reducer is called with updated controlled state after rerender', () => { + // Regression: getState(state, action.props) must use props at dispatch time, + // not stale props from a previous render. If action.props was captured before + // the rerender that introduced count: 5, getState would start from 0, not 5. + const {result, rerender} = renderReducer() + const [, dispatch] = result.current + + rerender({count: 5}) + + React.act(() => { + dispatch({type: 'add', add: 2}) + }) + + expect(defaultProps.stateReducer).toHaveBeenCalledWith( + expect.objectContaining({count: 5}), + expect.objectContaining({ + changes: {count: 7}, + }), + ) + expect( + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + (defaultProps.stateReducer as jest.Mock).mock.calls[0][1].props.count, + ).toBe(5) + expect(result.current[0].count).toBe(7) + }) + + test('callOnChangeProps receives controlled prevState from previous action props', () => { + // prevState in the effect is computed as getState(prevStateRef.current, action.props), + // so it should reflect any controlled value that was active at dispatch time. + const onStateChange = jest.fn() + const {result, rerender} = renderReducer({onStateChange}) + const [, dispatch] = result.current + + React.act(() => { + dispatch({type: 'increment'}) + }) + + // now introduce a controlled count: 10 and dispatch again + rerender({onStateChange, count: 10}) + + React.act(() => { + dispatch({type: 'increment'}) + }) + + // second call: prevState should reflect the controlled count:10 from before the dispatch, + // not the raw prevStateRef value of 1 + expect(callOnChangeProps).toHaveBeenCalledTimes(2) + expect(callOnChangeProps).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({type: 'increment'}), + expect.anything(), + {count: 10}, + {count: 11}, + ) + }) + + test('callOnChangeProps is not called on mount without any dispatch', () => { + renderReducer() + + expect(callOnChangeProps).not.toHaveBeenCalled() + }) + + test('stateReducer can partially override changes', () => { + // stateReducer returns {} which means no state change — keeps original state + const vetoReducer = (state: ReducerState) => state + const {result} = renderReducer({stateReducer: vetoReducer}) + const [, dispatch] = result.current + + React.act(() => { + dispatch({type: 'increment'}) + }) + + expect(result.current[0].count).toBe(0) + }) + + test('callOnChangeProps is not called when stateReducer cancels changes', () => { + const onStateChange = jest.fn() + const vetoReducer = (state: ReducerState) => state + const {result} = renderReducer({stateReducer: vetoReducer, onStateChange}) + const [, dispatch] = result.current + + React.act(() => { + dispatch({type: 'increment'}) + }) + + expect(callOnChangeProps).not.toHaveBeenCalled() + expect(onStateChange).not.toHaveBeenCalled() + }) + + test('callOnChangeProps receives prevState equal to state after previous dispatch', () => { + const {result} = renderReducer() + const [, dispatch] = result.current + + React.act(() => { + dispatch({type: 'increment'}) + }) + + React.act(() => { + dispatch({type: 'increment'}) + }) + + expect(callOnChangeProps).toHaveBeenCalledTimes(2) + expect(callOnChangeProps).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({type: 'increment'}), + expect.anything(), + {count: 1}, + {count: 2}, + ) + }) + + test('createInitialState is called with the correct initial props', () => { + const initialProps = {...defaultProps, count: 3} + const {createInitialState} = renderReducer(initialProps) + + expect(createInitialState).toHaveBeenCalledTimes(1) + expect(createInitialState).toHaveBeenCalledWith( + expect.objectContaining(initialProps), + ) + }) +}) diff --git a/src/hooks/utils-ts/__tests__/useIsInitialMount.test.ts b/src/hooks/utils-ts/__tests__/useIsInitialMount.test.ts new file mode 100644 index 000000000..ec95e9df0 --- /dev/null +++ b/src/hooks/utils-ts/__tests__/useIsInitialMount.test.ts @@ -0,0 +1,40 @@ +import {renderHook} from '@testing-library/react' +import {useIsInitialMount} from '../useIsInitialMount' + +test('useIsInitialMount returns true on first render and false on subsequent renders', () => { + const {result, rerender} = renderHook(() => useIsInitialMount()) + + expect(result.current).toBe(true) + + rerender() + + expect(result.current).toBe(false) +}) + +test('useIsInitialMount resets to true when component unmounts and remounts', () => { + const {result, rerender, unmount} = renderHook(() => useIsInitialMount()) + + expect(result.current).toBe(true) + + rerender() + + expect(result.current).toBe(false) + + unmount() + + const {result: remountedResult} = renderHook(() => useIsInitialMount()) + + expect(remountedResult.current).toBe(true) +}) + +test('useIsInitialMount does not change value on re-renders', () => { + const {result, rerender} = renderHook(() => useIsInitialMount()) + + expect(result.current).toBe(true) + + rerender() + rerender() + rerender() + + expect(result.current).toBe(false) +}) diff --git a/src/hooks/utils-ts/callOnChangeProps.ts b/src/hooks/utils-ts/callOnChangeProps.ts index f755892c3..ddad1da13 100644 --- a/src/hooks/utils-ts/callOnChangeProps.ts +++ b/src/hooks/utils-ts/callOnChangeProps.ts @@ -1,14 +1,16 @@ -import {Action, Props, State} from '../../utils-ts' import {capitalizeString} from './capitalizeString' -export function callOnChangeProps< - S extends State, - P extends Partial & Props, - T, ->(action: Action, props: P, state: S, newState: S) { +import {Action, Props} from './index.types' + +export function callOnChangeProps( + action: Action, + props: Props, + state: S, + newState: S, +) { const {type} = action - const changes: Partial = {} - const keys = Object.keys(state) + const changes: Partial = {} + const keys = Object.keys(state) as (keyof S)[] for (const key of keys) { invokeOnChangeHandler(key, action, props, state, newState) @@ -18,22 +20,24 @@ export function callOnChangeProps< } } - if (props.onStateChange && Object.keys(changes).length) { + if (typeof props.onStateChange === 'function' && Object.keys(changes).length) { props.onStateChange({type, ...changes}) } } -function invokeOnChangeHandler< - S extends State, - P extends Partial & Props, - T, ->(key: string, action: Action, props: P, state: S, newState: S) { +function invokeOnChangeHandler( + key: keyof S, + action: Action, + props: Props, + state: S, + newState: S, +) { if (newState[key] === state[key]) { return } - const handlerKey = `on${capitalizeString(key)}Change` - const handler = props[handlerKey] + const handlerKey = `on${capitalizeString(key as string)}Change` + const handler = (props as Record)[handlerKey] if (typeof handler !== 'function') { return diff --git a/src/hooks/utils-ts/getDefaultValue.ts b/src/hooks/utils-ts/getDefaultValue.ts index 6aa3bd009..1e6205b7e 100644 --- a/src/hooks/utils-ts/getDefaultValue.ts +++ b/src/hooks/utils-ts/getDefaultValue.ts @@ -1,16 +1,13 @@ -import {State} from '../../utils-ts' -import {capitalizeString} from './capitalizeString' - -export function getDefaultValue>( - props: P, - propKey: keyof S, - defaultStateValues: S, -): S[keyof S] { - const defaultValue = props[`default${capitalizeString(propKey as string)}`] - - if (defaultValue !== undefined) { - return defaultValue as S[keyof S] - } - - return defaultStateValues[propKey] +/** + * Returns the default value based on the defaultProp and defaultStateValue. + * + * @param defaultProp The default prop value. + * @param defaultStateValue The default state value. + * @returns The resolved default value. + */ +export function getDefaultValue( + defaultProp: T | undefined, + defaultStateValue: T, +): T { + return defaultProp === undefined ? defaultStateValue : defaultProp } diff --git a/src/hooks/utils-ts/getInitialValue.ts b/src/hooks/utils-ts/getInitialValue.ts index 954e5fbb3..fe8c400f0 100644 --- a/src/hooks/utils-ts/getInitialValue.ts +++ b/src/hooks/utils-ts/getInitialValue.ts @@ -1,23 +1,33 @@ -import {State} from '../../utils-ts' -import {capitalizeString} from './capitalizeString' -import {getDefaultValue} from './getDefaultValue' - -export function getInitialValue>( - props: P, - propKey: keyof S, - defaultStateValues: S, -): S[keyof S] { - const value = props[propKey] as keyof S | undefined - +/** + * Returns the initial value for a state variable, based on the following precedence: + * 1. The controlled value (if it's not undefined) + * 2. The initialValue (if it's not undefined) + * 3. The defaultValue (if it's not undefined) + * 4. The defaultStateValue + * + * @param value The controlled value of the state variable. + * @param initialValue The initial value of the state variable. + * @param defaultValue The default value of the state variable. + * @param defaultStateValue The default state value to use if all other values are undefined. + * @returns The initial value for the state variable. + */ +export function getInitialValue( + value: T | undefined, + initialValue: T | undefined, + defaultValue: T | undefined, + defaultStateValue: T, +): T { if (value !== undefined) { - return value as S[keyof S] + return value } - const initialValue = props[`initial${capitalizeString(propKey as string)}`] - if (initialValue !== undefined) { - return initialValue as S[keyof S] + return initialValue + } + + if (defaultValue !== undefined) { + return defaultValue } - return getDefaultValue(props, propKey, defaultStateValues) + return defaultStateValue } diff --git a/src/hooks/utils-ts/index.ts b/src/hooks/utils-ts/index.ts index 9f4c66895..b229cf7f6 100644 --- a/src/hooks/utils-ts/index.ts +++ b/src/hooks/utils-ts/index.ts @@ -9,3 +9,4 @@ export {capitalizeString} from './capitalizeString' export {getDefaultValue} from './getDefaultValue' export {getInitialValue} from './getInitialValue' export {useA11yMessageStatus} from './useA11yMessageStatus' +export type {StateReducer, Props, Action, Reducer} from './index.types' diff --git a/src/hooks/utils-ts/index.types.ts b/src/hooks/utils-ts/index.types.ts new file mode 100644 index 000000000..721b7c106 --- /dev/null +++ b/src/hooks/utils-ts/index.types.ts @@ -0,0 +1,18 @@ +export type StateReducer = ( + state: S, + actionAndChanges: Action & {changes: Partial}, +) => Partial + +export type Props = Partial & { + stateReducer: StateReducer + onStateChange?: (changes: {type: A['type']} & Partial) => void +} + +export type Action = A & { + props: Props +} + +export type Reducer = ( + state: S, + action: Action, +) => Partial diff --git a/src/hooks/utils-ts/stateReducer.ts b/src/hooks/utils-ts/stateReducer.ts index 1fedcb71d..20c6ea1c5 100644 --- a/src/hooks/utils-ts/stateReducer.ts +++ b/src/hooks/utils-ts/stateReducer.ts @@ -1,4 +1,9 @@ -import {Action, State} from '../../utils-ts' +type State = Record + +export type Action = { + type: string + changes: Partial +} /** * Default state reducer that returns the changes. diff --git a/src/hooks/utils-ts/useControlledReducer.ts b/src/hooks/utils-ts/useControlledReducer.ts index aa05ce1bc..c6692e3c6 100644 --- a/src/hooks/utils-ts/useControlledReducer.ts +++ b/src/hooks/utils-ts/useControlledReducer.ts @@ -1,5 +1,6 @@ -import {getState, type Action, type State, type Props} from '../../utils-ts' +import {getState} from '../../utils-ts' import {useEnhancedReducer} from './useEnhancedReducer' +import {type Props, type Reducer} from './index.types' /** * Wraps the useEnhancedReducer and applies the controlled prop values before @@ -12,17 +13,15 @@ import {useEnhancedReducer} from './useEnhancedReducer' * @returns {Array} An array with the state and an action dispatcher. */ export function useControlledReducer< - S extends State, - P extends Partial & Props, - T, - A extends Action, + S extends object, + A extends {type: string}, >( - reducer: (state: S, props: P, action: A) => S, - props: P, - createInitialState: (props: P) => S, - isStateEqual: (prevState: S, newState: S) => boolean, + reducer: Reducer, + props: Props, + createInitialState: (props: Props) => S, + isStateEqual: (prev: S, next: S) => boolean, ): [S, (action: A) => void] { - const [state, dispatch] = useEnhancedReducer( + const [state, dispatch] = useEnhancedReducer( reducer, props, createInitialState, diff --git a/src/hooks/utils-ts/useEnhancedReducer.ts b/src/hooks/utils-ts/useEnhancedReducer.ts index 9b06adf74..12c05d88f 100644 --- a/src/hooks/utils-ts/useEnhancedReducer.ts +++ b/src/hooks/utils-ts/useEnhancedReducer.ts @@ -1,77 +1,62 @@ import * as React from 'react' -import { - type Action, - type Props, - type State, - getState, - useLatestRef, -} from '../../utils-ts' +import {getState, useLatestRef} from '../../utils-ts' import {callOnChangeProps} from './callOnChangeProps' +import {type Action, type Props, type Reducer} from './index.types' /** * Computes the controlled state using a the previous state, props, * two reducers, one from downshift and an optional one from the user. * Also calls the onChange handlers for state values that have changed. * - * @param {Function} reducer Reducer function from downshift. - * @param {Object} props The hook props, also passed to createInitialState. - * @param {Function} createInitialState Function that returns the initial state. - * @param {Function} isStateEqual Function that checks if a previous state is equal to the next. - * @returns {Array} An array with the state and an action dispatcher. + * @param reducer Reducer function from downshift. + * @param props The hook props, also passed to createInitialState. + * @param createInitialState Function that returns the initial state. + * @param isStateEqual Function that checks if a previous state is equal to the next. + * @returns An array with the state and an action dispatcher. */ -export function useEnhancedReducer< - S extends State, - P extends Partial & Props, - T, - A extends Action, ->( - reducer: (state: S, props: P, action: A) => S, - props: P, - createInitialState: (props: P) => S, - isStateEqual: (prevState: S, newState: S) => boolean, +export function useEnhancedReducer( + reducer: Reducer, + props: Props, + createInitialState: (props: Props) => S, + isStateEqual: (prev: S, next: S) => boolean, ): [S, (action: A) => void] { - const prevStateRef = React.useRef(null) - const actionRef = React.useRef(undefined) - const propsRef = useLatestRef(props) - + const prevStateRef = React.useRef({} as S) + const actionRef = React.useRef>() const enhancedReducer = React.useCallback( - (state: S, action: A): S => { + (state: S, action: Action): S => { actionRef.current = action - state = getState(state, propsRef.current) + state = getState(state, action.props) - const changes = reducer(state, propsRef.current, action) - const newState = propsRef.current.stateReducer(state, { - ...action, - changes, - }) + const changes = reducer(state, action) + const newState = action.props.stateReducer(state, {...action, changes}) return {...state, ...newState} }, - [propsRef, reducer], + [reducer], ) const [state, dispatch] = React.useReducer( enhancedReducer, props, createInitialState, ) - + const propsRef = useLatestRef(props) + const dispatchWithProps = React.useCallback( + (action: A) => dispatch({...action, props: propsRef.current}), + [propsRef], + ) const action = actionRef.current React.useEffect(() => { - const prevState = getState( - prevStateRef.current ?? ({} as S), - propsRef.current, - ) - const shouldCallOnChangeProps = - action && prevStateRef.current && !isStateEqual(prevState, state) + const prevState = getState(prevStateRef.current, action?.props) + const shouldCallOnChangeProps = action && !isStateEqual(prevState, state) if (shouldCallOnChangeProps) { - callOnChangeProps(action, propsRef.current, prevState, state) + callOnChangeProps(action, action.props, prevState, state) } prevStateRef.current = state - }, [state, action, isStateEqual, propsRef]) + }, [state, action, isStateEqual]) - return [state, dispatch] + return [state, dispatchWithProps] } diff --git a/src/hooks/utils.js b/src/hooks/utils.js index bf4cc488b..c3f20e92a 100644 --- a/src/hooks/utils.js +++ b/src/hooks/utils.js @@ -19,16 +19,23 @@ function isAcceptedCharacterKey(key) { function getInitialState(props) { const selectedItem = getInitialValue( - props, - 'selectedItem', - dropdownDefaultStateValues, + props.selectedItem, + props.initialSelectedItem, + props.defaultSelectedItem, + dropdownDefaultStateValues.selectedItem, + ) + const isOpen = getInitialValue( + props.isOpen, + props.initialIsOpen, + props.defaultIsOpen, + dropdownDefaultStateValues.isOpen, ) - const isOpen = getInitialValue(props, 'isOpen', dropdownDefaultStateValues) const highlightedIndex = getInitialHighlightedIndex(props) const inputValue = getInitialValue( - props, - 'inputValue', - dropdownDefaultStateValues, + props.inputValue, + props.initialInputValue, + props.defaultInputValue, + dropdownDefaultStateValues.inputValue, ) return { @@ -99,21 +106,17 @@ function getHighlightedIndexOnOpen(props, state, offset) { * @param {Array<{current: HTMLElement}>} downshiftElementsRefs The refs for the elements that should not trigger a blur action from mouseDown or touchEnd. * @returns {{isMouseDown: boolean, isTouchMove: boolean, isTouchEnd: boolean}} The mouse and touch events information, if any of are happening. */ -function useMouseAndTouchTracker( - environment, - handleBlur, - downshiftRefs, -) { +function useMouseAndTouchTracker(environment, handleBlur, downshiftRefs) { const mouseAndTouchTrackersRef = React.useRef({ isMouseDown: false, isTouchMove: false, isTouchEnd: false, }) -const getDownshiftElements = React.useCallback( - () => downshiftRefs.map(ref => ref.current), - [downshiftRefs], -); + const getDownshiftElements = React.useCallback( + () => downshiftRefs.map(ref => ref.current), + [downshiftRefs], + ) React.useEffect(() => { if (isReactNative || !environment) { @@ -307,11 +310,13 @@ function getChangesOnSelection(props, highlightedIndex, inputValue = true) { highlightedIndex: -1, ...(shouldSelect && { selectedItem: props.items[highlightedIndex], - isOpen: getDefaultValue(props, 'isOpen', dropdownDefaultStateValues), + isOpen: getDefaultValue( + props.defaultIsOpen, + dropdownDefaultStateValues.isOpen, + ), highlightedIndex: getDefaultValue( - props, - 'highlightedIndex', - dropdownDefaultStateValues, + props.defaultHighlightedIndex, + dropdownDefaultStateValues.highlightedIndex, ), ...(inputValue && { inputValue: props.itemToString(props.items[highlightedIndex]), @@ -345,9 +350,8 @@ function isDropdownsStateEqual(prevState, newState) { */ function getDefaultHighlightedIndex(props) { const highlightedIndex = getDefaultValue( - props, - 'highlightedIndex', - dropdownDefaultStateValues, + props.defaultHighlightedIndex, + dropdownDefaultStateValues.highlightedIndex, ) if ( highlightedIndex > -1 && @@ -367,9 +371,10 @@ function getDefaultHighlightedIndex(props) { */ function getInitialHighlightedIndex(props) { const highlightedIndex = getInitialValue( - props, - 'highlightedIndex', - dropdownDefaultStateValues, + props.highlightedIndex, + props.initialHighlightedIndex, + props.defaultHighlightedIndex, + dropdownDefaultStateValues.highlightedIndex, ) if ( diff --git a/src/utils-ts/__tests__/getState.test.ts b/src/utils-ts/__tests__/getState.test.ts index 4feaac8af..71b51dd11 100644 --- a/src/utils-ts/__tests__/getState.test.ts +++ b/src/utils-ts/__tests__/getState.test.ts @@ -1,4 +1,4 @@ -import {getState, Props} from '../getState' +import {getState} from '../getState' test('returns state if no props are passed', () => { const state = {a: 'b'} @@ -8,7 +8,7 @@ test('returns state if no props are passed', () => { test('merges state with props', () => { const state = {a: 'b', c: 'd'} - const props = {b: 'e', c: 'f'} as unknown as Props + const props = {b: 'e', c: 'f'} expect(getState(state, props)).toEqual({a: 'b', c: 'f'}) }) diff --git a/src/utils-ts/getState.ts b/src/utils-ts/getState.ts index f49720a5d..9c54effc3 100644 --- a/src/utils-ts/getState.ts +++ b/src/utils-ts/getState.ts @@ -1,17 +1,3 @@ -export interface Action extends Record { - type: T -} - -export type State = Record - -export interface Props { - onStateChange?(typeAndChanges: unknown): void - stateReducer( - state: S, - actionAndChanges: Action & {changes: Partial}, - ): Partial -} - /** * This will perform a shallow merge of the given state object * with the state coming from props @@ -23,11 +9,7 @@ export interface Props { * @param props The props that may contain controlled values. * @returns The merged controlled state. */ -export function getState< - S extends State, - P extends Partial & Props, - T, ->(state: S, props?: P): S { +export function getState(state: S, props?: Partial): S { if (!props) { return state } @@ -36,8 +18,10 @@ export function getState< return keys.reduce( (newState, key) => { + // state keys could be in props, but with value undefined, which means they should be ignored. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (props[key] !== undefined) { - newState[key] = (props as Partial)[key] as S[typeof key] + newState[key] = props[key] as S[typeof key] } return newState }, diff --git a/src/utils-ts/index.ts b/src/utils-ts/index.ts index afd68dce9..208750b50 100644 --- a/src/utils-ts/index.ts +++ b/src/utils-ts/index.ts @@ -7,5 +7,4 @@ export {setStatus, cleanupStatusDiv} from './setA11yStatus' export {noop} from './noop' export {validatePropTypes} from './validatePropTypes' export {getState} from './getState' -export type {Action, Props, State} from './getState' export {scrollIntoView} from './scrollIntoView'