From 8551d7e9a9713863a131d042e583cdb02ed45f45 Mon Sep 17 00:00:00 2001 From: Silviu Alexandu Avram Date: Wed, 9 Jul 2025 09:42:03 +0300 Subject: [PATCH 01/40] migrate some utils to ts --- src/__tests__/downshift.aria.js | 2 +- src/__tests__/downshift.get-item-props.js | 2 +- src/__tests__/utils.reset-id-counter.js | 2 +- src/__tests__/utils.reset-id-counter.r18.js | 2 +- src/downshift.js | 4 +- src/hooks/__tests__/utils.test.js | 2 +- src/hooks/index.js | 1 + src/hooks/useCombobox/index.js | 2 +- src/hooks/useMultipleSelection/index.js | 2 +- src/hooks/useSelect/index.js | 2 +- src/hooks/utils-ts.ts | 40 ++++++++++++++++++ src/hooks/utils.js | 21 +--------- src/index.js | 4 +- src/utils-ts.ts | 46 +++++++++++++++++++++ src/utils.js | 38 ----------------- 15 files changed, 101 insertions(+), 69 deletions(-) create mode 100644 src/hooks/utils-ts.ts create mode 100644 src/utils-ts.ts diff --git a/src/__tests__/downshift.aria.js b/src/__tests__/downshift.aria.js index 19d6451d9..412554857 100644 --- a/src/__tests__/downshift.aria.js +++ b/src/__tests__/downshift.aria.js @@ -1,7 +1,7 @@ import * as React from 'react' import {render, screen} from '@testing-library/react' import Downshift from '../' -import {resetIdCounter} from '../utils' +import {resetIdCounter} from '../utils-ts' beforeEach(() => { if (!('useId' in React)) resetIdCounter() diff --git a/src/__tests__/downshift.get-item-props.js b/src/__tests__/downshift.get-item-props.js index fbd028ef0..cb3058eb4 100644 --- a/src/__tests__/downshift.get-item-props.js +++ b/src/__tests__/downshift.get-item-props.js @@ -1,7 +1,7 @@ import * as React from 'react' import {render, fireEvent, screen} from '@testing-library/react' import Downshift from '../' -import {setIdCounter} from '../utils' +import {setIdCounter} from '../utils-ts' beforeEach(() => { setIdCounter(1) diff --git a/src/__tests__/utils.reset-id-counter.js b/src/__tests__/utils.reset-id-counter.js index be8b6f8da..8daaf2fca 100644 --- a/src/__tests__/utils.reset-id-counter.js +++ b/src/__tests__/utils.reset-id-counter.js @@ -1,7 +1,7 @@ import * as React from 'react' import {render, screen} from '@testing-library/react' import Downshift from '../' -import {resetIdCounter} from '../utils' +import {resetIdCounter} from '../utils-ts' jest.mock('react', () => { const {useId, ...react} = jest.requireActual('react') diff --git a/src/__tests__/utils.reset-id-counter.r18.js b/src/__tests__/utils.reset-id-counter.r18.js index 6f3e3aa3a..abe49db1b 100644 --- a/src/__tests__/utils.reset-id-counter.r18.js +++ b/src/__tests__/utils.reset-id-counter.r18.js @@ -1,7 +1,7 @@ import * as React from 'react' import {render, screen} from '@testing-library/react' import Downshift from '..' -import {resetIdCounter} from '../utils' +import {resetIdCounter} from '../utils-ts' afterAll(() => { jest.restoreAllMocks() diff --git a/src/downshift.js b/src/downshift.js index 49bdda0f6..381650559 100644 --- a/src/downshift.js +++ b/src/downshift.js @@ -11,7 +11,6 @@ import { callAllEventHandlers, cbToCb, debounce, - generateId, getA11yStatusMessage, getElementProps, isDOMElement, @@ -29,6 +28,9 @@ import { getHighlightedIndex, getNonDisabledIndex, } from './utils' +import { + generateId +} from './utils-ts' class Downshift extends Component { static propTypes = { diff --git a/src/hooks/__tests__/utils.test.js b/src/hooks/__tests__/utils.test.js index 204e2b38b..4be6196d7 100644 --- a/src/hooks/__tests__/utils.test.js +++ b/src/hooks/__tests__/utils.test.js @@ -4,10 +4,10 @@ import { getInitialValue, getDefaultValue, useMouseAndTouchTracker, - getItemAndIndex, isDropdownsStateEqual, useElementIds, } from '../utils' +import {getItemAndIndex} from '../utils-ts' describe('utils', () => { describe('useElementIds', () => { diff --git a/src/hooks/index.js b/src/hooks/index.js index a1bb13600..c72d76376 100644 --- a/src/hooks/index.js +++ b/src/hooks/index.js @@ -1,3 +1,4 @@ export {default as useSelect} from './useSelect' export {default as useCombobox} from './useCombobox' export {default as useMultipleSelection} from './useMultipleSelection' +export {default as useTagGroup} from './useTagGroup' diff --git a/src/hooks/useCombobox/index.js b/src/hooks/useCombobox/index.js index e10c162ee..7c0553deb 100644 --- a/src/hooks/useCombobox/index.js +++ b/src/hooks/useCombobox/index.js @@ -8,12 +8,12 @@ import { useScrollIntoView, useControlPropsValidator, useElementIds, - getItemAndIndex, getInitialValue, isDropdownsStateEqual, useIsInitialMount, useA11yMessageStatus, } from '../utils' +import { getItemAndIndex } from '../utils-ts' import { getInitialState, defaultProps, diff --git a/src/hooks/useMultipleSelection/index.js b/src/hooks/useMultipleSelection/index.js index 1bdea917b..b08669a02 100644 --- a/src/hooks/useMultipleSelection/index.js +++ b/src/hooks/useMultipleSelection/index.js @@ -5,10 +5,10 @@ import { useGetterPropsCalledChecker, useLatestRef, useControlPropsValidator, - getItemAndIndex, useIsInitialMount, useA11yMessageStatus, } from '../utils' +import { getItemAndIndex } from '../utils-ts' import { getInitialState, defaultProps, diff --git a/src/hooks/useSelect/index.js b/src/hooks/useSelect/index.js index 5b7d37f38..f79d6bf47 100644 --- a/src/hooks/useSelect/index.js +++ b/src/hooks/useSelect/index.js @@ -9,7 +9,6 @@ import { useControlPropsValidator, useElementIds, useMouseAndTouchTracker, - getItemAndIndex, getInitialValue, isDropdownsStateEqual, useA11yMessageStatus, @@ -21,6 +20,7 @@ import { normalizeArrowKey, } from '../../utils' import {isReactNative, isReactNativeWeb} from '../../is.macro' +import { getItemAndIndex } from '../utils-ts' import downshiftSelectReducer from './reducer' import {validatePropTypes, defaultProps} from './utils' import * as stateChangeTypes from './stateChangeTypes' diff --git a/src/hooks/utils-ts.ts b/src/hooks/utils-ts.ts new file mode 100644 index 000000000..719afb70d --- /dev/null +++ b/src/hooks/utils-ts.ts @@ -0,0 +1,40 @@ +/** + * Returns both the item and index when both or either is passed. + * + * @param itemProp The item which could be undefined. + * @param indexProp The index which could be undefined. + * @param items The array of items to get the item based on index. + * @param errorMessage The error to be thrown if index and item could not be returned for any reason. + * @returns An array with item and index. + */ +export function getItemAndIndex( + itemProp: Item | undefined, + indexProp: number | undefined, + items: Item[], + errorMessage: string, +): [Item, number] { + if (itemProp !== undefined && indexProp !== undefined) { + return [itemProp, indexProp] + } + + if (itemProp !== undefined) { + const index = items.indexOf(itemProp) + + if (index < 0) { + throw new Error(errorMessage) + } + + return [itemProp, items.indexOf(itemProp)] + } + + if (indexProp !== undefined) { + const item = items[indexProp] + + if (item === undefined) { + throw new Error(errorMessage) + } + return [item, indexProp] + } + + throw new Error(errorMessage) +} diff --git a/src/hooks/utils.js b/src/hooks/utils.js index cbc8c6e6c..3311adb73 100644 --- a/src/hooks/utils.js +++ b/src/hooks/utils.js @@ -11,13 +11,13 @@ import {isReactNative} from '../is.macro' import { scrollIntoView, getState, - generateId, debounce, validateControlledUnchanged, noop, targetWithinDownshift, } from '../utils' import {cleanupStatusDiv, setStatus} from '../set-a11y-status' +import { generateId } from '../utils-ts' const dropdownDefaultStateValues = { highlightedIndex: -1, @@ -133,24 +133,6 @@ const useElementIds = return elementIds } -function getItemAndIndex(itemProp, indexProp, items, errorMessage) { - let item, index - - if (itemProp === undefined) { - if (indexProp === undefined) { - throw new Error(errorMessage) - } - - item = items[indexProp] - index = indexProp - } else { - index = indexProp === undefined ? items.indexOf(itemProp) : indexProp - item = itemProp - } - - return [item, index] -} - function isAcceptedCharacterKey(key) { return /^\S{1}$/.test(key) } @@ -752,7 +734,6 @@ export { useLatestRef, capitalizeString, isAcceptedCharacterKey, - getItemAndIndex, useElementIds, getChangesOnSelection, isDropdownsStateEqual, diff --git a/src/index.js b/src/index.js index ef3656b33..357106112 100644 --- a/src/index.js +++ b/src/index.js @@ -1,3 +1,3 @@ export {default} from './downshift' -export {resetIdCounter} from './utils' -export {useSelect, useCombobox, useMultipleSelection} from './hooks' +export {resetIdCounter} from './utils-ts' +export {useSelect, useCombobox, useMultipleSelection, useTagGroup} from './hooks' diff --git a/src/utils-ts.ts b/src/utils-ts.ts new file mode 100644 index 000000000..08db49c78 --- /dev/null +++ b/src/utils-ts.ts @@ -0,0 +1,46 @@ +import * as React from 'react' + +let idCounter = 0 + +/** + * This generates a unique ID for an instance of Downshift + * @return {string} the unique ID + */ +export function generateId(): string { + return String(idCounter++) +} + +/** + * This is only used in tests + * @param {number} num the number to set the idCounter to + */ +export function setIdCounter(num: number): void { + idCounter = num +} + +/** + * Resets idCounter to 0. Used for SSR. + */ +export function resetIdCounter() { + // istanbul ignore next + if ('useId' in React) { + console.warn( + `It is not necessary to call resetIdCounter when using React 18+`, + ) + + return + } + + idCounter = 0 +} + +export function useLatestRef(val: T): React.MutableRefObject { + const ref = React.useRef(val) + // technically this is not "concurrent mode safe" because we're manipulating + // the value during render (so it's not idempotent). However, the places this + // hook is used is to support memoizing callbacks which will be called + // *during* render, so we need the latest values *during* render. + // If not for this, then we'd probably want to use useLayoutEffect instead. + ref.current = val + return ref +} diff --git a/src/utils.js b/src/utils.js index 23b992236..693506580 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,9 +1,6 @@ import {compute} from 'compute-scroll-into-view' -import React from 'react' import {isPreact} from './is.macro' -let idCounter = 0 - /** * Accepts a parameter and returns it if it's a function * or a noop function if it's not. This allows us to @@ -117,38 +114,6 @@ function handleRefs(...refs) { } } -/** - * This generates a unique ID for an instance of Downshift - * @return {String} the unique ID - */ -function generateId() { - return String(idCounter++) -} - -/** - * This is only used in tests - * @param {Number} num the number to set the idCounter to - */ -function setIdCounter(num) { - idCounter = num -} - -/** - * Resets idCounter to 0. Used for SSR. - */ -function resetIdCounter() { - // istanbul ignore next - if ('useId' in React) { - console.warn( - `It is not necessary to call resetIdCounter when using React 18+`, - ) - - return - } - - idCounter = 0 -} - /** * Default implementation for status message. Only added when menu is open. * Will specify if there are results in the list, and if so, how many, @@ -477,15 +442,12 @@ export { handleRefs, debounce, scrollIntoView, - generateId, getA11yStatusMessage, unwrapArray, isDOMElement, getElementProps, noop, requiredProp, - setIdCounter, - resetIdCounter, pickState, isPlainObject, normalizeArrowKey, From 3c2e8262a5473139cf6b4f9a97b2bafa5707b7ea Mon Sep 17 00:00:00 2001 From: Silviu Alexandu Avram Date: Wed, 9 Jul 2025 09:42:33 +0300 Subject: [PATCH 02/40] start useTagGroup --- src/hooks/useTagGroup/index.ts | 131 ++++++++ src/hooks/useTagGroup/index.types.ts | 86 +++++ src/hooks/useTagGroup/reducer.ts | 39 +++ src/hooks/useTagGroup/stateChangeTypes.ts | 10 + src/hooks/useTagGroup/utils.ts | 387 ++++++++++++++++++++++ src/hooks/useTagGroup/utils.types.ts | 40 +++ 6 files changed, 693 insertions(+) create mode 100644 src/hooks/useTagGroup/index.ts create mode 100644 src/hooks/useTagGroup/index.types.ts create mode 100644 src/hooks/useTagGroup/reducer.ts create mode 100644 src/hooks/useTagGroup/stateChangeTypes.ts create mode 100644 src/hooks/useTagGroup/utils.ts create mode 100644 src/hooks/useTagGroup/utils.types.ts diff --git a/src/hooks/useTagGroup/index.ts b/src/hooks/useTagGroup/index.ts new file mode 100644 index 000000000..426c9f335 --- /dev/null +++ b/src/hooks/useTagGroup/index.ts @@ -0,0 +1,131 @@ +import {useCallback} from 'react' + +import {useLatestRef} from '../../utils-ts' +import { + useElementIds, + defaultProps, + validatePropTypes, + useControlledReducer, +} from './utils' +import * as stateChangeTypes from './stateChangeTypes' +import { + GetTagGroupProps, + GetTagGroupPropsOptions, + GetTagGroupPropsReturnValue, + GetTagProps, + GetTagPropsOptions, + GetTagPropsReturnValue, + GetTagRemoveProps, + GetTagRemovePropsOptions, + GetTagRemovePropsReturnValue, + UseTagGroupProps, + UseTagGroupReducerAction, + UseTagGroupReturnValue, + UseTagGroupState, + UseTagGroupStateChangeTypes, +} from './index.types' +import {useTagGroupReducer} from './reducer' + +useTagGroup.stateChangeTypes = stateChangeTypes + +export default function useTagGroup( + userProps: Partial = {}, +): UseTagGroupReturnValue { + validatePropTypes(userProps, useTagGroup) + // Props defaults and destructuring. + const props = { + ...defaultProps, + ...userProps, + } + const [state, dispatch] = useControlledReducer< + UseTagGroupState, + UseTagGroupProps, + UseTagGroupStateChangeTypes, + UseTagGroupReducerAction + >( + useTagGroupReducer, + props, + () => ({activeIndex: -1, items: []}), + (oldState, newState) => oldState.activeIndex === newState.activeIndex, + ) + // utility callback to get item element. + const latest = useLatestRef({state, props}) + + // prevent id re-generation between renders. + const elementIds = useElementIds(props) + + // Getter functions. + const getTagGroupProps = useCallback( + (options?: GetTagGroupPropsOptions & unknown) => { + const onKeyDown = (e: React.KeyboardEvent): void => { + if (e.key === 'ArrowLeft') { + dispatch({type: UseTagGroupStateChangeTypes.TagGroupKeyDownArrowLeft}) + } else if (e.key === 'ArrowRight') { + dispatch({ + type: UseTagGroupStateChangeTypes.TagGroupKeyDownArrowRight, + }) + } + } + + const tagGroupProps: GetTagGroupPropsReturnValue = { + role: 'grid', + onKeyDown, + ...(options ?? {}), + } + + return tagGroupProps + }, + [dispatch], + ) as GetTagGroupProps + + const getTagProps = useCallback( + ({index, ...rest}: GetTagPropsOptions): GetTagPropsReturnValue => { + if (index === undefined) { + throw new Error('Pass index to getTagProps!') + } + + const latestState = latest.current.state + + const onClick = () => { + dispatch({type: UseTagGroupStateChangeTypes.TagClick, index}) + } + + return { + role: 'row', + id: elementIds.getItemId(index), + onClick, + tabIndex: latestState.activeIndex === index ? 0 : -1, + ...rest, + } + }, + [dispatch, elementIds, latest], + ) as GetTagProps + + const getTagRemoveProps = useCallback( + ({ + index, + ...rest + }: GetTagRemovePropsOptions): GetTagRemovePropsReturnValue => { + if (index === undefined) { + throw new Error('Pass index to getTagRemoveProps!') + } + + const tagId = elementIds.getItemId(index) + const id = `${tagId}-remove` + + return { + id, + tabIndex: -1, + 'aria-labelledby': `${id} ${tagId}`, + ...rest, + } + }, + [elementIds], + ) as GetTagRemoveProps + + return { + getTagGroupProps, + getTagProps, + getTagRemoveProps, + } +} diff --git a/src/hooks/useTagGroup/index.types.ts b/src/hooks/useTagGroup/index.types.ts new file mode 100644 index 000000000..4b42b9bda --- /dev/null +++ b/src/hooks/useTagGroup/index.types.ts @@ -0,0 +1,86 @@ +import {Overwrite} from '../../../typings' +import {Action} from './utils.types' + +export interface UseTagGroupState extends Record { + activeIndex: number + items: unknown[] +} + +export interface UseTagGroupProps extends Partial { + id?: string + groupId?: string + getItemId?: (index: number) => string + stateReducer( + state: UseTagGroupState, + actionAndChanges: Action & { + changes: Partial + }, + ): Partial +} + +export interface UseTagGroupReturnValue { + getTagGroupProps: GetTagGroupProps + getTagProps: GetTagProps + getTagRemoveProps: GetTagRemoveProps +} + +export interface GetTagPropsOptions extends React.HTMLProps { + index?: number +} + +export interface GetTagPropsReturnValue extends React.HTMLProps { + id: string + role: string +} + +export interface GetTagRemovePropsOptions extends React.HTMLProps { + index?: number +} + +export interface GetTagRemovePropsReturnValue + extends React.HTMLProps { + id: string + 'aria-labelledby': string +} + +export type GetTagGroupPropsOptions = React.HTMLProps + +export interface GetTagGroupPropsReturnValue + extends React.HTMLProps { + role: string +} + +export type GetTagGroupProps = ( + options?: GetTagGroupPropsOptions & Options, +) => Overwrite + +export type GetTagProps = ( + options?: GetTagPropsOptions & Options, +) => Overwrite + +export type GetTagRemoveProps = ( + options?: GetTagRemovePropsOptions & Options, +) => Overwrite + +export enum UseTagGroupStateChangeTypes { + TagClick = '__tag_click__', + TagGroupKeyDownArrowLeft = '__taggroup_keydown_arrowleft__', + TagGroupKeyDownArrowRight = '__taggroup_keydown_arrowright__', +} + +export type UseTagGroupReducerAction = + | UseTagGroupTagClickReducerAction + | UseTagGroupTagGroupKeyDownArrowLeftAction + | UseTagGroupTagGroupKeyDownArrowRightAction + +export type UseTagGroupTagClickReducerAction = { + type: UseTagGroupStateChangeTypes.TagClick + index: number +} + +export type UseTagGroupTagGroupKeyDownArrowLeftAction = { + type: UseTagGroupStateChangeTypes.TagGroupKeyDownArrowLeft +} +export type UseTagGroupTagGroupKeyDownArrowRightAction = { + type: UseTagGroupStateChangeTypes.TagGroupKeyDownArrowRight +} diff --git a/src/hooks/useTagGroup/reducer.ts b/src/hooks/useTagGroup/reducer.ts new file mode 100644 index 000000000..fabae1550 --- /dev/null +++ b/src/hooks/useTagGroup/reducer.ts @@ -0,0 +1,39 @@ +import {UseTagGroupReducerAction, UseTagGroupState} from './index.types' +import * as stateChangeTypes from './stateChangeTypes' + +export function useTagGroupReducer( + state: UseTagGroupState, + action: UseTagGroupReducerAction, +): UseTagGroupState { + const {type} = action + + let changes + + switch (type) { + case stateChangeTypes.TagClick: + changes = { + activeIndex: action.index, + } + break + case stateChangeTypes.TagGroupKeyDownArrowLeft: + changes = { + activeIndex: + state.activeIndex === 0 + ? state.items.length - 1 + : state.activeIndex - 1, + } + break + case stateChangeTypes.TagGroupKeyDownArrowRight: + changes = { + activeIndex: + state.activeIndex === state.items.length - 1 + ? 0 + : state.activeIndex + 1, + } + break + default: + throw new Error('Invalid useTagGroup reducer action.') + } + + return {...state, ...changes} +} diff --git a/src/hooks/useTagGroup/stateChangeTypes.ts b/src/hooks/useTagGroup/stateChangeTypes.ts new file mode 100644 index 000000000..16b4c1638 --- /dev/null +++ b/src/hooks/useTagGroup/stateChangeTypes.ts @@ -0,0 +1,10 @@ +import productionEnum from '../../productionEnum.macro' +import {UseTagGroupStateChangeTypes} from './index.types' + +export const TagClick: UseTagGroupStateChangeTypes.TagClick = + productionEnum('__tag_click__') + +export const TagGroupKeyDownArrowLeft: UseTagGroupStateChangeTypes.TagGroupKeyDownArrowLeft = + productionEnum('__taggroup_keydown_arrowleft__') +export const TagGroupKeyDownArrowRight: UseTagGroupStateChangeTypes.TagGroupKeyDownArrowRight = + productionEnum('__taggroup_keydown_arrowright__') diff --git a/src/hooks/useTagGroup/utils.ts b/src/hooks/useTagGroup/utils.ts new file mode 100644 index 000000000..bcd2e4c40 --- /dev/null +++ b/src/hooks/useTagGroup/utils.ts @@ -0,0 +1,387 @@ +import * as React from 'react' +import PropTypes from 'prop-types' + +import {generateId} from '../../utils-ts' +import {noop} from '../../utils' +import { + Action, + Props, + State, + UseElementIdsProps, + UseElementIdsReturnValue, +} from './utils.types' +import {UseTagGroupProps} from './index.types' + +const propTypes = { + isItemDisabled: PropTypes.func, +} + +export const defaultProps: Pick = { + stateReducer(_s, {changes}) { + return changes + }, +} + +// eslint-disable-next-line import/no-mutable-exports +export let validatePropTypes = noop as ( + options: unknown, + caller: Function, +) => void +/* istanbul ignore next */ +if (process.env.NODE_ENV !== 'production') { + validatePropTypes = (options: unknown, caller: Function): void => { + PropTypes.checkPropTypes(propTypes, options, 'prop', caller.name) + } +} + +// istanbul ignore next +export const useElementIds: ( + props: UseElementIdsProps, +) => UseElementIdsReturnValue = + 'useId' in React // Avoid conditional useId call + ? useElementIdsR18 + : useElementIdsLegacy + +function useElementIdsR18({ + id, + groupId, + getItemId, +}: UseElementIdsProps): UseElementIdsReturnValue { + // Avoid conditional useId call + const reactId = `downshift-${React.useId()}` + if (!id) { + id = reactId + } + + const elementIdsRef = React.useRef({ + groupId: groupId ?? `${id}-tag-group`, + getItemId: getItemId ?? (index => `${id}-item-${index}`), + }) + + return elementIdsRef.current +} + +function useElementIdsLegacy({ + id = `downshift-${generateId()}`, + getItemId, + groupId, +}: UseElementIdsProps): UseElementIdsReturnValue { + const elementIdsRef = React.useRef({ + groupId: groupId ?? `${id}-menu`, + getItemId: getItemId ?? (index => `${id}-item-${index}`), + }) + + return elementIdsRef.current +} + +// probably to move + +export function getState< + S extends State, + P extends Partial & Props, + T, +>(state: S, props?: P): S { + if (!props) { + return state + } + + const keys = Object.keys(state) as (keyof S)[] + + return keys.reduce( + (newState, key) => { + if (props[key] !== undefined) { + newState[key] = props[key] as S[typeof key] + } + return newState + }, + {...state}, + ) +} + +/** + * Wraps the useEnhancedReducer and applies the controlled prop values before + * returning the new state. + * + * @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. + */ +export function useControlledReducer< + S extends State, + P extends Partial & Props, + T, + A extends Action +>( + reducer: (state: S, action: A) => S, + props: P, + createInitialState: (props: P) => S, + isStateEqual: (prevState: S, newState: S) => boolean, +): [S, (action: A) => void] { + const [state, dispatch] = useEnhancedReducer( + reducer, + props, + createInitialState, + isStateEqual, + ) + + return [getState(state, props), dispatch] +} + +/** + * 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. + */ +export function useEnhancedReducer< + S extends State, + P extends Partial & Props, + T, + A extends Action +>( + reducer: (state: S, action: A) => S, + props: P, + createInitialState: (props: P) => S, + isStateEqual: (prevState: S, newState: S) => boolean, +): [S, (action: A) => void] { + const prevStateRef = React.useRef(null) + const actionRef = React.useRef() + const propsRef = useLatestRef(props) + + const enhancedReducer = React.useCallback( + (state: S, action: A): S => { + actionRef.current = action + state = getState(state, propsRef.current) + + const changes = reducer(state, action) + const newState = propsRef.current.stateReducer(state, { + ...action, + changes, + }) + + return {...state, ...newState} + }, + [propsRef, reducer], + ) + const [state, dispatch] = React.useReducer( + enhancedReducer, + props, + createInitialState, + ) + + const action = actionRef.current + + React.useEffect(() => { + const prevState = getState( + prevStateRef.current ?? ({} as S), + propsRef.current, + ) + const shouldCallOnChangeProps = + action && prevStateRef.current && !isStateEqual(prevState, state) + + if (shouldCallOnChangeProps) { + callOnChangeProps(action, propsRef.current, prevState, state) + } + + prevStateRef.current = state + }, [state, action, isStateEqual, propsRef]) + + return [getState(state, props), dispatch] +} + +function useLatestRef(val: T) { + const ref = React.useRef(val) + // technically this is not "concurrent mode safe" because we're manipulating + // the value during render (so it's not idempotent). However, the places this + // hook is used is to support memoizing callbacks which will be called + // *during* render, so we need the latest values *during* render. + // If not for this, then we'd probably want to use useLayoutEffect instead. + ref.current = val + return ref +} + +function callOnChangeProps< + S extends State, + P extends Partial & Props, + T, +>(action: Action, props: P, state: S, newState: S) { + const {type} = action + const changes: Partial = {} + const keys = Object.keys(state) + + for (const key of keys) { + invokeOnChangeHandler(key, action, props, state, newState) + + if (newState[key] !== state[key]) { + changes[key] = newState[key] + } + } + + if (props.onStateChange && 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, +) { + const {type} = action + const handlerKey = `on${capitalizeString(key)}Change` + + if (typeof props[handlerKey] === 'function' && newState[key] !== state[key]) { + props[handlerKey]({type, ...newState}) + } +} + +function capitalizeString(string: string): string { + return `${string.slice(0, 1).toUpperCase()}${string.slice(1)}` +} + +// export function getState | undefined, T>( +// state: S, +// props: P, +// ): S { +// if (!props) { +// return state +// } + +// const keys = Object.keys(state) as Array + +// return keys.reduce( +// (newState, key) => { +// // if (props[key] !== undefined) { +// // newState[key] = props[key] +// // } +// if (key in props) { +// newState[key] = props[key] +// } + +// return newState +// }, +// {...state}, +// ) +// } + +// /** +// * 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. +// */ +// export function useEnhancedReducer, T>( +// reducer: (state: S, action: Action) => S, +// props: P, +// createInitialState: (props: P) => S, +// isStateEqual: (prevState: S, newState: S) => boolean, +// ) { +// const prevStateRef = React.useRef(null) +// const actionRef = React.useRef>() +// const enhancedReducer = React.useCallback( +// (state: S, action: ActionWithProps) => { +// actionRef.current = action +// state = getState(state, action.props) + +// const changes = reducer(state, action) +// const newState = action.props.stateReducer(state, {...action, changes}) + +// return newState +// }, +// [reducer], +// ) +// const [state, dispatch] = React.useReducer( +// enhancedReducer, +// props, +// createInitialState, +// ) +// const propsRef = useLatestRef(props) +// const dispatchWithProps = React.useCallback( +// (action: Action) => dispatch({props: propsRef.current, ...action}), +// [propsRef], +// ) +// const action = actionRef.current + +// React.useEffect(() => { +// const prevState = getState(prevStateRef.current ?? ({} as S), action?.props) +// const shouldCallOnChangeProps = +// action && prevStateRef.current && !isStateEqual(prevState, state) + +// if (shouldCallOnChangeProps) { +// callOnChangeProps(action, prevState, state) +// } + +// prevStateRef.current = state +// }, [state, action, isStateEqual]) + +// return [getState(state, props), dispatchWithProps] +// } + +// function useLatestRef(val: T) { +// const ref = React.useRef(val) +// // technically this is not "concurrent mode safe" because we're manipulating +// // the value during render (so it's not idempotent). However, the places this +// // hook is used is to support memoizing callbacks which will be called +// // *during* render, so we need the latest values *during* render. +// // If not for this, then we'd probably want to use useLayoutEffect instead. +// ref.current = val +// return ref +// } + +// function callOnChangeProps, T>( +// action: ActionWithProps, +// state: S, +// newState: S, +// ) { +// const {props, type} = action +// const changes: Partial = {} +// const keys = Object.keys(state) as Array> + +// for (const key of keys) { +// invokeOnChangeHandler(key, action, state, newState) + +// if (newState[key] !== state[key]) { +// changes[key] = newState[key] +// } +// } + +// if (props.onStateChange && Object.keys(changes).length) { +// props.onStateChange({type, ...changes}) +// } +// } + +// function invokeOnChangeHandler( +// key: Extract, +// action: ActionWithProps, +// state: S, +// newState: S, +// ) { +// const {props, type} = action +// const handlerKey = `on${capitalizeString(key)}Change` as keyof P + +// if (typeof props[handlerKey] === 'function' && newState[key] !== state[key]) { +// props[handlerKey]({type, ...newState}) +// } +// } + +// function capitalizeString(string: string): string { +// return `${string.slice(0, 1).toUpperCase()}${string.slice(1)}` +// } diff --git a/src/hooks/useTagGroup/utils.types.ts b/src/hooks/useTagGroup/utils.types.ts new file mode 100644 index 000000000..334c8c08a --- /dev/null +++ b/src/hooks/useTagGroup/utils.types.ts @@ -0,0 +1,40 @@ +import {UseTagGroupProps} from './index.types' + +export type UseElementIdsProps = Pick< + UseTagGroupProps, + 'id' | 'getItemId' | 'groupId' +> +export type UseElementIdsReturnValue = Required< + Pick +> + +// export interface ActionWithProps extends Action { +// props: P +// } + +// export interface Action { +// type: T +// } + +// export type StateReducer = ( +// state: S, +// actionAndChanges: ActionWithProps & {changes: S}, +// ) => S + +// export interface Props { +// onStateChange?(typeAndChanges: Action & Partial): void +// } + +export type State = Record + +export interface Props { + onStateChange?(typeAndChanges: unknown): void + stateReducer( + state: S, + actionAndChanges: Action & {changes: Partial}, + ): Partial +} + +export interface Action extends Record { + type: T +} From 493652da4c9756b53817f75c410ffe1772090784 Mon Sep 17 00:00:00 2001 From: Silviu Alexandu Avram Date: Wed, 9 Jul 2025 09:42:44 +0300 Subject: [PATCH 03/40] docusaurus changes --- docusaurus.config.js | 2 +- docusaurus/pages/index.js | 3 +++ docusaurus/pages/useCombobox.js | 6 ++++++ docusaurus/pages/useTagGroup.tsx | 30 ++++++++++++++++++++++++++++++ docusaurus/{utils.js => utils.ts} | 23 +++++++++++++++-------- 5 files changed, 55 insertions(+), 9 deletions(-) create mode 100644 docusaurus/pages/useTagGroup.tsx rename docusaurus/{utils.js => utils.ts} (56%) diff --git a/docusaurus.config.js b/docusaurus.config.js index c0ab1ed5b..3ffe3494f 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -31,7 +31,7 @@ const config = { blog: false, pages: { path: 'docusaurus/pages', - include: ['**/*.{js,jsx}'], + include: ['**/*.{js,jsx,tsx}'], }, }), ], diff --git a/docusaurus/pages/index.js b/docusaurus/pages/index.js index e1409a344..da2a29d2f 100644 --- a/docusaurus/pages/index.js +++ b/docusaurus/pages/index.js @@ -20,6 +20,9 @@ export default function Docs() { +
  • + useTagGroup +
  • ) diff --git a/docusaurus/pages/useCombobox.js b/docusaurus/pages/useCombobox.js index c5a25c8b0..04ab50b44 100644 --- a/docusaurus/pages/useCombobox.js +++ b/docusaurus/pages/useCombobox.js @@ -24,6 +24,12 @@ export default function DropdownCombobox() { ), ) }, + onSelectedItemChange(changes) { + console.log(changes) + }, + onIsOpenChange(changes) { + console.log(changes) + } }) return (
    diff --git a/docusaurus/pages/useTagGroup.tsx b/docusaurus/pages/useTagGroup.tsx new file mode 100644 index 000000000..93b4b22be --- /dev/null +++ b/docusaurus/pages/useTagGroup.tsx @@ -0,0 +1,30 @@ +import * as React from 'react' + +import {useTagGroup} from '../../src' + +import { + colors, + removeTagStyles, + selectedItemsContainerSyles, + selectedItemStyles, +} from '../utils' + +export default function DropdownMultipleCombobox() { + const {getTagProps, getTagRemoveProps, getTagGroupProps} = useTagGroup() + + return ( +
    + {colors.map((color, index) => ( + + {color} + + ✕ + + + ))} +
    + ) +} diff --git a/docusaurus/utils.js b/docusaurus/utils.ts similarity index 56% rename from docusaurus/utils.js rename to docusaurus/utils.ts index 72832df21..0ac405fce 100644 --- a/docusaurus/utils.js +++ b/docusaurus/utils.ts @@ -1,3 +1,5 @@ +import {type CSSProperties} from 'react' + export const colors = [ 'Black', 'Red', @@ -15,7 +17,7 @@ export const colors = [ 'Skyblue', ] -export const menuStyles = { +export const menuStyles: CSSProperties = { listStyle: 'none', width: '100%', padding: '0', @@ -24,7 +26,7 @@ export const menuStyles = { overflowY: 'scroll', } -export const containerStyles = { +export const containerStyles: CSSProperties = { display: 'flex', flexDirection: 'column', width: 'fit-content', @@ -33,7 +35,7 @@ export const containerStyles = { alignSelf: 'center', } -export const selectedItemsContainerSyles = { +export const selectedItemsContainerSyles: CSSProperties = { display: 'inline-flex', gap: '8px', alignItems: 'center', @@ -41,9 +43,14 @@ export const selectedItemsContainerSyles = { padding: '6px', } -export const selectedItemStyles = { - backgroundColor: 'lightgray', - paddingLeft: '4px', - paddingRight: '4px', - borderRadius: '6px', +export const selectedItemStyles: CSSProperties = { + backgroundColor: 'green', + padding: '0 6px', + margin: '0 2px', + borderRadius: '10px', +} + +export const removeTagStyles: CSSProperties = { + padding: '4px', + cursor: 'pointer', } From 159886005f5947c5508f4414f123dc7d91ba7407 Mon Sep 17 00:00:00 2001 From: Silviu Alexandu Avram Date: Fri, 11 Jul 2025 10:55:23 +0300 Subject: [PATCH 04/40] change some styles in docusaurus --- docusaurus/pages/useMultipleSelect.js | 8 ++--- docusaurus/pages/useTagGroup.css | 34 ++++++++++++++++++ docusaurus/pages/useTagGroup.tsx | 50 ++++++++++++++++++--------- docusaurus/utils.ts | 5 +-- 4 files changed, 75 insertions(+), 22 deletions(-) create mode 100644 docusaurus/pages/useTagGroup.css diff --git a/docusaurus/pages/useMultipleSelect.js b/docusaurus/pages/useMultipleSelect.js index 70c7374cd..87ac12b72 100644 --- a/docusaurus/pages/useMultipleSelect.js +++ b/docusaurus/pages/useMultipleSelect.js @@ -5,8 +5,8 @@ import { colors, containerStyles, menuStyles, - selectedItemsContainerSyles, - selectedItemStyles, + tagGroupSyles, + tagStyles, } from '../utils' const initialSelectedItems = [colors[0], colors[1]] @@ -76,14 +76,14 @@ export default function DropdownMultipleSelect() { > Choose an element: -
    +
    {selectedItems.map(function renderSelectedItem( selectedItemForRender, index, ) { return ( !items.includes(color)) return ( -
    - {colors.map((color, index) => ( - - {color} +
    +
    + {items.map((color, index) => ( - ✕ + {color} + + ✕ + - - ))} + ))} +
    +
    Add more items:
    +
      + {itemsToAdd.map(item => ( +
    • + {item} +
    • + ))} +
    ) } diff --git a/docusaurus/utils.ts b/docusaurus/utils.ts index 0ac405fce..6f0b35229 100644 --- a/docusaurus/utils.ts +++ b/docusaurus/utils.ts @@ -35,7 +35,7 @@ export const containerStyles: CSSProperties = { alignSelf: 'center', } -export const selectedItemsContainerSyles: CSSProperties = { +export const tagGroupSyles: CSSProperties = { display: 'inline-flex', gap: '8px', alignItems: 'center', @@ -43,11 +43,12 @@ export const selectedItemsContainerSyles: CSSProperties = { padding: '6px', } -export const selectedItemStyles: CSSProperties = { +export const tagStyles: CSSProperties = { backgroundColor: 'green', padding: '0 6px', margin: '0 2px', borderRadius: '10px', + cursor: 'auto', } export const removeTagStyles: CSSProperties = { From 180dbfebbba669d36b61967060173fd2a8248717 Mon Sep 17 00:00:00 2001 From: Silviu Alexandu Avram Date: Fri, 11 Jul 2025 10:56:35 +0300 Subject: [PATCH 05/40] add focus --- src/hooks/useTagGroup/index.ts | 52 +++++--- src/hooks/useTagGroup/index.types.ts | 24 ++-- src/hooks/useTagGroup/utils.ts | 180 +++++---------------------- src/hooks/useTagGroup/utils.types.ts | 21 +--- src/utils-ts.ts | 18 +++ 5 files changed, 102 insertions(+), 193 deletions(-) diff --git a/src/hooks/useTagGroup/index.ts b/src/hooks/useTagGroup/index.ts index 426c9f335..3c956a63d 100644 --- a/src/hooks/useTagGroup/index.ts +++ b/src/hooks/useTagGroup/index.ts @@ -1,11 +1,12 @@ -import {useCallback} from 'react' +import {useEffect, useCallback, useRef} from 'react' -import {useLatestRef} from '../../utils-ts' +import {handleRefs, useLatestRef} from '../../utils-ts' import { useElementIds, - defaultProps, validatePropTypes, useControlledReducer, + getInitialState, + isTagGroupStateEqual, } from './utils' import * as stateChangeTypes from './stateChangeTypes' import { @@ -28,31 +29,38 @@ import {useTagGroupReducer} from './reducer' useTagGroup.stateChangeTypes = stateChangeTypes -export default function useTagGroup( - userProps: Partial = {}, -): UseTagGroupReturnValue { +export default function useTagGroup( + userProps: Partial> = {}, +): UseTagGroupReturnValue { validatePropTypes(userProps, useTagGroup) // Props defaults and destructuring. + const defaultProps: Pick, 'stateReducer'> = { + stateReducer(_s, {changes}) { + return changes + }, + } const props = { ...defaultProps, ...userProps, } const [state, dispatch] = useControlledReducer< - UseTagGroupState, - UseTagGroupProps, + UseTagGroupState, + UseTagGroupProps, UseTagGroupStateChangeTypes, UseTagGroupReducerAction - >( - useTagGroupReducer, - props, - () => ({activeIndex: -1, items: []}), - (oldState, newState) => oldState.activeIndex === newState.activeIndex, - ) + >(useTagGroupReducer, props, getInitialState, isTagGroupStateEqual) + const {activeIndex, items} = state // utility callback to get item element. const latest = useLatestRef({state, props}) - // prevent id re-generation between renders. const elementIds = useElementIds(props) + const itemRefs = useRef>({}) + + useEffect(() => { + if (activeIndex >= 0 && activeIndex < items.length) { + itemRefs.current[elementIds.getItemId(activeIndex)]?.focus() + } + }, [activeIndex, elementIds, items]) // Getter functions. const getTagGroupProps = useCallback( @@ -79,7 +87,12 @@ export default function useTagGroup( ) as GetTagGroupProps const getTagProps = useCallback( - ({index, ...rest}: GetTagPropsOptions): GetTagPropsReturnValue => { + ({ + index, + refKey = 'ref', + ref, + ...rest + }: GetTagPropsOptions): GetTagPropsReturnValue => { if (index === undefined) { throw new Error('Pass index to getTagProps!') } @@ -91,6 +104,11 @@ export default function useTagGroup( } return { + [refKey]: handleRefs(ref, itemNode => { + if (itemNode) { + itemRefs.current[elementIds.getItemId(index)] = itemNode + } + }), role: 'row', id: elementIds.getItemId(index), onClick, @@ -124,8 +142,10 @@ export default function useTagGroup( ) as GetTagRemoveProps return { + activeIndex, getTagGroupProps, getTagProps, getTagRemoveProps, + items, } } diff --git a/src/hooks/useTagGroup/index.types.ts b/src/hooks/useTagGroup/index.types.ts index 4b42b9bda..348d8df07 100644 --- a/src/hooks/useTagGroup/index.types.ts +++ b/src/hooks/useTagGroup/index.types.ts @@ -1,31 +1,39 @@ import {Overwrite} from '../../../typings' import {Action} from './utils.types' -export interface UseTagGroupState extends Record { +export interface UseTagGroupState extends Record { activeIndex: number - items: unknown[] + items: Item[] } -export interface UseTagGroupProps extends Partial { - id?: string +export interface UseTagGroupProps extends Partial> { + defaultActiveIndex?: number + defaultItems?: Item[] + initialActiveIndex?: number + initialItems?: Item[] groupId?: string getItemId?: (index: number) => string + id?: string stateReducer( - state: UseTagGroupState, + state: UseTagGroupState, actionAndChanges: Action & { - changes: Partial + changes: Partial> }, - ): Partial + ): Partial> } -export interface UseTagGroupReturnValue { +export interface UseTagGroupReturnValue { getTagGroupProps: GetTagGroupProps getTagProps: GetTagProps getTagRemoveProps: GetTagRemoveProps + items: Item[] + activeIndex: number } export interface GetTagPropsOptions extends React.HTMLProps { index?: number + refKey?: string + ref?: React.MutableRefObject } export interface GetTagPropsReturnValue extends React.HTMLProps { diff --git a/src/hooks/useTagGroup/utils.ts b/src/hooks/useTagGroup/utils.ts index bcd2e4c40..a11da442d 100644 --- a/src/hooks/useTagGroup/utils.ts +++ b/src/hooks/useTagGroup/utils.ts @@ -10,18 +10,12 @@ import { UseElementIdsProps, UseElementIdsReturnValue, } from './utils.types' -import {UseTagGroupProps} from './index.types' +import {UseTagGroupProps, UseTagGroupState} from './index.types' const propTypes = { isItemDisabled: PropTypes.func, } -export const defaultProps: Pick = { - stateReducer(_s, {changes}) { - return changes - }, -} - // eslint-disable-next-line import/no-mutable-exports export let validatePropTypes = noop as ( options: unknown, @@ -112,7 +106,7 @@ export function useControlledReducer< S extends State, P extends Partial & Props, T, - A extends Action + A extends Action, >( reducer: (state: S, action: A) => S, props: P, @@ -144,7 +138,7 @@ export function useEnhancedReducer< S extends State, P extends Partial & Props, T, - A extends Action + A extends Action, >( reducer: (state: S, action: A) => S, props: P, @@ -233,13 +227,7 @@ function invokeOnChangeHandler< S extends State, P extends Partial & Props, T, ->( - key: string, - action: Action, - props: P, - state: S, - newState: S, -) { +>(key: string, action: Action, props: P, state: S, newState: S) { const {type} = action const handlerKey = `on${capitalizeString(key)}Change` @@ -248,140 +236,32 @@ function invokeOnChangeHandler< } } +export function getInitialState( + props: UseTagGroupProps, +): UseTagGroupState { + const activeIndex = + props.activeIndex ?? + props.initialActiveIndex ?? + props.defaultActiveIndex ?? + -1 + const items = props.items ?? props.initialItems ?? props.defaultItems ?? [] + + return { + activeIndex, + items, + } +} + +export function isTagGroupStateEqual( + oldState: UseTagGroupState, + newState: UseTagGroupState, +): boolean { + return ( + oldState.activeIndex === newState.activeIndex && + oldState.items === newState.items + ) +} + function capitalizeString(string: string): string { return `${string.slice(0, 1).toUpperCase()}${string.slice(1)}` } - -// export function getState | undefined, T>( -// state: S, -// props: P, -// ): S { -// if (!props) { -// return state -// } - -// const keys = Object.keys(state) as Array - -// return keys.reduce( -// (newState, key) => { -// // if (props[key] !== undefined) { -// // newState[key] = props[key] -// // } -// if (key in props) { -// newState[key] = props[key] -// } - -// return newState -// }, -// {...state}, -// ) -// } - -// /** -// * 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. -// */ -// export function useEnhancedReducer, T>( -// reducer: (state: S, action: Action) => S, -// props: P, -// createInitialState: (props: P) => S, -// isStateEqual: (prevState: S, newState: S) => boolean, -// ) { -// const prevStateRef = React.useRef(null) -// const actionRef = React.useRef>() -// const enhancedReducer = React.useCallback( -// (state: S, action: ActionWithProps) => { -// actionRef.current = action -// state = getState(state, action.props) - -// const changes = reducer(state, action) -// const newState = action.props.stateReducer(state, {...action, changes}) - -// return newState -// }, -// [reducer], -// ) -// const [state, dispatch] = React.useReducer( -// enhancedReducer, -// props, -// createInitialState, -// ) -// const propsRef = useLatestRef(props) -// const dispatchWithProps = React.useCallback( -// (action: Action) => dispatch({props: propsRef.current, ...action}), -// [propsRef], -// ) -// const action = actionRef.current - -// React.useEffect(() => { -// const prevState = getState(prevStateRef.current ?? ({} as S), action?.props) -// const shouldCallOnChangeProps = -// action && prevStateRef.current && !isStateEqual(prevState, state) - -// if (shouldCallOnChangeProps) { -// callOnChangeProps(action, prevState, state) -// } - -// prevStateRef.current = state -// }, [state, action, isStateEqual]) - -// return [getState(state, props), dispatchWithProps] -// } - -// function useLatestRef(val: T) { -// const ref = React.useRef(val) -// // technically this is not "concurrent mode safe" because we're manipulating -// // the value during render (so it's not idempotent). However, the places this -// // hook is used is to support memoizing callbacks which will be called -// // *during* render, so we need the latest values *during* render. -// // If not for this, then we'd probably want to use useLayoutEffect instead. -// ref.current = val -// return ref -// } - -// function callOnChangeProps, T>( -// action: ActionWithProps, -// state: S, -// newState: S, -// ) { -// const {props, type} = action -// const changes: Partial = {} -// const keys = Object.keys(state) as Array> - -// for (const key of keys) { -// invokeOnChangeHandler(key, action, state, newState) - -// if (newState[key] !== state[key]) { -// changes[key] = newState[key] -// } -// } - -// if (props.onStateChange && Object.keys(changes).length) { -// props.onStateChange({type, ...changes}) -// } -// } - -// function invokeOnChangeHandler( -// key: Extract, -// action: ActionWithProps, -// state: S, -// newState: S, -// ) { -// const {props, type} = action -// const handlerKey = `on${capitalizeString(key)}Change` as keyof P - -// if (typeof props[handlerKey] === 'function' && newState[key] !== state[key]) { -// props[handlerKey]({type, ...newState}) -// } -// } - -// function capitalizeString(string: string): string { -// return `${string.slice(0, 1).toUpperCase()}${string.slice(1)}` -// } diff --git a/src/hooks/useTagGroup/utils.types.ts b/src/hooks/useTagGroup/utils.types.ts index 334c8c08a..190cbe9d4 100644 --- a/src/hooks/useTagGroup/utils.types.ts +++ b/src/hooks/useTagGroup/utils.types.ts @@ -1,30 +1,13 @@ import {UseTagGroupProps} from './index.types' export type UseElementIdsProps = Pick< - UseTagGroupProps, + UseTagGroupProps, 'id' | 'getItemId' | 'groupId' > export type UseElementIdsReturnValue = Required< - Pick + Pick, 'getItemId' | 'groupId'> > -// export interface ActionWithProps extends Action { -// props: P -// } - -// export interface Action { -// type: T -// } - -// export type StateReducer = ( -// state: S, -// actionAndChanges: ActionWithProps & {changes: S}, -// ) => S - -// export interface Props { -// onStateChange?(typeAndChanges: Action & Partial): void -// } - export type State = Record export interface Props { diff --git a/src/utils-ts.ts b/src/utils-ts.ts index 08db49c78..bfee12c9e 100644 --- a/src/utils-ts.ts +++ b/src/utils-ts.ts @@ -44,3 +44,21 @@ export function useLatestRef(val: T): React.MutableRefObject { ref.current = val return ref } + +export function handleRefs( + ...refs: ( + | React.MutableRefObject + | React.RefCallback + | undefined + )[] +) { + return (node: HTMLElement) => { + refs.forEach(ref => { + if (typeof ref === 'function') { + ref(node) + } else if (ref) { + ref.current = node + } + }) + } +} From 925e2b46231f1de9ac7355f316486c6a2d8c0ba1 Mon Sep 17 00:00:00 2001 From: Silviu Alexandu Avram Date: Mon, 14 Jul 2025 09:49:37 +0300 Subject: [PATCH 06/40] focus, delete, add --- docusaurus/pages/useTagGroup.css | 6 +- docusaurus/pages/useTagGroup.tsx | 16 ++++- src/hooks/useTagGroup/index.ts | 66 +++++++++++++++++---- src/hooks/useTagGroup/index.types.ts | 47 ++++++++++++--- src/hooks/useTagGroup/reducer.ts | 71 +++++++++++++++++++++-- src/hooks/useTagGroup/stateChangeTypes.ts | 10 ++++ src/hooks/useTagGroup/utils.ts | 6 +- src/hooks/utils-ts.ts | 19 ++++++ src/hooks/utils.js | 19 +----- 9 files changed, 213 insertions(+), 47 deletions(-) diff --git a/docusaurus/pages/useTagGroup.css b/docusaurus/pages/useTagGroup.css index dd7faa728..b34b3307d 100644 --- a/docusaurus/pages/useTagGroup.css +++ b/docusaurus/pages/useTagGroup.css @@ -16,10 +16,10 @@ } .tag:hover { - opacity: .5; + opacity: 0.5; } -.selected-tag { +.tag:focus { background-color: red; border-color: darkred; } @@ -31,4 +31,4 @@ .item-to-add { cursor: pointer; -} \ No newline at end of file +} diff --git a/docusaurus/pages/useTagGroup.tsx b/docusaurus/pages/useTagGroup.tsx index baf5179cb..3b06ca8ae 100644 --- a/docusaurus/pages/useTagGroup.tsx +++ b/docusaurus/pages/useTagGroup.tsx @@ -8,6 +8,7 @@ import './useTagGroup.css' export default function DropdownMultipleCombobox() { const initialItems = colors.slice(0, 5) const { + addItem, getTagProps, getTagRemoveProps, getTagGroupProps, @@ -38,8 +39,19 @@ export default function DropdownMultipleCombobox() {
    Add more items:
      {itemsToAdd.map(item => ( -
    • - {item} +
    • +
    • ))}
    diff --git a/src/hooks/useTagGroup/index.ts b/src/hooks/useTagGroup/index.ts index 3c956a63d..050496ce2 100644 --- a/src/hooks/useTagGroup/index.ts +++ b/src/hooks/useTagGroup/index.ts @@ -1,6 +1,9 @@ import {useEffect, useCallback, useRef} from 'react' import {handleRefs, useLatestRef} from '../../utils-ts' +import {useIsInitialMount} from '../utils-ts' +// @ts-expect-error: can't import it otherwise. +import {isReactNative} from '../../is.macro' import { useElementIds, validatePropTypes, @@ -33,11 +36,18 @@ export default function useTagGroup( userProps: Partial> = {}, ): UseTagGroupReturnValue { validatePropTypes(userProps, useTagGroup) + console.log(isReactNative) // Props defaults and destructuring. - const defaultProps: Pick, 'stateReducer'> = { + const defaultProps: Pick< + UseTagGroupProps, + 'stateReducer' | 'environment' + > = { stateReducer(_s, {changes}) { return changes }, + environment: + /* istanbul ignore next (ssr) */ + typeof window === 'undefined' || isReactNative ? undefined : window, } const props = { ...defaultProps, @@ -47,7 +57,7 @@ export default function useTagGroup( UseTagGroupState, UseTagGroupProps, UseTagGroupStateChangeTypes, - UseTagGroupReducerAction + UseTagGroupReducerAction >(useTagGroupReducer, props, getInitialState, isTagGroupStateEqual) const {activeIndex, items} = state // utility callback to get item element. @@ -55,23 +65,43 @@ export default function useTagGroup( // prevent id re-generation between renders. const elementIds = useElementIds(props) const itemRefs = useRef>({}) + const isInitialMount = useIsInitialMount() useEffect(() => { - if (activeIndex >= 0 && activeIndex < items.length) { + if (isInitialMount) { + return + } + if (activeIndex >= 0 && activeIndex < items.length && props.environment) { itemRefs.current[elementIds.getItemId(activeIndex)]?.focus() } - }, [activeIndex, elementIds, items]) + }, [activeIndex, elementIds, isInitialMount, items.length, props.environment]) // Getter functions. const getTagGroupProps = useCallback( (options?: GetTagGroupPropsOptions & unknown) => { const onKeyDown = (e: React.KeyboardEvent): void => { - if (e.key === 'ArrowLeft') { - dispatch({type: UseTagGroupStateChangeTypes.TagGroupKeyDownArrowLeft}) - } else if (e.key === 'ArrowRight') { - dispatch({ - type: UseTagGroupStateChangeTypes.TagGroupKeyDownArrowRight, - }) + switch (e.key) { + case 'ArrowLeft': + dispatch({ + type: UseTagGroupStateChangeTypes.TagGroupKeyDownArrowLeft, + }) + break + case 'ArrowRight': + dispatch({ + type: UseTagGroupStateChangeTypes.TagGroupKeyDownArrowRight, + }) + break + case 'Delete': + dispatch({ + type: UseTagGroupStateChangeTypes.TagGroupKeyDownDelete, + }) + break + case 'Backspace': + dispatch({ + type: UseTagGroupStateChangeTypes.TagGroupKeyDownBackspace, + }) + break + default: } } @@ -128,6 +158,11 @@ export default function useTagGroup( throw new Error('Pass index to getTagRemoveProps!') } + const onClick = (event: React.MouseEvent) => { + event.stopPropagation() + dispatch({type: UseTagGroupStateChangeTypes.TagRemoveClick, index}) + } + const tagId = elementIds.getItemId(index) const id = `${tagId}-remove` @@ -135,14 +170,23 @@ export default function useTagGroup( id, tabIndex: -1, 'aria-labelledby': `${id} ${tagId}`, + onClick, ...rest, } }, - [elementIds], + [elementIds, dispatch], ) as GetTagRemoveProps + const addItem = useCallback['addItem']>( + (item, index): void => { + dispatch({type: UseTagGroupStateChangeTypes.FunctionAddItem, item, index}) + }, + [dispatch], + ) + return { activeIndex, + addItem, getTagGroupProps, getTagProps, getTagRemoveProps, diff --git a/src/hooks/useTagGroup/index.types.ts b/src/hooks/useTagGroup/index.types.ts index 348d8df07..fac56562a 100644 --- a/src/hooks/useTagGroup/index.types.ts +++ b/src/hooks/useTagGroup/index.types.ts @@ -6,7 +6,8 @@ export interface UseTagGroupState extends Record { items: Item[] } -export interface UseTagGroupProps extends Partial> { +export interface UseTagGroupProps + extends Partial> { defaultActiveIndex?: number defaultItems?: Item[] initialActiveIndex?: number @@ -20,14 +21,16 @@ export interface UseTagGroupProps extends Partial> changes: Partial> }, ): Partial> + environment?: Environment } export interface UseTagGroupReturnValue { + activeIndex: number + addItem: (item: Item, index?: number) => void getTagGroupProps: GetTagGroupProps getTagProps: GetTagProps getTagRemoveProps: GetTagRemoveProps items: Item[] - activeIndex: number } export interface GetTagPropsOptions extends React.HTMLProps { @@ -74,21 +77,51 @@ export enum UseTagGroupStateChangeTypes { TagClick = '__tag_click__', TagGroupKeyDownArrowLeft = '__taggroup_keydown_arrowleft__', TagGroupKeyDownArrowRight = '__taggroup_keydown_arrowright__', + TagGroupKeyDownBackspace = '__taggroup_keydown_backspace__', + TagGroupKeyDownDelete = '__taggroup_keydown_delete__', + TagRemoveClick = '__tagremove_click__', + FunctionAddItem = '__function_add_item__', } -export type UseTagGroupReducerAction = +export type UseTagGroupReducerAction = | UseTagGroupTagClickReducerAction - | UseTagGroupTagGroupKeyDownArrowLeftAction - | UseTagGroupTagGroupKeyDownArrowRightAction + | UseTagGroupTagKeyDownArrowLeftAction + | UseTagGroupTagKeyDownArrowRightAction + | UseTagGroupTagKeyDownBackspaceAction + | UseTagGroupTagKeyDownDeleteAction + | UseTagGroupTagRemoveClickAction + | UseTagGroupFunctionAddItem export type UseTagGroupTagClickReducerAction = { type: UseTagGroupStateChangeTypes.TagClick index: number } -export type UseTagGroupTagGroupKeyDownArrowLeftAction = { +export type UseTagGroupTagKeyDownArrowLeftAction = { type: UseTagGroupStateChangeTypes.TagGroupKeyDownArrowLeft } -export type UseTagGroupTagGroupKeyDownArrowRightAction = { +export type UseTagGroupTagKeyDownArrowRightAction = { type: UseTagGroupStateChangeTypes.TagGroupKeyDownArrowRight } +export type UseTagGroupTagKeyDownBackspaceAction = { + type: UseTagGroupStateChangeTypes.TagGroupKeyDownBackspace +} +export type UseTagGroupTagKeyDownDeleteAction = { + type: UseTagGroupStateChangeTypes.TagGroupKeyDownDelete +} +export type UseTagGroupTagRemoveClickAction = { + type: UseTagGroupStateChangeTypes.TagRemoveClick + index: number +} +export type UseTagGroupFunctionAddItem = { + type: UseTagGroupStateChangeTypes.FunctionAddItem + item: Item + index?: number +} + +export interface Environment { + addEventListener: typeof window.addEventListener + removeEventListener: typeof window.removeEventListener + document: Document + Node: typeof window.Node +} diff --git a/src/hooks/useTagGroup/reducer.ts b/src/hooks/useTagGroup/reducer.ts index fabae1550..bac8643d2 100644 --- a/src/hooks/useTagGroup/reducer.ts +++ b/src/hooks/useTagGroup/reducer.ts @@ -1,10 +1,10 @@ import {UseTagGroupReducerAction, UseTagGroupState} from './index.types' import * as stateChangeTypes from './stateChangeTypes' -export function useTagGroupReducer( - state: UseTagGroupState, - action: UseTagGroupReducerAction, -): UseTagGroupState { +export function useTagGroupReducer( + state: UseTagGroupState, + action: UseTagGroupReducerAction, +): UseTagGroupState { const {type} = action let changes @@ -31,6 +31,69 @@ export function useTagGroupReducer( : state.activeIndex + 1, } break + case stateChangeTypes.TagGroupKeyDownBackspace: + case stateChangeTypes.TagGroupKeyDownDelete: { + const newItems = [ + ...state.items.slice(0, state.activeIndex), + ...state.items.slice(state.activeIndex + 1), + ] + const newActiveIndex = + newItems.length === 0 + ? -1 + : newItems.length === state.activeIndex + ? state.activeIndex - 1 + : state.activeIndex + changes = { + items: [ + ...state.items.slice(0, state.activeIndex), + ...state.items.slice(state.activeIndex + 1), + ], + activeIndex: newActiveIndex, + } + break + } + case stateChangeTypes.TagRemoveClick: + { + const newItems = [ + ...state.items.slice(0, action.index), + ...state.items.slice(action.index + 1), + ] + const newActiveIndex = + newItems.length === 0 + ? -1 + : newItems.length === action.index + ? action.index - 1 + : action.index + changes = { + items: newItems, + activeIndex: newActiveIndex, + } + } + break + case stateChangeTypes.FunctionAddItem: { + let newItems: Item[] = [] + let newActiveIndex = state.activeIndex + + if (action.index === undefined) { + newItems = [...state.items, action.item] + } else { + newItems = [ + ...state.items.slice(0, action.index), + action.item, + ...state.items.slice(action.index), + ] + + if (action.index >= state.activeIndex) { + newActiveIndex = state.activeIndex - 1 + } + } + + changes = { + items: newItems, + activeIndex: newActiveIndex, + } + break + } default: throw new Error('Invalid useTagGroup reducer action.') } diff --git a/src/hooks/useTagGroup/stateChangeTypes.ts b/src/hooks/useTagGroup/stateChangeTypes.ts index 16b4c1638..034d250af 100644 --- a/src/hooks/useTagGroup/stateChangeTypes.ts +++ b/src/hooks/useTagGroup/stateChangeTypes.ts @@ -8,3 +8,13 @@ export const TagGroupKeyDownArrowLeft: UseTagGroupStateChangeTypes.TagGroupKeyDo productionEnum('__taggroup_keydown_arrowleft__') export const TagGroupKeyDownArrowRight: UseTagGroupStateChangeTypes.TagGroupKeyDownArrowRight = productionEnum('__taggroup_keydown_arrowright__') +export const TagGroupKeyDownDelete: UseTagGroupStateChangeTypes.TagGroupKeyDownDelete = + productionEnum('__taggroup_keydown_delete__') +export const TagGroupKeyDownBackspace: UseTagGroupStateChangeTypes.TagGroupKeyDownBackspace = + productionEnum('__taggroup_keydown_backspace__') + +export const TagRemoveClick: UseTagGroupStateChangeTypes.TagRemoveClick = + productionEnum('__tagremove_click__') + +export const FunctionAddItem: UseTagGroupStateChangeTypes.FunctionAddItem = + productionEnum('__function_add_item__') diff --git a/src/hooks/useTagGroup/utils.ts b/src/hooks/useTagGroup/utils.ts index a11da442d..fb3de3ac5 100644 --- a/src/hooks/useTagGroup/utils.ts +++ b/src/hooks/useTagGroup/utils.ts @@ -239,12 +239,14 @@ function invokeOnChangeHandler< export function getInitialState( props: UseTagGroupProps, ): UseTagGroupState { + const items = props.items ?? props.initialItems ?? props.defaultItems ?? [] const activeIndex = props.activeIndex ?? props.initialActiveIndex ?? props.defaultActiveIndex ?? - -1 - const items = props.items ?? props.initialItems ?? props.defaultItems ?? [] + items.length === 0 + ? -1 + : 0 return { activeIndex, diff --git a/src/hooks/utils-ts.ts b/src/hooks/utils-ts.ts index 719afb70d..213c4bfb3 100644 --- a/src/hooks/utils-ts.ts +++ b/src/hooks/utils-ts.ts @@ -1,3 +1,5 @@ +import * as React from 'react' + /** * Returns both the item and index when both or either is passed. * @@ -38,3 +40,20 @@ export function getItemAndIndex( throw new Error(errorMessage) } + +/** + * Tracks if it's the first render. + */ +export function useIsInitialMount(): boolean { + const isInitialMountRef = React.useRef(true) + + React.useEffect(() => { + isInitialMountRef.current = false + + return () => { + isInitialMountRef.current = true + } + }, []) + + return isInitialMountRef.current +} \ No newline at end of file diff --git a/src/hooks/utils.js b/src/hooks/utils.js index 3311adb73..a50316388 100644 --- a/src/hooks/utils.js +++ b/src/hooks/utils.js @@ -18,6 +18,7 @@ import { } from '../utils' import {cleanupStatusDiv, setStatus} from '../set-a11y-status' import { generateId } from '../utils-ts' +import { useIsInitialMount } from './utils-ts' const dropdownDefaultStateValues = { highlightedIndex: -1, @@ -621,23 +622,6 @@ function isDropdownsStateEqual(prevState, newState) { ) } -/** - * Tracks if it's the first render. - */ -function useIsInitialMount() { - const isInitialMountRef = React.useRef(true) - - React.useEffect(() => { - isInitialMountRef.current = false - - return () => { - isInitialMountRef.current = true - } - }, []) - - return isInitialMountRef.current -} - /** * Returns the new highlightedIndex based on the defaultHighlightedIndex prop, if it's not disabled. * @@ -739,7 +723,6 @@ export { isDropdownsStateEqual, commonDropdownPropTypes, commonPropTypes, - useIsInitialMount, useA11yMessageStatus, getDefaultHighlightedIndex, } From 11c5967e9f932a3d845b2b42b76763f4c50c8192 Mon Sep 17 00:00:00 2001 From: Silviu Alexandu Avram Date: Tue, 15 Jul 2025 10:42:12 +0300 Subject: [PATCH 07/40] more updates on active and focus --- src/hooks/useTagGroup/index.ts | 18 +++++++++++++++--- src/hooks/useTagGroup/reducer.ts | 10 ++++------ src/hooks/useTagGroup/utils.ts | 2 +- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/hooks/useTagGroup/index.ts b/src/hooks/useTagGroup/index.ts index 050496ce2..a3aab4407 100644 --- a/src/hooks/useTagGroup/index.ts +++ b/src/hooks/useTagGroup/index.ts @@ -36,7 +36,6 @@ export default function useTagGroup( userProps: Partial> = {}, ): UseTagGroupReturnValue { validatePropTypes(userProps, useTagGroup) - console.log(isReactNative) // Props defaults and destructuring. const defaultProps: Pick< UseTagGroupProps, @@ -65,16 +64,29 @@ export default function useTagGroup( // prevent id re-generation between renders. const elementIds = useElementIds(props) const itemRefs = useRef>({}) + const previousItemsLengthRef = useRef(items.length) const isInitialMount = useIsInitialMount() useEffect(() => { if (isInitialMount) { return } - if (activeIndex >= 0 && activeIndex < items.length && props.environment) { + + if (previousItemsLengthRef.current < items.length) { + return + } + + if ( + activeIndex >= 0 && + activeIndex < Object.keys(itemRefs.current).length + ) { itemRefs.current[elementIds.getItemId(activeIndex)]?.focus() } - }, [activeIndex, elementIds, isInitialMount, items.length, props.environment]) + }, [activeIndex, elementIds, isInitialMount, items.length]) + + useEffect(() => { + previousItemsLengthRef.current = items.length + }) // Getter functions. const getTagGroupProps = useCallback( diff --git a/src/hooks/useTagGroup/reducer.ts b/src/hooks/useTagGroup/reducer.ts index bac8643d2..83de7dba3 100644 --- a/src/hooks/useTagGroup/reducer.ts +++ b/src/hooks/useTagGroup/reducer.ts @@ -72,7 +72,6 @@ export function useTagGroupReducer( break case stateChangeTypes.FunctionAddItem: { let newItems: Item[] = [] - let newActiveIndex = state.activeIndex if (action.index === undefined) { newItems = [...state.items, action.item] @@ -82,15 +81,14 @@ export function useTagGroupReducer( action.item, ...state.items.slice(action.index), ] - - if (action.index >= state.activeIndex) { - newActiveIndex = state.activeIndex - 1 - } } + const newActiveIndex = + state.activeIndex === -1 ? newItems.length - 1 : state.activeIndex + changes = { items: newItems, - activeIndex: newActiveIndex, + activeIndex: newActiveIndex } break } diff --git a/src/hooks/useTagGroup/utils.ts b/src/hooks/useTagGroup/utils.ts index fb3de3ac5..538431638 100644 --- a/src/hooks/useTagGroup/utils.ts +++ b/src/hooks/useTagGroup/utils.ts @@ -246,7 +246,7 @@ export function getInitialState( props.defaultActiveIndex ?? items.length === 0 ? -1 - : 0 + : items.length - 1 return { activeIndex, From 7c7803c9f9a829ba30acd4f4c582de137fcb77a6 Mon Sep 17 00:00:00 2001 From: Silviu Alexandu Avram Date: Sat, 19 Jul 2025 16:59:11 +0300 Subject: [PATCH 08/40] tests and some fixes --- docusaurus/pages/useTagGroup.tsx | 2 +- .../__tests__/getToggleButtonProps.test.js | 2 +- .../__tests__/getTagGroupProps.test.ts | 170 ++++++++++++++++++ .../useTagGroup/__tests__/getTagProps.test.ts | 88 +++++++++ src/hooks/useTagGroup/index.ts | 23 +-- src/hooks/useTagGroup/index.types.ts | 4 +- src/hooks/useTagGroup/testUtils.tsx | 100 +++++++++++ src/hooks/useTagGroup/utils.ts | 20 +-- src/hooks/useTagGroup/utils.types.ts | 4 +- src/utils-ts.ts | 27 +++ 10 files changed, 412 insertions(+), 28 deletions(-) create mode 100644 src/hooks/useTagGroup/__tests__/getTagGroupProps.test.ts create mode 100644 src/hooks/useTagGroup/__tests__/getTagProps.test.ts create mode 100644 src/hooks/useTagGroup/testUtils.tsx diff --git a/docusaurus/pages/useTagGroup.tsx b/docusaurus/pages/useTagGroup.tsx index 3b06ca8ae..0bba34392 100644 --- a/docusaurus/pages/useTagGroup.tsx +++ b/docusaurus/pages/useTagGroup.tsx @@ -5,7 +5,7 @@ import {colors} from '../utils' import './useTagGroup.css' -export default function DropdownMultipleCombobox() { +export default function TagGroup() { const initialItems = colors.slice(0, 5) const { addItem, diff --git a/src/hooks/useSelect/__tests__/getToggleButtonProps.test.js b/src/hooks/useSelect/__tests__/getToggleButtonProps.test.js index ee240d152..3d494a8cf 100644 --- a/src/hooks/useSelect/__tests__/getToggleButtonProps.test.js +++ b/src/hooks/useSelect/__tests__/getToggleButtonProps.test.js @@ -65,7 +65,7 @@ describe('getToggleButtonProps', () => { expect(toggleButtonProps.id).toEqual(props.toggleButtonId) }) - test("assign 'listbbox' to aria-haspopup", () => { + test("assign 'listbox' to aria-haspopup", () => { const {result} = renderUseSelect() const toggleButtonProps = result.current.getToggleButtonProps() diff --git a/src/hooks/useTagGroup/__tests__/getTagGroupProps.test.ts b/src/hooks/useTagGroup/__tests__/getTagGroupProps.test.ts new file mode 100644 index 000000000..9c376feed --- /dev/null +++ b/src/hooks/useTagGroup/__tests__/getTagGroupProps.test.ts @@ -0,0 +1,170 @@ +import { act } from 'react-dom/test-utils' +import { + screen, + defaultProps, + renderTagGroup, + renderUseTagGroup, +} from '../testUtils' + +describe('getTagGroupProps', () => { + describe('hook props', () => { + test('assign assigns a role of "grid"', () => { + const {result} = renderUseTagGroup() + const tagGroupProps = result.current.getTagGroupProps() + + expect(tagGroupProps.role).toEqual('grid') + }) + }) + + describe('user props', () => { + test('are passed down', () => { + const {result} = renderUseTagGroup() + + expect(result.current.getTagGroupProps({foo: 'bar'})).toHaveProperty( + 'foo', + 'bar', + ) + }) + + test('event handler onKeyDown is called along with downshift handler', () => { + const userOnKeyDown = jest.fn() + const {result} = renderUseTagGroup({initialActiveIndex: 2}) + + act(() => { + const {onKeyDown} = result.current.getTagGroupProps({ + onKeyDown: userOnKeyDown, + }) + + onKeyDown({key: 'ArrowLeft'}) + }) + + expect(userOnKeyDown).toHaveBeenCalledTimes(1) + expect(result.current.activeIndex).toBe(1) + }) + + test('event handler onKeyDown is called along without downshift handler if event.preventDownshiftDefault is passed', () => { + const userOnKeyDown = jest.fn(event => { + event.preventDownshiftDefault = true + }) + const {result} = renderUseTagGroup({initialActiveIndex: 2}) + + act(() => { + const {onKeyDown} = result.current.getTagGroupProps({ + onKeyDown: userOnKeyDown, + }) + + onKeyDown({key: 'ArrowLeft'}) + }) + + expect(userOnKeyDown).toHaveBeenCalledTimes(1) + expect(result.current.activeIndex).toBe(2) + }) + }) + + describe('keydown', () => { + test('arrow left moves selection to the previous item', async () => { + const {clickOnTag, user, getTags} = renderTagGroup() + + await clickOnTag(2) + await user.keyboard('{ArrowLeft}') + + const tags = getTags() + + expect(tags[2]).toHaveAttribute('tabindex', '-1') + expect(tags[1]).toHaveAttribute('tabindex', '0') + }) + + test('arrow left moves selection to the last item if first item was active', async () => { + const {clickOnTag, user, getTags} = renderTagGroup() + + await clickOnTag(0) + await user.keyboard('{ArrowLeft}') + + const tags = getTags() + + expect(tags[tags.length - 1]).toHaveAttribute('tabindex', '0') + expect(tags[0]).toHaveAttribute('tabindex', '-1') + }) + + test('arrow right moves selection to the previous item', async () => { + const {clickOnTag, user, getTags} = renderTagGroup() + + await clickOnTag(2) + await user.keyboard('{ArrowRight}') + + const tags = getTags() + + expect(tags[2]).toHaveAttribute('tabindex', '-1') + expect(tags[3]).toHaveAttribute('tabindex', '0') + }) + + test('arrow right moves selection to the first item if last item was active', async () => { + const lastItemIndex = defaultProps.initialItems.length - 1 + const {clickOnTag, user, getTags} = renderTagGroup() + + await clickOnTag(lastItemIndex) + await user.keyboard('{ArrowRight}') + + const tags = getTags() + + expect(tags[lastItemIndex]).toHaveAttribute('tabindex', '-1') + expect(tags[0]).toHaveAttribute('tabindex', '0') + }) + + test('arrows move selection correctly', async () => { + const {clickOnTag, user, getTags} = renderTagGroup() + + await clickOnTag(0) + await user.keyboard('{ArrowRight}') + await user.keyboard('{ArrowRight}') + + const tags = getTags() + + expect(tags[0]).toHaveAttribute('tabindex', '-1') + expect(tags[1]).toHaveAttribute('tabindex', '-1') + expect(tags[2]).toHaveAttribute('tabindex', '0') + + await user.keyboard('{ArrowLeft}') + + expect(tags[2]).toHaveAttribute('tabindex', '-1') + expect(tags[1]).toHaveAttribute('tabindex', '0') + + await user.keyboard('{ArrowRight}') + + expect(tags[1]).toHaveAttribute('tabindex', '-1') + expect(tags[2]).toHaveAttribute('tabindex', '0') + }) + + test('delete removes the active item', async () => { + const {clickOnTag, user, getTags} = renderTagGroup() + + const tagsCount = getTags().length + + await clickOnTag(2) + await user.keyboard('{Delete}') + + const newTagsCount = getTags().length + + expect(newTagsCount).toEqual(tagsCount - 1) + expect( + screen.queryByRole('tag', {name: defaultProps.initialItems[2]}), + ).not.toBeInTheDocument() + }) + + test('backspace removes the active item', async () => { + const {clickOnTag, user, getTags} = renderTagGroup() + + const tagsCount = getTags().length + + await clickOnTag(2) + await user.keyboard('{Backspace}') + + const newTagsCount = getTags().length + + expect(newTagsCount).toEqual(tagsCount - 1) + expect( + screen.queryByRole('tag', {name: defaultProps.initialItems[2]}), + ).not.toBeInTheDocument() + }) + }) +}) diff --git a/src/hooks/useTagGroup/__tests__/getTagProps.test.ts b/src/hooks/useTagGroup/__tests__/getTagProps.test.ts new file mode 100644 index 000000000..2c5797670 --- /dev/null +++ b/src/hooks/useTagGroup/__tests__/getTagProps.test.ts @@ -0,0 +1,88 @@ +import {renderTagGroup, renderUseTagGroup, defaultIds, act} from '../testUtils' + +describe('getTagProps', () => { + describe('hook props', () => { + test('assign assigns a role of "row"', () => { + const {result} = renderUseTagGroup() + const tagGroupProps = result.current.getTagProps({index: 0}) + + expect(tagGroupProps.role).toEqual('row') + expect(tagGroupProps.tabIndex).toEqual(-1) + }) + + test('assign default value to id', () => { + const {result} = renderUseTagGroup() + + expect(result.current.getTagProps({index: 0}).id).toEqual( + `${defaultIds.getTagId(0)}`, + ) + }) + + test('assign custom value passed by user to id', () => { + const getTagId = (index: number) => `my-custom-item-id-${index}` + const {result} = renderUseTagGroup({getTagId}) + + expect(result.current.getTagProps({index: 0}).id).toEqual(getTagId(0)) + }) + }) + + describe('user props', () => { + test('are passed down', () => { + const {result} = renderUseTagGroup() + + expect(result.current.getTagProps({foo: 'bar', index: 0})).toHaveProperty( + 'foo', + 'bar', + ) + }) + + test('event handler onClick is called along with downshift handler', () => { + const userOnClick = jest.fn() + const {result} = renderUseTagGroup({initialActiveIndex: 2}) + + act(() => { + const {onClick} = result.current.getTagProps({ + onClick: userOnClick, + index: 1, + }) + + onClick({}) + }) + + expect(userOnClick).toHaveBeenCalledTimes(1) + expect(result.current.activeIndex).toBe(1) + }) + + test('event handler onClick is called along without downshift handler if event.preventDownshiftDefault is passed', () => { + const userOnClick = jest.fn(event => { + event.preventDownshiftDefault = true + }) + const {result} = renderUseTagGroup({initialActiveIndex: 2}) + + act(() => { + const {onClick} = result.current.getTagProps({ + onClick: userOnClick, + index: 1, + }) + + onClick({}) + }) + + expect(userOnClick).toHaveBeenCalledTimes(1) + expect(result.current.activeIndex).toBe(2) + }) + }) + + describe('click', () => { + test('sets the item as active and focusable', async () => { + const {clickOnTag, getTags} = renderTagGroup({initialActiveIndex: 1}) + + await clickOnTag(2) + + const tags = getTags() + + expect(tags[2]).toHaveAttribute('tabindex', '0') + expect(tags[1]).toHaveAttribute('tabindex', '-1') + }) + }) +}) diff --git a/src/hooks/useTagGroup/index.ts b/src/hooks/useTagGroup/index.ts index a3aab4407..5e50f7eba 100644 --- a/src/hooks/useTagGroup/index.ts +++ b/src/hooks/useTagGroup/index.ts @@ -1,6 +1,6 @@ import {useEffect, useCallback, useRef} from 'react' -import {handleRefs, useLatestRef} from '../../utils-ts' +import {callAllEventHandlers, handleRefs, useLatestRef} from '../../utils-ts' import {useIsInitialMount} from '../utils-ts' // @ts-expect-error: can't import it otherwise. import {isReactNative} from '../../is.macro' @@ -80,7 +80,7 @@ export default function useTagGroup( activeIndex >= 0 && activeIndex < Object.keys(itemRefs.current).length ) { - itemRefs.current[elementIds.getItemId(activeIndex)]?.focus() + itemRefs.current[elementIds.getTagId(activeIndex)]?.focus() } }, [activeIndex, elementIds, isInitialMount, items.length]) @@ -90,8 +90,8 @@ export default function useTagGroup( // Getter functions. const getTagGroupProps = useCallback( - (options?: GetTagGroupPropsOptions & unknown) => { - const onKeyDown = (e: React.KeyboardEvent): void => { + ({onKeyDown, ...rest}: GetTagGroupPropsOptions & unknown = {}) => { + const handleKeyDown = (e: React.KeyboardEvent): void => { switch (e.key) { case 'ArrowLeft': dispatch({ @@ -119,8 +119,8 @@ export default function useTagGroup( const tagGroupProps: GetTagGroupPropsReturnValue = { role: 'grid', - onKeyDown, - ...(options ?? {}), + onKeyDown: callAllEventHandlers(onKeyDown, handleKeyDown), + ...rest, } return tagGroupProps @@ -131,6 +131,7 @@ export default function useTagGroup( const getTagProps = useCallback( ({ index, + onClick, refKey = 'ref', ref, ...rest @@ -141,19 +142,19 @@ export default function useTagGroup( const latestState = latest.current.state - const onClick = () => { + const handleClick = () => { dispatch({type: UseTagGroupStateChangeTypes.TagClick, index}) } return { [refKey]: handleRefs(ref, itemNode => { if (itemNode) { - itemRefs.current[elementIds.getItemId(index)] = itemNode + itemRefs.current[elementIds.getTagId(index)] = itemNode } }), role: 'row', - id: elementIds.getItemId(index), - onClick, + id: elementIds.getTagId(index), + onClick: callAllEventHandlers(onClick, handleClick), tabIndex: latestState.activeIndex === index ? 0 : -1, ...rest, } @@ -175,7 +176,7 @@ export default function useTagGroup( dispatch({type: UseTagGroupStateChangeTypes.TagRemoveClick, index}) } - const tagId = elementIds.getItemId(index) + const tagId = elementIds.getTagId(index) const id = `${tagId}-remove` return { diff --git a/src/hooks/useTagGroup/index.types.ts b/src/hooks/useTagGroup/index.types.ts index fac56562a..bf856ffc0 100644 --- a/src/hooks/useTagGroup/index.types.ts +++ b/src/hooks/useTagGroup/index.types.ts @@ -12,8 +12,8 @@ export interface UseTagGroupProps defaultItems?: Item[] initialActiveIndex?: number initialItems?: Item[] - groupId?: string - getItemId?: (index: number) => string + tagGroupId?: string + getTagId?: (index: number) => string id?: string stateReducer( state: UseTagGroupState, diff --git a/src/hooks/useTagGroup/testUtils.tsx b/src/hooks/useTagGroup/testUtils.tsx new file mode 100644 index 000000000..33f1e14e2 --- /dev/null +++ b/src/hooks/useTagGroup/testUtils.tsx @@ -0,0 +1,100 @@ +import * as React from 'react' +import {render, renderHook} from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +import {UseTagGroupProps} from './index.types' +import useTagGroup from '.' + +export * from '@testing-library/react' + +// We are using React 18. +jest.mock('react', () => { + return { + ...jest.requireActual('react'), + useId() { + return 'test-id' + }, + } +}) + +const colors = [ + 'Black', + 'Red', + 'Green', + 'Blue', + 'Orange', + 'Purple', + 'Pink', + 'Orchid', + 'Aqua', + 'Lime', + 'Gray', + 'Brown', + 'Teal', + 'Skyblue', +] + +export const defaultProps = { + initialItems: colors.slice(0, 5), +} + +export const defaultIds = { + tagGroupId: 'downshift-test-id-tag-group', + getTagId: (index: number) => `downshift-test-id-tag-${index}`, +} + +export function renderTagGroup(props: Partial> = {}) { + const utils = render() + const user = userEvent.setup() + + function getTags() { + return utils.getAllByRole('row') + } + + function getTagGroup() { + return utils.getByRole('grid') + } + + async function clickOnTag(index: number) { + const tags = getTags() + const tag = tags[index] as HTMLElement + + await user.click(tag) + } + + return {...utils, getTags, getTagGroup, clickOnTag, user} +} + +export function renderUseTagGroup( + initialProps: Partial> = {}, +) { + return renderHook( + (props: Partial> = {}) => useTagGroup(props), + {initialProps: {...defaultProps, ...initialProps}}, + ) +} + +function TagGroup(props: Partial> = {}) { + const {getTagProps, getTagRemoveProps, getTagGroupProps, items, activeIndex} = + useTagGroup(props) + + return ( +
    + {items.map((color, index) => ( + + {color} + + ✕ + + + ))} +
    + ) +} diff --git a/src/hooks/useTagGroup/utils.ts b/src/hooks/useTagGroup/utils.ts index 538431638..564d2d055 100644 --- a/src/hooks/useTagGroup/utils.ts +++ b/src/hooks/useTagGroup/utils.ts @@ -38,8 +38,8 @@ export const useElementIds: ( function useElementIdsR18({ id, - groupId, - getItemId, + tagGroupId, + getTagId, }: UseElementIdsProps): UseElementIdsReturnValue { // Avoid conditional useId call const reactId = `downshift-${React.useId()}` @@ -48,8 +48,8 @@ function useElementIdsR18({ } const elementIdsRef = React.useRef({ - groupId: groupId ?? `${id}-tag-group`, - getItemId: getItemId ?? (index => `${id}-item-${index}`), + tagGroupId: tagGroupId ?? `${id}-tag-group`, + getTagId: getTagId ?? (index => `${id}-tag-${index}`), }) return elementIdsRef.current @@ -57,12 +57,12 @@ function useElementIdsR18({ function useElementIdsLegacy({ id = `downshift-${generateId()}`, - getItemId, - groupId, + getTagId, + tagGroupId, }: UseElementIdsProps): UseElementIdsReturnValue { const elementIdsRef = React.useRef({ - groupId: groupId ?? `${id}-menu`, - getItemId: getItemId ?? (index => `${id}-item-${index}`), + tagGroupId: tagGroupId ?? `${id}-menu`, + getTagId: getTagId ?? (index => `${id}-item-${index}`), }) return elementIdsRef.current @@ -244,9 +244,7 @@ export function getInitialState( props.activeIndex ?? props.initialActiveIndex ?? props.defaultActiveIndex ?? - items.length === 0 - ? -1 - : items.length - 1 + (items.length === 0 ? -1 : items.length - 1) return { activeIndex, diff --git a/src/hooks/useTagGroup/utils.types.ts b/src/hooks/useTagGroup/utils.types.ts index 190cbe9d4..2cc9b5ad1 100644 --- a/src/hooks/useTagGroup/utils.types.ts +++ b/src/hooks/useTagGroup/utils.types.ts @@ -2,10 +2,10 @@ import {UseTagGroupProps} from './index.types' export type UseElementIdsProps = Pick< UseTagGroupProps, - 'id' | 'getItemId' | 'groupId' + 'id' | 'getTagId' | 'tagGroupId' > export type UseElementIdsReturnValue = Required< - Pick, 'getItemId' | 'groupId'> + Pick, 'getTagId' | 'tagGroupId'> > export type State = Record diff --git a/src/utils-ts.ts b/src/utils-ts.ts index bfee12c9e..69988c1de 100644 --- a/src/utils-ts.ts +++ b/src/utils-ts.ts @@ -62,3 +62,30 @@ export function handleRefs( }) } } + +/** + * This is intended to be used to compose event handlers. + * They are executed in order until one of them sets + * `event.preventDownshiftDefault = true`. + * @param fns the event handler functions + * @return the event handler to add to an element + */ +export function callAllEventHandlers(...fns: (Function | undefined)[]) { + return ( + event: React.SyntheticEvent & { + preventDownshiftDefault?: boolean + nativeEvent: {preventDownshiftDefault?: boolean} + }, + ...args: unknown[] + ) => + fns.some(fn => { + if (fn) { + fn(event, ...args) + } + return ( + event.preventDownshiftDefault || + (event.hasOwnProperty('nativeEvent') && + event.nativeEvent.preventDownshiftDefault) + ) + }) +} From ac2a67c19b3924f629c72805d789888ad394a7bb Mon Sep 17 00:00:00 2001 From: Silviu Alexandu Avram Date: Fri, 25 Jul 2025 09:52:20 +0300 Subject: [PATCH 09/40] improve styles --- docusaurus/pages/useTagGroup.css | 2 ++ docusaurus/pages/useTagGroup.tsx | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docusaurus/pages/useTagGroup.css b/docusaurus/pages/useTagGroup.css index b34b3307d..f38a7ea1b 100644 --- a/docusaurus/pages/useTagGroup.css +++ b/docusaurus/pages/useTagGroup.css @@ -27,6 +27,8 @@ .tag-remove-button { padding: 4px; cursor: pointer; + border: none; + background-color: transparent; } .item-to-add { diff --git a/docusaurus/pages/useTagGroup.tsx b/docusaurus/pages/useTagGroup.tsx index 0bba34392..21eb96fc5 100644 --- a/docusaurus/pages/useTagGroup.tsx +++ b/docusaurus/pages/useTagGroup.tsx @@ -27,12 +27,12 @@ export default function TagGroup() { {...getTagProps({index})} > {color} - ✕ - +
    ))}
    From ff7fcca96a2559f127149bff35b72bb1503c0fed Mon Sep 17 00:00:00 2001 From: Silviu Alexandu Avram Date: Fri, 25 Jul 2025 09:52:33 +0300 Subject: [PATCH 10/40] improve types --- src/hooks/useTagGroup/index.ts | 28 ++++++++++------------------ src/hooks/useTagGroup/index.types.ts | 22 +++++++++++++++------- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/src/hooks/useTagGroup/index.ts b/src/hooks/useTagGroup/index.ts index 5e50f7eba..978dde143 100644 --- a/src/hooks/useTagGroup/index.ts +++ b/src/hooks/useTagGroup/index.ts @@ -15,13 +15,10 @@ import * as stateChangeTypes from './stateChangeTypes' import { GetTagGroupProps, GetTagGroupPropsOptions, - GetTagGroupPropsReturnValue, GetTagProps, GetTagPropsOptions, - GetTagPropsReturnValue, GetTagRemoveProps, GetTagRemovePropsOptions, - GetTagRemovePropsReturnValue, UseTagGroupProps, UseTagGroupReducerAction, UseTagGroupReturnValue, @@ -117,7 +114,10 @@ export default function useTagGroup( } } - const tagGroupProps: GetTagGroupPropsReturnValue = { + const tagGroupProps = { + 'aria-live': 'polite', + 'aria-atomic': 'false', + 'aria-relevant': 'additions', role: 'grid', onKeyDown: callAllEventHandlers(onKeyDown, handleKeyDown), ...rest, @@ -129,13 +129,7 @@ export default function useTagGroup( ) as GetTagGroupProps const getTagProps = useCallback( - ({ - index, - onClick, - refKey = 'ref', - ref, - ...rest - }: GetTagPropsOptions): GetTagPropsReturnValue => { + ({index, onClick, refKey = 'ref', ref, ...rest}: GetTagPropsOptions) => { if (index === undefined) { throw new Error('Pass index to getTagProps!') } @@ -145,6 +139,7 @@ export default function useTagGroup( const handleClick = () => { dispatch({type: UseTagGroupStateChangeTypes.TagClick, index}) } + const id = elementIds.getTagId(index) return { [refKey]: handleRefs(ref, itemNode => { @@ -153,7 +148,7 @@ export default function useTagGroup( } }), role: 'row', - id: elementIds.getTagId(index), + id, onClick: callAllEventHandlers(onClick, handleClick), tabIndex: latestState.activeIndex === index ? 0 : -1, ...rest, @@ -163,15 +158,12 @@ export default function useTagGroup( ) as GetTagProps const getTagRemoveProps = useCallback( - ({ - index, - ...rest - }: GetTagRemovePropsOptions): GetTagRemovePropsReturnValue => { + ({index, onClick, ...rest}: GetTagRemovePropsOptions) => { if (index === undefined) { throw new Error('Pass index to getTagRemoveProps!') } - const onClick = (event: React.MouseEvent) => { + const handleClick = (event: React.MouseEvent) => { event.stopPropagation() dispatch({type: UseTagGroupStateChangeTypes.TagRemoveClick, index}) } @@ -183,7 +175,7 @@ export default function useTagGroup( id, tabIndex: -1, 'aria-labelledby': `${id} ${tagId}`, - onClick, + onClick: callAllEventHandlers(onClick, handleClick), ...rest, } }, diff --git a/src/hooks/useTagGroup/index.types.ts b/src/hooks/useTagGroup/index.types.ts index bf856ffc0..37e147616 100644 --- a/src/hooks/useTagGroup/index.types.ts +++ b/src/hooks/useTagGroup/index.types.ts @@ -39,26 +39,34 @@ export interface GetTagPropsOptions extends React.HTMLProps { ref?: React.MutableRefObject } -export interface GetTagPropsReturnValue extends React.HTMLProps { +export interface GetTagPropsReturnValue { id: string - role: string + role: 'row' + onPress?: (event: React.BaseSyntheticEvent) => void + onClick?: React.MouseEventHandler + tabIndex: 0 | -1 } export interface GetTagRemovePropsOptions extends React.HTMLProps { index?: number } -export interface GetTagRemovePropsReturnValue - extends React.HTMLProps { +export interface GetTagRemovePropsReturnValue { id: string 'aria-labelledby': string + onPress?: (event: React.BaseSyntheticEvent) => void + onClick?: React.MouseEventHandler + tabIndex: -1 } export type GetTagGroupPropsOptions = React.HTMLProps -export interface GetTagGroupPropsReturnValue - extends React.HTMLProps { - role: string +export interface GetTagGroupPropsReturnValue { + role: 'grid' + 'aria-live': 'polite' + 'aria-atomic': 'false' + 'aria-relevant': 'additions' + onKeyDown: React.KeyboardEventHandler } export type GetTagGroupProps = ( From 3185d038584ea0a093d4c9e21e7160646df7756f Mon Sep 17 00:00:00 2001 From: Silviu Alexandu Avram Date: Fri, 25 Jul 2025 09:52:43 +0300 Subject: [PATCH 11/40] more tests --- .../__tests__/getTagGroupProps.test.ts | 5 +- .../useTagGroup/__tests__/getTagProps.test.ts | 6 +- .../__tests__/getTagRemoveProps.test.ts | 103 ++++++++++++++++++ src/hooks/useTagGroup/testUtils.tsx | 17 ++- 4 files changed, 124 insertions(+), 7 deletions(-) create mode 100644 src/hooks/useTagGroup/__tests__/getTagRemoveProps.test.ts diff --git a/src/hooks/useTagGroup/__tests__/getTagGroupProps.test.ts b/src/hooks/useTagGroup/__tests__/getTagGroupProps.test.ts index 9c376feed..921d49b85 100644 --- a/src/hooks/useTagGroup/__tests__/getTagGroupProps.test.ts +++ b/src/hooks/useTagGroup/__tests__/getTagGroupProps.test.ts @@ -8,11 +8,14 @@ import { describe('getTagGroupProps', () => { describe('hook props', () => { - test('assign assigns a role of "grid"', () => { + test('assign assigns a role of "grid" and aria live attributes', () => { const {result} = renderUseTagGroup() const tagGroupProps = result.current.getTagGroupProps() expect(tagGroupProps.role).toEqual('grid') + expect(tagGroupProps["aria-live"]).toEqual('polite') + expect(tagGroupProps["aria-atomic"]).toEqual('false') + expect(tagGroupProps["aria-relevant"]).toEqual('additions') }) }) diff --git a/src/hooks/useTagGroup/__tests__/getTagProps.test.ts b/src/hooks/useTagGroup/__tests__/getTagProps.test.ts index 2c5797670..b7a434669 100644 --- a/src/hooks/useTagGroup/__tests__/getTagProps.test.ts +++ b/src/hooks/useTagGroup/__tests__/getTagProps.test.ts @@ -4,10 +4,10 @@ describe('getTagProps', () => { describe('hook props', () => { test('assign assigns a role of "row"', () => { const {result} = renderUseTagGroup() - const tagGroupProps = result.current.getTagProps({index: 0}) + const tagProps = result.current.getTagProps({index: 0}) - expect(tagGroupProps.role).toEqual('row') - expect(tagGroupProps.tabIndex).toEqual(-1) + expect(tagProps.role).toEqual('row') + expect(tagProps.tabIndex).toEqual(-1) }) test('assign default value to id', () => { diff --git a/src/hooks/useTagGroup/__tests__/getTagRemoveProps.test.ts b/src/hooks/useTagGroup/__tests__/getTagRemoveProps.test.ts new file mode 100644 index 000000000..424cfe29d --- /dev/null +++ b/src/hooks/useTagGroup/__tests__/getTagRemoveProps.test.ts @@ -0,0 +1,103 @@ +import { + renderTagGroup, + renderUseTagGroup, + defaultIds, + act, + defaultProps, + screen, +} from '../testUtils' + +describe('getTagRemoveProps', () => { + describe('hook props', () => { + test('assign assigns tabindex of -1 and aria-labelledby', () => { + const {result} = renderUseTagGroup() + const tagRemoveProps = result.current.getTagRemoveProps({index: 0}) + + expect(tagRemoveProps.tabIndex).toEqual(-1) + expect(tagRemoveProps['aria-labelledby']).toEqual( + `${defaultIds.getTagId(0)}-remove ${defaultIds.getTagId(0)}`, + ) + }) + + test('assign default value to id', () => { + const {result} = renderUseTagGroup() + + expect(result.current.getTagRemoveProps({index: 0}).id).toEqual( + `${defaultIds.getTagId(0)}-remove`, + ) + }) + + test('assign custom value passed by user to id', () => { + const getTagId = (index: number) => `my-custom-item-id-${index}` + const {result} = renderUseTagGroup({getTagId}) + + expect(result.current.getTagRemoveProps({index: 0}).id).toEqual( + `${getTagId(0)}-remove`, + ) + }) + }) + + describe('user props', () => { + test('are passed down', () => { + const {result} = renderUseTagGroup() + + expect( + result.current.getTagRemoveProps({foo: 'bar', index: 0}), + ).toHaveProperty('foo', 'bar') + }) + + test('event handler onClick is called along with downshift handler', () => { + const userOnClick = jest.fn() + const stopPropagation = jest.fn() + const {result} = renderUseTagGroup() + + act(() => { + const {onClick} = result.current.getTagRemoveProps({ + onClick: userOnClick, + index: 1, + }) + + onClick({stopPropagation}) + }) + + expect(userOnClick).toHaveBeenCalledTimes(1) + expect(result.current.items).not.toContain(defaultProps.initialItems[1]) + expect(stopPropagation).toHaveBeenCalledTimes(1) + }) + + test('event handler onClick is called along without downshift handler if event.preventDownshiftDefault is passed', () => { + const stopPropagation = jest.fn() + const userOnClick = jest.fn(event => { + event.preventDownshiftDefault = true + }) + const {result} = renderUseTagGroup({initialActiveIndex: 2}) + + act(() => { + const {onClick} = result.current.getTagRemoveProps({ + onClick: userOnClick, + index: 1, + }) + + onClick({stopPropagation}) + }) + + expect(userOnClick).toHaveBeenCalledTimes(1) + expect(result.current.items).toContain(defaultProps.initialItems[1]) + expect(stopPropagation).not.toHaveBeenCalled() + }) + }) + + describe('click', () => { + test('removes the tag', async () => { + const {clickOnRemoveTag} = renderTagGroup() + + await clickOnRemoveTag(2) + + expect( + screen.getByRole('row', {name: defaultProps.initialItems[1]}), + ).toBeInTheDocument() + }) + + + }) +}) diff --git a/src/hooks/useTagGroup/testUtils.tsx b/src/hooks/useTagGroup/testUtils.tsx index 33f1e14e2..9bc2a1cd0 100644 --- a/src/hooks/useTagGroup/testUtils.tsx +++ b/src/hooks/useTagGroup/testUtils.tsx @@ -55,6 +55,10 @@ export function renderTagGroup(props: Partial> = {}) { return utils.getByRole('grid') } + function getTagsRemoves() { + return utils.getAllByRole('button') + } + async function clickOnTag(index: number) { const tags = getTags() const tag = tags[index] as HTMLElement @@ -62,7 +66,13 @@ export function renderTagGroup(props: Partial> = {}) { await user.click(tag) } - return {...utils, getTags, getTagGroup, clickOnTag, user} + async function clickOnRemoveTag(index: number) { + const removeButtons = getTagsRemoves() + + await user.click(removeButtons[index] as HTMLElement) + } + + return {...utils, getTags, getTagGroup, clickOnTag, clickOnRemoveTag, user} } export function renderUseTagGroup( @@ -84,15 +94,16 @@ function TagGroup(props: Partial> = {}) { {color} - ✕ - + ))}
    From 84f641a506e42cea8c36bfbbb3ba214c2019f7cb Mon Sep 17 00:00:00 2001 From: Silviu Alexandu Avram Date: Tue, 29 Jul 2025 10:08:10 +0300 Subject: [PATCH 12/40] ids --- .../__tests__/getTagGroupProps.test.ts | 1 + .../__tests__/getTagRemoveProps.test.ts | 2 +- src/hooks/useTagGroup/__tests__/props.test.ts | 17 +++++++ .../useTagGroup/__tests__/returnProps.test.ts | 50 +++++++++++++++++++ src/hooks/useTagGroup/index.ts | 5 +- src/hooks/useTagGroup/index.types.ts | 1 + src/hooks/useTagGroup/testUtils.tsx | 2 +- src/set-a11y-status.js | 2 + 8 files changed, 76 insertions(+), 4 deletions(-) create mode 100644 src/hooks/useTagGroup/__tests__/props.test.ts create mode 100644 src/hooks/useTagGroup/__tests__/returnProps.test.ts diff --git a/src/hooks/useTagGroup/__tests__/getTagGroupProps.test.ts b/src/hooks/useTagGroup/__tests__/getTagGroupProps.test.ts index 921d49b85..8c56806f2 100644 --- a/src/hooks/useTagGroup/__tests__/getTagGroupProps.test.ts +++ b/src/hooks/useTagGroup/__tests__/getTagGroupProps.test.ts @@ -13,6 +13,7 @@ describe('getTagGroupProps', () => { const tagGroupProps = result.current.getTagGroupProps() expect(tagGroupProps.role).toEqual('grid') + expect(tagGroupProps.id).toEqual('downshift-test-id-tag-group') expect(tagGroupProps["aria-live"]).toEqual('polite') expect(tagGroupProps["aria-atomic"]).toEqual('false') expect(tagGroupProps["aria-relevant"]).toEqual('additions') diff --git a/src/hooks/useTagGroup/__tests__/getTagRemoveProps.test.ts b/src/hooks/useTagGroup/__tests__/getTagRemoveProps.test.ts index 424cfe29d..9acdb7ab4 100644 --- a/src/hooks/useTagGroup/__tests__/getTagRemoveProps.test.ts +++ b/src/hooks/useTagGroup/__tests__/getTagRemoveProps.test.ts @@ -15,7 +15,7 @@ describe('getTagRemoveProps', () => { expect(tagRemoveProps.tabIndex).toEqual(-1) expect(tagRemoveProps['aria-labelledby']).toEqual( - `${defaultIds.getTagId(0)}-remove ${defaultIds.getTagId(0)}`, + `${defaultIds.tagGroupId} ${defaultIds.getTagId(0)}`, ) }) diff --git a/src/hooks/useTagGroup/__tests__/props.test.ts b/src/hooks/useTagGroup/__tests__/props.test.ts new file mode 100644 index 000000000..94b416149 --- /dev/null +++ b/src/hooks/useTagGroup/__tests__/props.test.ts @@ -0,0 +1,17 @@ +import {renderTagGroup} from '../testUtils' + +describe('props', () => { + test('id if passed will override downshift default', () => { + const {getTagGroup, getTags} = renderTagGroup({ + id: 'my-custom-little-id', + }) + const elements = [getTagGroup(), getTags()[0]] + + elements.forEach(element => { + expect(element).toHaveAttribute( + 'id', + expect.stringContaining('my-custom-little-id'), + ) + }) + }) +}) diff --git a/src/hooks/useTagGroup/__tests__/returnProps.test.ts b/src/hooks/useTagGroup/__tests__/returnProps.test.ts new file mode 100644 index 000000000..85f231897 --- /dev/null +++ b/src/hooks/useTagGroup/__tests__/returnProps.test.ts @@ -0,0 +1,50 @@ +import useTagGroup from '..' +import {renderUseTagGroup, act, defaultProps} from '../testUtils' + +import * as stateChangeTypes from '../stateChangeTypes' + +describe('returnProps', () => { + test('should have stateChangeTypes attached to hook', () => { + expect(useTagGroup).toHaveProperty('stateChangeTypes', stateChangeTypes) + }) + + describe('prop getters', () => { + test('are returned as functions', () => { + const {result} = renderUseTagGroup() + + expect(result.current.getTagGroupProps).toBeInstanceOf(Function) + expect(result.current.getTagProps).toBeInstanceOf(Function) + expect(result.current.getTagRemoveProps).toBeInstanceOf(Function) + }) + }) + + describe('actions', () => { + test('addItem adds an item to the group', () => { + const {result} = renderUseTagGroup() + + const previousItems = result.current.items + + act(() => { + result.current.addItem('test') + }) + + expect(result.current.items).toEqual([...previousItems, 'test']) + }) + }) + + describe('state and props', () => { + test('activeIndex is returned', () => { + const {result} = renderUseTagGroup() + + expect(result.current.activeIndex).toBe( + defaultProps.initialItems.length - 1, + ) + }) + + test('items is returned', () => { + const {result} = renderUseTagGroup() + + expect(result.current.items).toBe(defaultProps.initialItems) + }) + }) +}) diff --git a/src/hooks/useTagGroup/index.ts b/src/hooks/useTagGroup/index.ts index 978dde143..2c6610d11 100644 --- a/src/hooks/useTagGroup/index.ts +++ b/src/hooks/useTagGroup/index.ts @@ -115,6 +115,7 @@ export default function useTagGroup( } const tagGroupProps = { + id: elementIds.tagGroupId, 'aria-live': 'polite', 'aria-atomic': 'false', 'aria-relevant': 'additions', @@ -125,7 +126,7 @@ export default function useTagGroup( return tagGroupProps }, - [dispatch], + [dispatch, elementIds.tagGroupId], ) as GetTagGroupProps const getTagProps = useCallback( @@ -174,7 +175,7 @@ export default function useTagGroup( return { id, tabIndex: -1, - 'aria-labelledby': `${id} ${tagId}`, + 'aria-labelledby': `${elementIds.tagGroupId} ${tagId}`, onClick: callAllEventHandlers(onClick, handleClick), ...rest, } diff --git a/src/hooks/useTagGroup/index.types.ts b/src/hooks/useTagGroup/index.types.ts index 37e147616..4609a3b19 100644 --- a/src/hooks/useTagGroup/index.types.ts +++ b/src/hooks/useTagGroup/index.types.ts @@ -62,6 +62,7 @@ export interface GetTagRemovePropsReturnValue { export type GetTagGroupPropsOptions = React.HTMLProps export interface GetTagGroupPropsReturnValue { + id: string role: 'grid' 'aria-live': 'polite' 'aria-atomic': 'false' diff --git a/src/hooks/useTagGroup/testUtils.tsx b/src/hooks/useTagGroup/testUtils.tsx index 9bc2a1cd0..e1cead25d 100644 --- a/src/hooks/useTagGroup/testUtils.tsx +++ b/src/hooks/useTagGroup/testUtils.tsx @@ -72,7 +72,7 @@ export function renderTagGroup(props: Partial> = {}) { await user.click(removeButtons[index] as HTMLElement) } - return {...utils, getTags, getTagGroup, clickOnTag, clickOnRemoveTag, user} + return {...utils, getTags, getTagGroup, clickOnTag, clickOnRemoveTag, getTagsRemoves, user} } export function renderUseTagGroup( diff --git a/src/set-a11y-status.js b/src/set-a11y-status.js index 3beecf35b..4ed4799d6 100644 --- a/src/set-a11y-status.js +++ b/src/set-a11y-status.js @@ -15,6 +15,8 @@ function getStatusDiv(documentProp) { return statusDiv } + // refactor this for the aria description + statusDiv = documentProp.createElement('div') statusDiv.setAttribute('id', 'a11y-status-message') statusDiv.setAttribute('role', 'status') From 3a793c1bd737241a40f9fa809f830862a2c56af6 Mon Sep 17 00:00:00 2001 From: Silviu Alexandu Avram Date: Mon, 11 Aug 2025 10:22:32 +0300 Subject: [PATCH 13/40] move utils to ts --- src/__tests__/downshift.lifecycle.js | 24 +- .../downshift.misc-with-utils-mocked.js | 6 +- src/__tests__/set-a11y-status.js | 3 +- src/__tests__/utils.scroll-into-view.js | 2 +- src/downshift.js | 6 +- src/hooks/__tests__/utils.test.js | 14 +- .../utils.use-element-ids.r18.test.js | 4 +- .../__tests__/utils.use-element-ids.test.js | 15 +- src/hooks/reducer.js | 25 +- .../__tests__/getItemProps.test.js | 2 +- src/hooks/useCombobox/__tests__/utils.test.js | 2 +- src/hooks/useCombobox/index.js | 13 +- src/hooks/useCombobox/reducer.js | 11 +- src/hooks/useCombobox/testUtils.js | 4 +- src/hooks/useCombobox/utils.js | 14 +- .../__tests__/props.test.js | 5 +- .../__tests__/utils.test.js | 2 +- src/hooks/useMultipleSelection/index.js | 9 +- src/hooks/useMultipleSelection/reducer.js | 8 +- src/hooks/useMultipleSelection/testUtils.js | 4 +- src/hooks/useMultipleSelection/utils.js | 20 +- .../useSelect/__tests__/getItemProps.test.js | 2 +- src/hooks/useSelect/__tests__/utils.test.ts | 2 +- src/hooks/useSelect/index.js | 25 +- src/hooks/useSelect/reducer.js | 11 +- src/hooks/useSelect/testUtils.js | 4 +- src/hooks/useSelect/utils.ts | 9 +- src/hooks/useSelect/utils/defaultProps.ts | 8 + .../utils/getItemIndexByCharacterKey.ts | 29 ++ src/hooks/useSelect/utils/index.ts | 3 + src/hooks/useSelect/utils/propTypes.ts | 8 + src/hooks/useTagGroup/index.ts | 21 +- src/hooks/useTagGroup/index.types.ts | 4 +- src/hooks/useTagGroup/reducer.ts | 9 +- src/hooks/useTagGroup/utils.ts | 267 -------------- src/hooks/useTagGroup/utils.types.ts | 23 -- .../useTagGroup/utils/getInitialState.ts | 25 ++ src/hooks/useTagGroup/utils/index.ts | 16 + src/hooks/useTagGroup/utils/isStateEqual.ts | 11 + src/hooks/useTagGroup/utils/useElementIds.ts | 44 +++ src/hooks/utils-ts/callOnChangeProps.ts | 37 ++ src/hooks/utils-ts/capitalizeString.ts | 3 + src/hooks/utils-ts/getDefaultValue.ts | 16 + src/hooks/utils-ts/getInitialValue.ts | 23 ++ .../getItemAndIndex.ts} | 19 - src/hooks/utils-ts/index.ts | 11 + src/hooks/utils-ts/propTypes.ts | 18 + src/hooks/utils-ts/stateReducer.ts | 9 + src/hooks/utils-ts/useA11yMessageStatus.ts | 51 +++ src/hooks/utils-ts/useControlledReducer.ts | 33 ++ src/hooks/utils-ts/useEnhancedReducer.ts | 77 ++++ src/hooks/utils-ts/useIsInitialMount.ts | 18 + src/hooks/utils.dropdown/defaultProps.ts | 18 + .../utils.dropdown/defaultStateValues.ts | 6 + src/hooks/utils.dropdown/index.ts | 3 + src/hooks/utils.dropdown/propTypes.ts | 28 ++ src/hooks/utils.js | 339 ++---------------- src/set-a11y-status.js | 64 ---- src/utils-ts.ts | 91 ----- src/utils-ts/callAllEventHandlers.ts | 26 ++ src/utils-ts/debounce.ts | 29 ++ src/utils-ts/generateId.ts | 35 ++ src/utils-ts/getState.ts | 35 ++ src/utils-ts/index.ts | 10 + src/utils-ts/noop.ts | 1 + src/utils-ts/scrollIntoView.ts | 25 ++ src/utils-ts/setA11yStatus.ts | 59 +++ src/utils-ts/useLatestRef.ts | 30 ++ src/utils-ts/validatePropTypes.ts | 25 ++ src/utils.js | 23 -- 70 files changed, 941 insertions(+), 935 deletions(-) create mode 100644 src/hooks/useSelect/utils/defaultProps.ts create mode 100644 src/hooks/useSelect/utils/getItemIndexByCharacterKey.ts create mode 100644 src/hooks/useSelect/utils/index.ts create mode 100644 src/hooks/useSelect/utils/propTypes.ts delete mode 100644 src/hooks/useTagGroup/utils.ts delete mode 100644 src/hooks/useTagGroup/utils.types.ts create mode 100644 src/hooks/useTagGroup/utils/getInitialState.ts create mode 100644 src/hooks/useTagGroup/utils/index.ts create mode 100644 src/hooks/useTagGroup/utils/isStateEqual.ts create mode 100644 src/hooks/useTagGroup/utils/useElementIds.ts create mode 100644 src/hooks/utils-ts/callOnChangeProps.ts create mode 100644 src/hooks/utils-ts/capitalizeString.ts create mode 100644 src/hooks/utils-ts/getDefaultValue.ts create mode 100644 src/hooks/utils-ts/getInitialValue.ts rename src/hooks/{utils-ts.ts => utils-ts/getItemAndIndex.ts} (75%) create mode 100644 src/hooks/utils-ts/index.ts create mode 100644 src/hooks/utils-ts/propTypes.ts create mode 100644 src/hooks/utils-ts/stateReducer.ts create mode 100644 src/hooks/utils-ts/useA11yMessageStatus.ts create mode 100644 src/hooks/utils-ts/useControlledReducer.ts create mode 100644 src/hooks/utils-ts/useEnhancedReducer.ts create mode 100644 src/hooks/utils-ts/useIsInitialMount.ts create mode 100644 src/hooks/utils.dropdown/defaultProps.ts create mode 100644 src/hooks/utils.dropdown/defaultStateValues.ts create mode 100644 src/hooks/utils.dropdown/index.ts create mode 100644 src/hooks/utils.dropdown/propTypes.ts delete mode 100644 src/set-a11y-status.js delete mode 100644 src/utils-ts.ts create mode 100644 src/utils-ts/callAllEventHandlers.ts create mode 100644 src/utils-ts/debounce.ts create mode 100644 src/utils-ts/generateId.ts create mode 100644 src/utils-ts/getState.ts create mode 100644 src/utils-ts/index.ts create mode 100644 src/utils-ts/noop.ts create mode 100644 src/utils-ts/scrollIntoView.ts create mode 100644 src/utils-ts/setA11yStatus.ts create mode 100644 src/utils-ts/useLatestRef.ts create mode 100644 src/utils-ts/validatePropTypes.ts diff --git a/src/__tests__/downshift.lifecycle.js b/src/__tests__/downshift.lifecycle.js index dfd3782ea..95c209f88 100644 --- a/src/__tests__/downshift.lifecycle.js +++ b/src/__tests__/downshift.lifecycle.js @@ -1,21 +1,19 @@ import * as React from 'react' + import {act, fireEvent, render, screen} from '@testing-library/react' import Downshift from '../' -import {setStatus} from '../set-a11y-status' -import * as utils from '../utils' +import {setStatus, scrollIntoView} from '../utils-ts' jest.useFakeTimers() -jest.mock('../set-a11y-status') -jest.mock('../utils', () => { - const realUtils = jest.requireActual('../utils') - return { - ...realUtils, - scrollIntoView: jest.fn(), - } -}) +jest.mock('../utils-ts/scrollIntoView.ts', () => ({ + scrollIntoView: jest.fn(), +})) +jest.mock('../utils-ts/setA11yStatus.ts', () => ({ + setStatus: jest.fn(), +})) afterEach(() => { - utils.scrollIntoView.mockReset() + scrollIntoView.mockReset() }) test('do not set state after unmount', () => { @@ -248,9 +246,9 @@ test('controlled highlighted index change scrolls the item into view', () => { updateProps({highlightedIndex: 75}) expect(renderFn).toHaveBeenCalledTimes(1) - expect(utils.scrollIntoView).toHaveBeenCalledTimes(1) + expect(scrollIntoView).toHaveBeenCalledTimes(1) const menuDiv = screen.queryByTestId('menu') - expect(utils.scrollIntoView).toHaveBeenCalledWith( + expect(scrollIntoView).toHaveBeenCalledWith( screen.queryByTestId('item-75'), menuDiv, ) diff --git a/src/__tests__/downshift.misc-with-utils-mocked.js b/src/__tests__/downshift.misc-with-utils-mocked.js index b5d7dfd9e..6407476cd 100644 --- a/src/__tests__/downshift.misc-with-utils-mocked.js +++ b/src/__tests__/downshift.misc-with-utils-mocked.js @@ -4,10 +4,12 @@ import * as React from 'react' import {render, fireEvent, screen} from '@testing-library/react' import Downshift from '../' -import {scrollIntoView} from '../utils' +import {scrollIntoView} from '../utils-ts' jest.useFakeTimers() -jest.mock('../utils') +jest.mock('../utils-ts/scrollIntoView.ts', () => ({ + scrollIntoView: jest.fn(), +})) test('does not scroll from an onMouseMove event', () => { class HighlightedIndexController extends React.Component { diff --git a/src/__tests__/set-a11y-status.js b/src/__tests__/set-a11y-status.js index 1fb3b013d..c751143f3 100644 --- a/src/__tests__/set-a11y-status.js +++ b/src/__tests__/set-a11y-status.js @@ -71,7 +71,6 @@ test('creates new status div if there is none', () => { expect(statusDiv.textContent).toEqual('hello') }) - test('creates no status div if there is no document', () => { const setA11yStatus = setup() setA11yStatus('') @@ -80,5 +79,5 @@ test('creates no status div if there is no document', () => { function setup() { jest.resetModules() - return require('../set-a11y-status').setStatus + return require('../utils-ts').setStatus } diff --git a/src/__tests__/utils.scroll-into-view.js b/src/__tests__/utils.scroll-into-view.js index dc89dc310..13780032c 100644 --- a/src/__tests__/utils.scroll-into-view.js +++ b/src/__tests__/utils.scroll-into-view.js @@ -1,4 +1,4 @@ -import {scrollIntoView} from '../utils' +import {scrollIntoView} from '../utils-ts' test('does not throw with a null node', () => { expect(() => scrollIntoView(null)).not.toThrow() diff --git a/src/downshift.js b/src/downshift.js index 381650559..4b59c006b 100644 --- a/src/downshift.js +++ b/src/downshift.js @@ -4,7 +4,6 @@ import PropTypes from 'prop-types' import {Component, cloneElement} from 'react' import {isForwardRef} from 'react-is' import {isPreact, isReactNative, isReactNativeWeb} from './is.macro' -import {setStatus} from './set-a11y-status' import * as stateChangeTypes from './stateChangeTypes' import { handleRefs, @@ -20,7 +19,6 @@ import { normalizeArrowKey, pickState, requiredProp, - scrollIntoView, unwrapArray, getState, isControlledProp, @@ -28,9 +26,7 @@ import { getHighlightedIndex, getNonDisabledIndex, } from './utils' -import { - generateId -} from './utils-ts' +import {generateId, scrollIntoView, setStatus} from './utils-ts' class Downshift extends Component { static propTypes = { diff --git a/src/hooks/__tests__/utils.test.js b/src/hooks/__tests__/utils.test.js index 4be6196d7..44cf82f7f 100644 --- a/src/hooks/__tests__/utils.test.js +++ b/src/hooks/__tests__/utils.test.js @@ -1,13 +1,7 @@ import {renderHook} from '@testing-library/react' -import { - defaultProps, - getInitialValue, - getDefaultValue, - useMouseAndTouchTracker, - isDropdownsStateEqual, - useElementIds, -} from '../utils' -import {getItemAndIndex} from '../utils-ts' +import {useMouseAndTouchTracker, isDropdownsStateEqual} from '../utils' +import {getInitialValue, getDefaultValue, getItemAndIndex} from '../utils-ts' +import {dropdownDefaultProps} from '../utils.dropdown' describe('utils', () => { describe('useElementIds', () => { @@ -63,7 +57,7 @@ describe('utils', () => { describe('itemToString', () => { test('returns empty string if item is falsy', () => { - const emptyString = defaultProps.itemToString(null) + const emptyString = dropdownDefaultProps.itemToString(null) expect(emptyString).toBe('') }) }) diff --git a/src/hooks/__tests__/utils.use-element-ids.r18.test.js b/src/hooks/__tests__/utils.use-element-ids.r18.test.js index d05e435d3..6353219eb 100644 --- a/src/hooks/__tests__/utils.use-element-ids.r18.test.js +++ b/src/hooks/__tests__/utils.use-element-ids.r18.test.js @@ -1,5 +1,5 @@ -const {renderHook} = require('@testing-library/react') -const {useElementIds} = require('../utils') +import {renderHook} from '@testing-library/react' +import {useElementIds} from '../utils' jest.mock('react', () => { return { diff --git a/src/hooks/__tests__/utils.use-element-ids.test.js b/src/hooks/__tests__/utils.use-element-ids.test.js index b7a4f557f..32f58a23d 100644 --- a/src/hooks/__tests__/utils.use-element-ids.test.js +++ b/src/hooks/__tests__/utils.use-element-ids.test.js @@ -1,19 +1,14 @@ -const {renderHook} = require('@testing-library/react') -const {useElementIds} = require('../utils') +import {renderHook} from '@testing-library/react' +import {useElementIds} from '../utils' jest.mock('react', () => { const {useId, ...react} = jest.requireActual('react') return react }) -jest.mock('../../utils', () => { - const downshiftUtils = jest.requireActual('../../utils') - - return { - ...downshiftUtils, - generateId: () => 'test-id', - } -}) +jest.mock('../../utils-ts/generateId.ts', () => ({ + generateId: jest.fn().mockReturnValue('test-id'), +})) describe('useElementIds', () => { test('uses React.useId for React < 18', () => { diff --git a/src/hooks/reducer.js b/src/hooks/reducer.js index 62117c018..c1ec2f8e5 100644 --- a/src/hooks/reducer.js +++ b/src/hooks/reducer.js @@ -1,15 +1,14 @@ -import { - getHighlightedIndexOnOpen, - getDefaultValue, - getDefaultHighlightedIndex, -} from './utils' +import {getHighlightedIndexOnOpen, getDefaultHighlightedIndex} from './utils' +import {getDefaultValue} from './utils-ts' +import {dropdownDefaultStateValues} from './utils.dropdown' export default function downshiftCommonReducer( state, + props, action, stateChangeTypes, ) { - const {type, props} = action + const {type} = action let changes switch (type) { @@ -68,9 +67,17 @@ export default function downshiftCommonReducer( case stateChangeTypes.FunctionReset: changes = { highlightedIndex: getDefaultHighlightedIndex(props), - isOpen: getDefaultValue(props, 'isOpen'), - selectedItem: getDefaultValue(props, 'selectedItem'), - inputValue: getDefaultValue(props, 'inputValue'), + isOpen: getDefaultValue(props, 'isOpen', dropdownDefaultStateValues), + selectedItem: getDefaultValue( + props, + 'selectedItem', + dropdownDefaultStateValues, + ), + inputValue: getDefaultValue( + props, + 'inputValue', + dropdownDefaultStateValues, + ), } break diff --git a/src/hooks/useCombobox/__tests__/getItemProps.test.js b/src/hooks/useCombobox/__tests__/getItemProps.test.js index d0472a934..e933bc63d 100644 --- a/src/hooks/useCombobox/__tests__/getItemProps.test.js +++ b/src/hooks/useCombobox/__tests__/getItemProps.test.js @@ -394,7 +394,7 @@ describe('getItemProps', () => { test('will be displayed if getInputProps is not called', () => { renderHook(() => { const {getItemProps} = useCombobox({items}) - getItemProps({disabled: true, index: 98}) + getItemProps({disabled: true, index: 1}) }) expect(console.warn.mock.calls[0][0]).toMatchInlineSnapshot( diff --git a/src/hooks/useCombobox/__tests__/utils.test.js b/src/hooks/useCombobox/__tests__/utils.test.js index 8a27b1745..e11414adc 100644 --- a/src/hooks/useCombobox/__tests__/utils.test.js +++ b/src/hooks/useCombobox/__tests__/utils.test.js @@ -3,7 +3,7 @@ import reducer from '../reducer' describe('utils', () => { test('reducer throws error if called without proper action type', () => { expect(() => { - reducer({}, {type: 'super-bogus'}) + reducer({}, {}, {type: 'super-bogus'}) }).toThrowError('Reducer called without proper action type.') }) }) diff --git a/src/hooks/useCombobox/index.js b/src/hooks/useCombobox/index.js index 7c0553deb..41c999fa8 100644 --- a/src/hooks/useCombobox/index.js +++ b/src/hooks/useCombobox/index.js @@ -1,19 +1,22 @@ import {useRef, useEffect, useCallback, useMemo} from 'react' import {isPreact, isReactNative, isReactNativeWeb} from '../../is.macro' import {handleRefs, normalizeArrowKey, callAllEventHandlers} from '../../utils' +import {useLatestRef} from '../../utils-ts' import { useMouseAndTouchTracker, useGetterPropsCalledChecker, - useLatestRef, useScrollIntoView, useControlPropsValidator, useElementIds, - getInitialValue, isDropdownsStateEqual, +} from '../utils' +import { + getItemAndIndex, + getInitialValue, useIsInitialMount, useA11yMessageStatus, -} from '../utils' -import { getItemAndIndex } from '../utils-ts' +} from '../utils-ts' +import {defaultStateValues} from '../utils.dropdown/defaultStateValues' import { getInitialState, defaultProps, @@ -84,7 +87,7 @@ function useCombobox(userProps = {}) { }) // Focus the input on first render if required. useEffect(() => { - const focusOnOpen = getInitialValue(props, 'isOpen') + const focusOnOpen = getInitialValue(props, 'isOpen', defaultStateValues) if (focusOnOpen && inputRef.current) { inputRef.current.focus() diff --git a/src/hooks/useCombobox/reducer.js b/src/hooks/useCombobox/reducer.js index 065df4974..3e97c3633 100644 --- a/src/hooks/useCombobox/reducer.js +++ b/src/hooks/useCombobox/reducer.js @@ -1,22 +1,23 @@ import { getHighlightedIndexOnOpen, - getDefaultValue, getChangesOnSelection, getDefaultHighlightedIndex, } from '../utils' +import {getDefaultValue} from '../utils-ts' import {getHighlightedIndex, getNonDisabledIndex} from '../../utils' import commonReducer from '../reducer' +import {dropdownDefaultStateValues} from '../utils.dropdown' import * as stateChangeTypes from './stateChangeTypes' /* eslint-disable complexity */ -export default function downshiftUseComboboxReducer(state, action) { - const {type, props, altKey} = action +export default function downshiftUseComboboxReducer(state, props, action) { + const {type, altKey} = action let changes switch (type) { case stateChangeTypes.ItemClick: changes = { - isOpen: getDefaultValue(props, 'isOpen'), + isOpen: getDefaultValue(props, 'isOpen', dropdownDefaultStateValues), highlightedIndex: getDefaultHighlightedIndex(props), selectedItem: props.items[action.index], inputValue: props.itemToString(props.items[action.index]), @@ -161,7 +162,7 @@ export default function downshiftUseComboboxReducer(state, action) { } break default: - return commonReducer(state, action, stateChangeTypes) + return commonReducer(state, props, action, stateChangeTypes) } return { diff --git a/src/hooks/useCombobox/testUtils.js b/src/hooks/useCombobox/testUtils.js index a0f09befb..fc5f2ab2b 100644 --- a/src/hooks/useCombobox/testUtils.js +++ b/src/hooks/useCombobox/testUtils.js @@ -1,6 +1,6 @@ import * as React from 'react' import {render, screen, renderHook} from '@testing-library/react' -import {defaultProps} from '../utils' +import {dropdownDefaultProps} from '../utils.dropdown' import {dataTestIds, items, user} from '../testUtils' import useCombobox from '.' @@ -76,7 +76,7 @@ function DropdownCombobox({renderSpy, renderItem, ...props}) { getInputProps, getItemProps, } = useCombobox({items, ...props}) - const {itemToString} = props.itemToString ? props : defaultProps + const {itemToString} = props.itemToString ? props : dropdownDefaultProps renderSpy() diff --git a/src/hooks/useCombobox/utils.js b/src/hooks/useCombobox/utils.js index 290c0bd28..cd154de45 100644 --- a/src/hooks/useCombobox/utils.js +++ b/src/hooks/useCombobox/utils.js @@ -1,13 +1,9 @@ import {useRef, useEffect} from 'react' import PropTypes from 'prop-types' import {isControlledProp, getState, noop} from '../../utils' -import { - commonDropdownPropTypes, - defaultProps as defaultPropsCommon, - getInitialState as getInitialStateCommon, - useEnhancedReducer, - useIsInitialMount, -} from '../utils' +import {getInitialState as getInitialStateCommon} from '../utils' +import {dropdownDefaultProps, dropdownPropTypes} from '../utils.dropdown' +import {useIsInitialMount, useEnhancedReducer} from '../utils-ts' import {ControlledPropUpdatedSelectedItem} from './stateChangeTypes' export function getInitialState(props) { @@ -32,7 +28,7 @@ export function getInitialState(props) { } const propTypes = { - ...commonDropdownPropTypes, + ...dropdownPropTypes, items: PropTypes.array.isRequired, isItemDisabled: PropTypes.func, inputValue: PropTypes.string, @@ -110,7 +106,7 @@ if (process.env.NODE_ENV !== 'production') { } export const defaultProps = { - ...defaultPropsCommon, + ...dropdownDefaultProps, isItemDisabled() { return false }, diff --git a/src/hooks/useMultipleSelection/__tests__/props.test.js b/src/hooks/useMultipleSelection/__tests__/props.test.js index d9b647183..659c5c361 100644 --- a/src/hooks/useMultipleSelection/__tests__/props.test.js +++ b/src/hooks/useMultipleSelection/__tests__/props.test.js @@ -92,7 +92,9 @@ describe('props', () => { expect(getA11yStatusMessage).toHaveBeenCalledTimes(1) getA11yStatusMessage.mockClear() - rerender({multipleSelectionProps: {...multipleSelectionProps, activeIndex: 0}}) + rerender({ + multipleSelectionProps: {...multipleSelectionProps, activeIndex: 0}, + }) expect(getA11yStatusMessage).not.toHaveBeenCalled() }) @@ -587,6 +589,7 @@ describe('props', () => { onActiveIndexChange: () => { result.current.setSelectedItems([items[0]]) }, + initialSelectedItems: items, }) act(() => { diff --git a/src/hooks/useMultipleSelection/__tests__/utils.test.js b/src/hooks/useMultipleSelection/__tests__/utils.test.js index 8a27b1745..e11414adc 100644 --- a/src/hooks/useMultipleSelection/__tests__/utils.test.js +++ b/src/hooks/useMultipleSelection/__tests__/utils.test.js @@ -3,7 +3,7 @@ import reducer from '../reducer' describe('utils', () => { test('reducer throws error if called without proper action type', () => { expect(() => { - reducer({}, {type: 'super-bogus'}) + reducer({}, {}, {type: 'super-bogus'}) }).toThrowError('Reducer called without proper action type.') }) }) diff --git a/src/hooks/useMultipleSelection/index.js b/src/hooks/useMultipleSelection/index.js index b08669a02..8a668ee80 100644 --- a/src/hooks/useMultipleSelection/index.js +++ b/src/hooks/useMultipleSelection/index.js @@ -1,14 +1,13 @@ import {useRef, useEffect, useCallback, useMemo} from 'react' import {handleRefs, callAllEventHandlers, normalizeArrowKey} from '../../utils' +import {useLatestRef} from '../../utils-ts' +import {useGetterPropsCalledChecker, useControlPropsValidator} from '../utils' import { useControlledReducer, - useGetterPropsCalledChecker, - useLatestRef, - useControlPropsValidator, useIsInitialMount, useA11yMessageStatus, -} from '../utils' -import { getItemAndIndex } from '../utils-ts' + getItemAndIndex, +} from '../utils-ts' import { getInitialState, defaultProps, diff --git a/src/hooks/useMultipleSelection/reducer.js b/src/hooks/useMultipleSelection/reducer.js index cc8ba2116..8c22cdd04 100644 --- a/src/hooks/useMultipleSelection/reducer.js +++ b/src/hooks/useMultipleSelection/reducer.js @@ -2,8 +2,12 @@ import {getDefaultValue} from './utils' import * as stateChangeTypes from './stateChangeTypes' /* eslint-disable complexity */ -export default function downshiftMultipleSelectionReducer(state, action) { - const {type, index, props, selectedItem} = action +export default function downshiftMultipleSelectionReducer( + state, + props, + action, +) { + const {type, index, selectedItem} = action const {activeIndex, selectedItems} = state let changes diff --git a/src/hooks/useMultipleSelection/testUtils.js b/src/hooks/useMultipleSelection/testUtils.js index cd648603f..86db7417c 100644 --- a/src/hooks/useMultipleSelection/testUtils.js +++ b/src/hooks/useMultipleSelection/testUtils.js @@ -1,7 +1,7 @@ import * as React from 'react' import {render, screen, renderHook} from '@testing-library/react' -import {defaultProps} from '../utils' +import {dropdownDefaultProps} from '../utils.dropdown' import {items, user, dataTestIds} from '../testUtils' import useCombobox from '../useCombobox' import {getInput, keyDownOnInput} from '../useCombobox/testUtils' @@ -73,7 +73,7 @@ const DropdownMultipleCombobox = ({ items, ...comboboxProps, }) - const {itemToString} = defaultProps + const {itemToString} = dropdownDefaultProps return (
    diff --git a/src/hooks/useMultipleSelection/utils.js b/src/hooks/useMultipleSelection/utils.js index 024455772..e28bb80d6 100644 --- a/src/hooks/useMultipleSelection/utils.js +++ b/src/hooks/useMultipleSelection/utils.js @@ -1,11 +1,11 @@ import PropTypes from 'prop-types' + +import {noop} from '../../utils' import { getInitialValue as getInitialValueCommon, getDefaultValue as getDefaultValueCommon, - defaultProps as defaultPropsCommon, - commonPropTypes, -} from '../utils' -import {noop} from '../../utils' +} from '../utils-ts' +import {dropdownDefaultProps, dropdownPropTypes} from '../utils.dropdown' const defaultStateValues = { activeIndex: -1, @@ -98,9 +98,9 @@ function isStateEqual(prevState, newState) { } const propTypes = { - stateReducer: commonPropTypes.stateReducer, - itemToKey: commonPropTypes.itemToKey, - environment: commonPropTypes.environment, + stateReducer: dropdownPropTypes.stateReducer, + itemToKey: dropdownPropTypes.itemToKey, + environment: dropdownPropTypes.environment, selectedItems: PropTypes.array, initialSelectedItems: PropTypes.array, defaultSelectedItems: PropTypes.array, @@ -115,9 +115,9 @@ const propTypes = { } export const defaultProps = { - itemToKey: defaultPropsCommon.itemToKey, - stateReducer: defaultPropsCommon.stateReducer, - environment: defaultPropsCommon.environment, + itemToKey: dropdownDefaultProps.itemToKey, + stateReducer: dropdownDefaultProps.stateReducer, + environment: dropdownDefaultProps.environment, keyNavigationNext: 'ArrowRight', keyNavigationPrevious: 'ArrowLeft', } diff --git a/src/hooks/useSelect/__tests__/getItemProps.test.js b/src/hooks/useSelect/__tests__/getItemProps.test.js index 1231eedc9..9f743ce99 100644 --- a/src/hooks/useSelect/__tests__/getItemProps.test.js +++ b/src/hooks/useSelect/__tests__/getItemProps.test.js @@ -422,7 +422,7 @@ describe('getItemProps', () => { test('will be displayed if getInputProps is not called', () => { renderHook(() => { const {getItemProps} = useSelect({items}) - getItemProps({disabled: true, index: 99}) + getItemProps({disabled: true, index: 9}) }) expect(console.warn.mock.calls[0][0]).toMatchInlineSnapshot( diff --git a/src/hooks/useSelect/__tests__/utils.test.ts b/src/hooks/useSelect/__tests__/utils.test.ts index 982a8bb03..c148daf1f 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'}) }).toThrowError('Reducer called without proper action type.') }) diff --git a/src/hooks/useSelect/index.js b/src/hooks/useSelect/index.js index f79d6bf47..0c236e092 100644 --- a/src/hooks/useSelect/index.js +++ b/src/hooks/useSelect/index.js @@ -1,26 +1,29 @@ import {useRef, useEffect, useCallback, useMemo} from 'react' +import {useLatestRef} from '../../utils-ts' +import { + callAllEventHandlers, + handleRefs, + debounce, + normalizeArrowKey, +} from '../../utils' import { isAcceptedCharacterKey, - useControlledReducer, getInitialState, useGetterPropsCalledChecker, - useLatestRef, useScrollIntoView, useControlPropsValidator, useElementIds, useMouseAndTouchTracker, - getInitialValue, isDropdownsStateEqual, - useA11yMessageStatus, } from '../utils' import { - callAllEventHandlers, - handleRefs, - debounce, - normalizeArrowKey, -} from '../../utils' + useControlledReducer, + getInitialValue, + getItemAndIndex, + useA11yMessageStatus, +} from '../utils-ts' +import {defaultStateValues} from '../utils.dropdown/defaultStateValues' import {isReactNative, isReactNativeWeb} from '../../is.macro' -import { getItemAndIndex } from '../utils-ts' import downshiftSelectReducer from './reducer' import {validatePropTypes, defaultProps} from './utils' import * as stateChangeTypes from './stateChangeTypes' @@ -111,7 +114,7 @@ function useSelect(userProps = {}) { }) // Focus the toggle button on first render if required. useEffect(() => { - const focusOnOpen = getInitialValue(props, 'isOpen') + const focusOnOpen = getInitialValue(props, 'isOpen', defaultStateValues) if (focusOnOpen && toggleButtonRef.current) { toggleButtonRef.current.focus() diff --git a/src/hooks/useSelect/reducer.js b/src/hooks/useSelect/reducer.js index 380818bd8..8203ac4c9 100644 --- a/src/hooks/useSelect/reducer.js +++ b/src/hooks/useSelect/reducer.js @@ -1,23 +1,24 @@ import {getNonDisabledIndex, getHighlightedIndex} from '../../utils' import { getHighlightedIndexOnOpen, - getDefaultValue, getChangesOnSelection, getDefaultHighlightedIndex, } from '../utils' +import {getDefaultValue} from '../utils-ts' import commonReducer from '../reducer' +import {defaultStateValues} from '../utils.dropdown/defaultStateValues' import {getItemIndexByCharacterKey} from './utils' import * as stateChangeTypes from './stateChangeTypes' /* eslint-disable complexity */ -export default function downshiftSelectReducer(state, action) { - const {type, props, altKey} = action +export default function downshiftSelectReducer(state, props, action) { + const {type, altKey} = action let changes switch (type) { case stateChangeTypes.ItemClick: changes = { - isOpen: getDefaultValue(props, 'isOpen'), + isOpen: getDefaultValue(props, 'isOpen', defaultStateValues), highlightedIndex: getDefaultHighlightedIndex(props), selectedItem: props.items[action.index], } @@ -163,7 +164,7 @@ export default function downshiftSelectReducer(state, action) { break default: - return commonReducer(state, action, stateChangeTypes) + return commonReducer(state, props, action, stateChangeTypes) } return { diff --git a/src/hooks/useSelect/testUtils.js b/src/hooks/useSelect/testUtils.js index a473077e5..ef29b47df 100644 --- a/src/hooks/useSelect/testUtils.js +++ b/src/hooks/useSelect/testUtils.js @@ -1,6 +1,6 @@ import * as React from 'react' import {render, act, renderHook} from '@testing-library/react' -import {defaultProps} from '../utils' +import {dropdownDefaultProps} from '../utils.dropdown' import { clickOnItemAtIndex, clickOnToggleButton, @@ -65,7 +65,7 @@ export function DropdownSelect({renderSpy, renderItem, ...props}) { getMenuProps, getItemProps, } = useSelect({items, ...props}) - const itemToString = props?.itemToString ?? defaultProps.itemToString + const itemToString = props?.itemToString ?? dropdownDefaultProps.itemToString renderSpy() diff --git a/src/hooks/useSelect/utils.ts b/src/hooks/useSelect/utils.ts index ede313748..a9e9ec170 100644 --- a/src/hooks/useSelect/utils.ts +++ b/src/hooks/useSelect/utils.ts @@ -1,8 +1,5 @@ import PropTypes from 'prop-types' -import { - commonDropdownPropTypes, - defaultProps as commonDefaultProps, -} from '../utils' +import {dropdownPropTypes, dropdownDefaultProps} from '../utils.dropdown' import {noop} from '../../utils' import {GetItemIndexByCharacterKeyOptions} from './types' @@ -35,13 +32,13 @@ export function getItemIndexByCharacterKey({ } const propTypes = { - ...commonDropdownPropTypes, + ...dropdownPropTypes, items: PropTypes.array.isRequired, isItemDisabled: PropTypes.func, } export const defaultProps = { - ...commonDefaultProps, + ...dropdownDefaultProps, isItemDisabled() { return false }, diff --git a/src/hooks/useSelect/utils/defaultProps.ts b/src/hooks/useSelect/utils/defaultProps.ts new file mode 100644 index 000000000..e206fe2c9 --- /dev/null +++ b/src/hooks/useSelect/utils/defaultProps.ts @@ -0,0 +1,8 @@ +import {dropdownDefaultProps} from '../../utils.dropdown' + +export const defaultProps = { + ...dropdownDefaultProps, + isItemDisabled() { + return false + }, +} diff --git a/src/hooks/useSelect/utils/getItemIndexByCharacterKey.ts b/src/hooks/useSelect/utils/getItemIndexByCharacterKey.ts new file mode 100644 index 000000000..612c35bb7 --- /dev/null +++ b/src/hooks/useSelect/utils/getItemIndexByCharacterKey.ts @@ -0,0 +1,29 @@ +import {GetItemIndexByCharacterKeyOptions} from '../types' + +export function getItemIndexByCharacterKey({ + keysSoFar, + highlightedIndex, + items, + itemToString, + isItemDisabled, +}: GetItemIndexByCharacterKeyOptions) { + const lowerCasedKeysSoFar = keysSoFar.toLowerCase() + + for (let index = 0; index < items.length; index++) { + // if we already have a search query in progress, we also consider the current highlighted item. + const offsetIndex = + (index + highlightedIndex + (keysSoFar.length < 2 ? 1 : 0)) % items.length + + const item = items[offsetIndex] + + if ( + item !== undefined && + itemToString(item).toLowerCase().startsWith(lowerCasedKeysSoFar) && + !isItemDisabled(item, offsetIndex) + ) { + return offsetIndex + } + } + + return highlightedIndex +} diff --git a/src/hooks/useSelect/utils/index.ts b/src/hooks/useSelect/utils/index.ts new file mode 100644 index 000000000..2e35b2ab2 --- /dev/null +++ b/src/hooks/useSelect/utils/index.ts @@ -0,0 +1,3 @@ +export {propTypes} from './propTypes' +export {defaultProps} from './defaultProps' +export {getItemIndexByCharacterKey} from './getItemIndexByCharacterKey' diff --git a/src/hooks/useSelect/utils/propTypes.ts b/src/hooks/useSelect/utils/propTypes.ts new file mode 100644 index 000000000..229ed8c50 --- /dev/null +++ b/src/hooks/useSelect/utils/propTypes.ts @@ -0,0 +1,8 @@ +import PropTypes from 'prop-types' +import {dropdownPropTypes} from '../../utils.dropdown' + +export const propTypes = { + ...dropdownPropTypes, + items: PropTypes.array.isRequired, + isItemDisabled: PropTypes.func, +} diff --git a/src/hooks/useTagGroup/index.ts b/src/hooks/useTagGroup/index.ts index 2c6610d11..9bf4fa7b2 100644 --- a/src/hooks/useTagGroup/index.ts +++ b/src/hooks/useTagGroup/index.ts @@ -1,16 +1,14 @@ import {useEffect, useCallback, useRef} from 'react' -import {callAllEventHandlers, handleRefs, useLatestRef} from '../../utils-ts' -import {useIsInitialMount} from '../utils-ts' -// @ts-expect-error: can't import it otherwise. -import {isReactNative} from '../../is.macro' import { - useElementIds, + callAllEventHandlers, + handleRefs, + useLatestRef, validatePropTypes, - useControlledReducer, - getInitialState, - isTagGroupStateEqual, -} from './utils' +} from '../../utils-ts' +// @ts-expect-error: can't import it otherwise. +import {isReactNative} from '../../is.macro' +import {useControlledReducer, useIsInitialMount} from '../utils-ts' import * as stateChangeTypes from './stateChangeTypes' import { GetTagGroupProps, @@ -26,13 +24,14 @@ import { UseTagGroupStateChangeTypes, } from './index.types' import {useTagGroupReducer} from './reducer' +import {getInitialState, isStateEqual, propTypes, useElementIds} from './utils' useTagGroup.stateChangeTypes = stateChangeTypes export default function useTagGroup( userProps: Partial> = {}, ): UseTagGroupReturnValue { - validatePropTypes(userProps, useTagGroup) + validatePropTypes(userProps, useTagGroup, propTypes) // Props defaults and destructuring. const defaultProps: Pick< UseTagGroupProps, @@ -54,7 +53,7 @@ export default function useTagGroup( UseTagGroupProps, UseTagGroupStateChangeTypes, UseTagGroupReducerAction - >(useTagGroupReducer, props, getInitialState, isTagGroupStateEqual) + >(useTagGroupReducer, props, getInitialState, isStateEqual) const {activeIndex, items} = state // utility callback to get item element. const latest = useLatestRef({state, props}) diff --git a/src/hooks/useTagGroup/index.types.ts b/src/hooks/useTagGroup/index.types.ts index 4609a3b19..3bb89c956 100644 --- a/src/hooks/useTagGroup/index.types.ts +++ b/src/hooks/useTagGroup/index.types.ts @@ -1,7 +1,7 @@ import {Overwrite} from '../../../typings' -import {Action} from './utils.types' +import {Action, State} from '../../utils-ts' -export interface UseTagGroupState extends Record { +export interface UseTagGroupState extends State { activeIndex: number items: Item[] } diff --git a/src/hooks/useTagGroup/reducer.ts b/src/hooks/useTagGroup/reducer.ts index 83de7dba3..5f05201d0 100644 --- a/src/hooks/useTagGroup/reducer.ts +++ b/src/hooks/useTagGroup/reducer.ts @@ -1,8 +1,13 @@ -import {UseTagGroupReducerAction, UseTagGroupState} from './index.types' +import { + UseTagGroupProps, + UseTagGroupReducerAction, + UseTagGroupState, +} from './index.types' import * as stateChangeTypes from './stateChangeTypes' export function useTagGroupReducer( state: UseTagGroupState, + _props: UseTagGroupProps, action: UseTagGroupReducerAction, ): UseTagGroupState { const {type} = action @@ -88,7 +93,7 @@ export function useTagGroupReducer( changes = { items: newItems, - activeIndex: newActiveIndex + activeIndex: newActiveIndex, } break } diff --git a/src/hooks/useTagGroup/utils.ts b/src/hooks/useTagGroup/utils.ts deleted file mode 100644 index 564d2d055..000000000 --- a/src/hooks/useTagGroup/utils.ts +++ /dev/null @@ -1,267 +0,0 @@ -import * as React from 'react' -import PropTypes from 'prop-types' - -import {generateId} from '../../utils-ts' -import {noop} from '../../utils' -import { - Action, - Props, - State, - UseElementIdsProps, - UseElementIdsReturnValue, -} from './utils.types' -import {UseTagGroupProps, UseTagGroupState} from './index.types' - -const propTypes = { - isItemDisabled: PropTypes.func, -} - -// eslint-disable-next-line import/no-mutable-exports -export let validatePropTypes = noop as ( - options: unknown, - caller: Function, -) => void -/* istanbul ignore next */ -if (process.env.NODE_ENV !== 'production') { - validatePropTypes = (options: unknown, caller: Function): void => { - PropTypes.checkPropTypes(propTypes, options, 'prop', caller.name) - } -} - -// istanbul ignore next -export const useElementIds: ( - props: UseElementIdsProps, -) => UseElementIdsReturnValue = - 'useId' in React // Avoid conditional useId call - ? useElementIdsR18 - : useElementIdsLegacy - -function useElementIdsR18({ - id, - tagGroupId, - getTagId, -}: UseElementIdsProps): UseElementIdsReturnValue { - // Avoid conditional useId call - const reactId = `downshift-${React.useId()}` - if (!id) { - id = reactId - } - - const elementIdsRef = React.useRef({ - tagGroupId: tagGroupId ?? `${id}-tag-group`, - getTagId: getTagId ?? (index => `${id}-tag-${index}`), - }) - - return elementIdsRef.current -} - -function useElementIdsLegacy({ - id = `downshift-${generateId()}`, - getTagId, - tagGroupId, -}: UseElementIdsProps): UseElementIdsReturnValue { - const elementIdsRef = React.useRef({ - tagGroupId: tagGroupId ?? `${id}-menu`, - getTagId: getTagId ?? (index => `${id}-item-${index}`), - }) - - return elementIdsRef.current -} - -// probably to move - -export function getState< - S extends State, - P extends Partial & Props, - T, ->(state: S, props?: P): S { - if (!props) { - return state - } - - const keys = Object.keys(state) as (keyof S)[] - - return keys.reduce( - (newState, key) => { - if (props[key] !== undefined) { - newState[key] = props[key] as S[typeof key] - } - return newState - }, - {...state}, - ) -} - -/** - * Wraps the useEnhancedReducer and applies the controlled prop values before - * returning the new state. - * - * @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. - */ -export function useControlledReducer< - S extends State, - P extends Partial & Props, - T, - A extends Action, ->( - reducer: (state: S, action: A) => S, - props: P, - createInitialState: (props: P) => S, - isStateEqual: (prevState: S, newState: S) => boolean, -): [S, (action: A) => void] { - const [state, dispatch] = useEnhancedReducer( - reducer, - props, - createInitialState, - isStateEqual, - ) - - return [getState(state, props), dispatch] -} - -/** - * 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. - */ -export function useEnhancedReducer< - S extends State, - P extends Partial & Props, - T, - A extends Action, ->( - reducer: (state: S, action: A) => S, - props: P, - createInitialState: (props: P) => S, - isStateEqual: (prevState: S, newState: S) => boolean, -): [S, (action: A) => void] { - const prevStateRef = React.useRef(null) - const actionRef = React.useRef() - const propsRef = useLatestRef(props) - - const enhancedReducer = React.useCallback( - (state: S, action: A): S => { - actionRef.current = action - state = getState(state, propsRef.current) - - const changes = reducer(state, action) - const newState = propsRef.current.stateReducer(state, { - ...action, - changes, - }) - - return {...state, ...newState} - }, - [propsRef, reducer], - ) - const [state, dispatch] = React.useReducer( - enhancedReducer, - props, - createInitialState, - ) - - const action = actionRef.current - - React.useEffect(() => { - const prevState = getState( - prevStateRef.current ?? ({} as S), - propsRef.current, - ) - const shouldCallOnChangeProps = - action && prevStateRef.current && !isStateEqual(prevState, state) - - if (shouldCallOnChangeProps) { - callOnChangeProps(action, propsRef.current, prevState, state) - } - - prevStateRef.current = state - }, [state, action, isStateEqual, propsRef]) - - return [getState(state, props), dispatch] -} - -function useLatestRef(val: T) { - const ref = React.useRef(val) - // technically this is not "concurrent mode safe" because we're manipulating - // the value during render (so it's not idempotent). However, the places this - // hook is used is to support memoizing callbacks which will be called - // *during* render, so we need the latest values *during* render. - // If not for this, then we'd probably want to use useLayoutEffect instead. - ref.current = val - return ref -} - -function callOnChangeProps< - S extends State, - P extends Partial & Props, - T, ->(action: Action, props: P, state: S, newState: S) { - const {type} = action - const changes: Partial = {} - const keys = Object.keys(state) - - for (const key of keys) { - invokeOnChangeHandler(key, action, props, state, newState) - - if (newState[key] !== state[key]) { - changes[key] = newState[key] - } - } - - if (props.onStateChange && 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) { - const {type} = action - const handlerKey = `on${capitalizeString(key)}Change` - - if (typeof props[handlerKey] === 'function' && newState[key] !== state[key]) { - props[handlerKey]({type, ...newState}) - } -} - -export function getInitialState( - props: UseTagGroupProps, -): UseTagGroupState { - const items = props.items ?? props.initialItems ?? props.defaultItems ?? [] - const activeIndex = - props.activeIndex ?? - props.initialActiveIndex ?? - props.defaultActiveIndex ?? - (items.length === 0 ? -1 : items.length - 1) - - return { - activeIndex, - items, - } -} - -export function isTagGroupStateEqual( - oldState: UseTagGroupState, - newState: UseTagGroupState, -): boolean { - return ( - oldState.activeIndex === newState.activeIndex && - oldState.items === newState.items - ) -} - -function capitalizeString(string: string): string { - return `${string.slice(0, 1).toUpperCase()}${string.slice(1)}` -} diff --git a/src/hooks/useTagGroup/utils.types.ts b/src/hooks/useTagGroup/utils.types.ts deleted file mode 100644 index 2cc9b5ad1..000000000 --- a/src/hooks/useTagGroup/utils.types.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {UseTagGroupProps} from './index.types' - -export type UseElementIdsProps = Pick< - UseTagGroupProps, - 'id' | 'getTagId' | 'tagGroupId' -> -export type UseElementIdsReturnValue = Required< - Pick, 'getTagId' | 'tagGroupId'> -> - -export type State = Record - -export interface Props { - onStateChange?(typeAndChanges: unknown): void - stateReducer( - state: S, - actionAndChanges: Action & {changes: Partial}, - ): Partial -} - -export interface Action extends Record { - type: T -} diff --git a/src/hooks/useTagGroup/utils/getInitialState.ts b/src/hooks/useTagGroup/utils/getInitialState.ts new file mode 100644 index 000000000..3f0a706ad --- /dev/null +++ b/src/hooks/useTagGroup/utils/getInitialState.ts @@ -0,0 +1,25 @@ +import {UseTagGroupProps, UseTagGroupState} from '../index.types' + +export type UseElementIdsProps = Pick< + UseTagGroupProps, + 'id' | 'getTagId' | 'tagGroupId' +> +export type UseElementIdsReturnValue = Required< + Pick, 'getTagId' | 'tagGroupId'> +> + +export function getInitialState( + props: UseTagGroupProps, +): UseTagGroupState { + const items = props.items ?? props.initialItems ?? props.defaultItems ?? [] + const activeIndex = + props.activeIndex ?? + props.initialActiveIndex ?? + props.defaultActiveIndex ?? + (items.length === 0 ? -1 : items.length - 1) + + return { + activeIndex, + items, + } +} diff --git a/src/hooks/useTagGroup/utils/index.ts b/src/hooks/useTagGroup/utils/index.ts new file mode 100644 index 000000000..796f35328 --- /dev/null +++ b/src/hooks/useTagGroup/utils/index.ts @@ -0,0 +1,16 @@ +import PropTypes from 'prop-types' + +export {useElementIds} from './useElementIds' +export { + getInitialState, + type UseElementIdsProps, + type UseElementIdsReturnValue, +} from './getInitialState' +export {isStateEqual} from './isStateEqual' + +export const propTypes: Record< + string, + PropTypes.Requireable<(...args: unknown[]) => unknown> +> = { + isItemDisabled: PropTypes.func, +} diff --git a/src/hooks/useTagGroup/utils/isStateEqual.ts b/src/hooks/useTagGroup/utils/isStateEqual.ts new file mode 100644 index 000000000..307e55e48 --- /dev/null +++ b/src/hooks/useTagGroup/utils/isStateEqual.ts @@ -0,0 +1,11 @@ +import {UseTagGroupState} from '../index.types' + +export function isStateEqual( + oldState: UseTagGroupState, + newState: UseTagGroupState, +): boolean { + return ( + oldState.activeIndex === newState.activeIndex && + oldState.items === newState.items + ) +} diff --git a/src/hooks/useTagGroup/utils/useElementIds.ts b/src/hooks/useTagGroup/utils/useElementIds.ts new file mode 100644 index 000000000..37932ee36 --- /dev/null +++ b/src/hooks/useTagGroup/utils/useElementIds.ts @@ -0,0 +1,44 @@ +import * as React from 'react' + +import {generateId} from '../../../utils-ts' +import {UseElementIdsProps, UseElementIdsReturnValue} from '.' + +// istanbul ignore next +export const useElementIds: ( + props: UseElementIdsProps, +) => UseElementIdsReturnValue = + 'useId' in React // Avoid conditional useId call + ? useElementIdsR18 + : useElementIdsLegacy + +function useElementIdsR18({ + id, + tagGroupId, + getTagId, +}: UseElementIdsProps): UseElementIdsReturnValue { + // Avoid conditional useId call + const reactId = `downshift-${React.useId()}` + if (!id) { + id = reactId + } + + const elementIdsRef = React.useRef({ + tagGroupId: tagGroupId ?? `${id}-tag-group`, + getTagId: getTagId ?? (index => `${id}-tag-${index}`), + }) + + return elementIdsRef.current +} + +function useElementIdsLegacy({ + id = `downshift-${generateId()}`, + getTagId, + tagGroupId, +}: UseElementIdsProps): UseElementIdsReturnValue { + const elementIdsRef = React.useRef({ + tagGroupId: tagGroupId ?? `${id}-menu`, + getTagId: getTagId ?? (index => `${id}-item-${index}`), + }) + + return elementIdsRef.current +} diff --git a/src/hooks/utils-ts/callOnChangeProps.ts b/src/hooks/utils-ts/callOnChangeProps.ts new file mode 100644 index 000000000..dee36c885 --- /dev/null +++ b/src/hooks/utils-ts/callOnChangeProps.ts @@ -0,0 +1,37 @@ +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) { + const {type} = action + const changes: Partial = {} + const keys = Object.keys(state) + + for (const key of keys) { + invokeOnChangeHandler(key, action, props, state, newState) + + if (newState[key] !== state[key]) { + changes[key] = newState[key] + } + } + + if (props.onStateChange && 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) { + const {type} = action + const handlerKey = `on${capitalizeString(key)}Change` + + if (typeof props[handlerKey] === 'function' && newState[key] !== state[key]) { + props[handlerKey]({type, ...newState}) + } +} diff --git a/src/hooks/utils-ts/capitalizeString.ts b/src/hooks/utils-ts/capitalizeString.ts new file mode 100644 index 000000000..033d20981 --- /dev/null +++ b/src/hooks/utils-ts/capitalizeString.ts @@ -0,0 +1,3 @@ +export function capitalizeString(string: string): string { + return `${string.slice(0, 1).toUpperCase()}${string.slice(1)}` +} diff --git a/src/hooks/utils-ts/getDefaultValue.ts b/src/hooks/utils-ts/getDefaultValue.ts new file mode 100644 index 000000000..6aa3bd009 --- /dev/null +++ b/src/hooks/utils-ts/getDefaultValue.ts @@ -0,0 +1,16 @@ +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] +} diff --git a/src/hooks/utils-ts/getInitialValue.ts b/src/hooks/utils-ts/getInitialValue.ts new file mode 100644 index 000000000..a9e761b31 --- /dev/null +++ b/src/hooks/utils-ts/getInitialValue.ts @@ -0,0 +1,23 @@ +import {State} from '../../utils-ts' +import {capitalizeString} from './capitalizeString' +import {getDefaultValue} from '.' + +export function getInitialValue>( + props: P, + propKey: keyof S, + defaultStateValues: S, +): S[keyof S] { + const value = props[propKey] as keyof S | undefined + + if (value !== undefined) { + return value as S[keyof S] + } + + const initialValue = props[`initial${capitalizeString(propKey as string)}`] + + if (initialValue !== undefined) { + return initialValue as S[keyof S] + } + + return getDefaultValue(props, propKey, defaultStateValues) +} diff --git a/src/hooks/utils-ts.ts b/src/hooks/utils-ts/getItemAndIndex.ts similarity index 75% rename from src/hooks/utils-ts.ts rename to src/hooks/utils-ts/getItemAndIndex.ts index 213c4bfb3..719afb70d 100644 --- a/src/hooks/utils-ts.ts +++ b/src/hooks/utils-ts/getItemAndIndex.ts @@ -1,5 +1,3 @@ -import * as React from 'react' - /** * Returns both the item and index when both or either is passed. * @@ -40,20 +38,3 @@ export function getItemAndIndex( throw new Error(errorMessage) } - -/** - * Tracks if it's the first render. - */ -export function useIsInitialMount(): boolean { - const isInitialMountRef = React.useRef(true) - - React.useEffect(() => { - isInitialMountRef.current = false - - return () => { - isInitialMountRef.current = true - } - }, []) - - return isInitialMountRef.current -} \ No newline at end of file diff --git a/src/hooks/utils-ts/index.ts b/src/hooks/utils-ts/index.ts new file mode 100644 index 000000000..9f4c66895 --- /dev/null +++ b/src/hooks/utils-ts/index.ts @@ -0,0 +1,11 @@ +export {useControlledReducer} from './useControlledReducer' +export {useEnhancedReducer} from './useEnhancedReducer' +export {callOnChangeProps} from './callOnChangeProps' +export {getItemAndIndex} from './getItemAndIndex' +export {useIsInitialMount} from './useIsInitialMount' +export {stateReducer} from './stateReducer' +export {propTypes as commonPropTypes} from './propTypes' +export {capitalizeString} from './capitalizeString' +export {getDefaultValue} from './getDefaultValue' +export {getInitialValue} from './getInitialValue' +export {useA11yMessageStatus} from './useA11yMessageStatus' diff --git a/src/hooks/utils-ts/propTypes.ts b/src/hooks/utils-ts/propTypes.ts new file mode 100644 index 000000000..c424483b6 --- /dev/null +++ b/src/hooks/utils-ts/propTypes.ts @@ -0,0 +1,18 @@ +import PropTypes from 'prop-types' + +// Shared between all exports. +export const propTypes = { + environment: PropTypes.shape({ + addEventListener: PropTypes.func.isRequired, + removeEventListener: PropTypes.func.isRequired, + document: PropTypes.shape({ + createElement: PropTypes.func.isRequired, + getElementById: PropTypes.func.isRequired, + activeElement: PropTypes.any.isRequired, + body: PropTypes.any.isRequired, + }).isRequired, + Node: PropTypes.func.isRequired, + }), + itemToKey: PropTypes.func, + stateReducer: PropTypes.func, +} diff --git a/src/hooks/utils-ts/stateReducer.ts b/src/hooks/utils-ts/stateReducer.ts new file mode 100644 index 000000000..1fedcb71d --- /dev/null +++ b/src/hooks/utils-ts/stateReducer.ts @@ -0,0 +1,9 @@ +import {Action, State} from '../../utils-ts' + +/** + * Default state reducer that returns the changes. + * + */ +export function stateReducer(_s: State, a: Action) { + return a.changes +} diff --git a/src/hooks/utils-ts/useA11yMessageStatus.ts b/src/hooks/utils-ts/useA11yMessageStatus.ts new file mode 100644 index 000000000..da5861303 --- /dev/null +++ b/src/hooks/utils-ts/useA11yMessageStatus.ts @@ -0,0 +1,51 @@ +import * as React from 'react' + +// @ts-expect-error: can't import it otherwise. +import {isReactNative} from '../../is.macro' +import {cleanupStatusDiv, debounce, setStatus} from '../../utils-ts' +import {useIsInitialMount} from '.' + +/** + * Debounced call for updating the a11y message. + */ +const updateA11yStatus = debounce((status: string, document: Document) => { + setStatus(status, document) +}, 200) + +/** + * Adds an a11y aria live status message if getA11yStatusMessage is passed. + * @param getA11yStatusMessage The function that builds the status message. + * @param options The options to be passed to getA11yStatusMessage if called. + * @param dependencyArray The dependency array that triggers the status message setter via useEffect. + * @param environment The environment object containing the document. + */ +export function useA11yMessageStatus( + getA11yStatusMessage: ((options: Options) => string) | undefined, + options: Options, + dependencyArray: unknown[], + environment: {document: Document | undefined} | undefined, +) { + const document = environment?.document + const isInitialMount = useIsInitialMount() + + // Adds an a11y aria live status message if getA11yStatusMessage is passed. + React.useEffect(() => { + if (!getA11yStatusMessage || isInitialMount || isReactNative || !document) { + return + } + + const status = getA11yStatusMessage(options) + + updateA11yStatus(status, document) + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, dependencyArray) + + // Cleanup the status message container. + React.useEffect(() => { + return () => { + updateA11yStatus.cancel() + cleanupStatusDiv(document) + } + }, [document]) +} diff --git a/src/hooks/utils-ts/useControlledReducer.ts b/src/hooks/utils-ts/useControlledReducer.ts new file mode 100644 index 000000000..b63aaea6a --- /dev/null +++ b/src/hooks/utils-ts/useControlledReducer.ts @@ -0,0 +1,33 @@ +import {getState, type Action, type State, type Props} from '../../utils-ts' +import {useEnhancedReducer} from '.' + +/** + * Wraps the useEnhancedReducer and applies the controlled prop values before + * returning the new state. + * + * @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. + */ +export function useControlledReducer< + 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, +): [S, (action: A) => void] { + const [state, dispatch] = useEnhancedReducer( + reducer, + props, + createInitialState, + isStateEqual, + ) + + return [getState(state, props), dispatch] +} diff --git a/src/hooks/utils-ts/useEnhancedReducer.ts b/src/hooks/utils-ts/useEnhancedReducer.ts new file mode 100644 index 000000000..db3346286 --- /dev/null +++ b/src/hooks/utils-ts/useEnhancedReducer.ts @@ -0,0 +1,77 @@ +import * as React from 'react' + +import { + type Action, + type Props, + type State, + getState, + useLatestRef, +} from '../../utils-ts' +import {callOnChangeProps} from '.' + +/** + * 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. + */ +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, +): [S, (action: A) => void] { + const prevStateRef = React.useRef(null) + const actionRef = React.useRef() + const propsRef = useLatestRef(props) + + const enhancedReducer = React.useCallback( + (state: S, action: A): S => { + actionRef.current = action + state = getState(state, propsRef.current) + + const changes = reducer(state, propsRef.current, action) + const newState = propsRef.current.stateReducer(state, { + ...action, + changes, + }) + + return {...state, ...newState} + }, + [propsRef, reducer], + ) + const [state, dispatch] = React.useReducer( + enhancedReducer, + props, + createInitialState, + ) + + const action = actionRef.current + + React.useEffect(() => { + const prevState = getState( + prevStateRef.current ?? ({} as S), + propsRef.current, + ) + const shouldCallOnChangeProps = + action && prevStateRef.current && !isStateEqual(prevState, state) + + if (shouldCallOnChangeProps) { + callOnChangeProps(action, propsRef.current, prevState, state) + } + + prevStateRef.current = state + }, [state, action, isStateEqual, propsRef]) + + return [state, dispatch] +} diff --git a/src/hooks/utils-ts/useIsInitialMount.ts b/src/hooks/utils-ts/useIsInitialMount.ts new file mode 100644 index 000000000..68ef5559a --- /dev/null +++ b/src/hooks/utils-ts/useIsInitialMount.ts @@ -0,0 +1,18 @@ +import * as React from 'react' + +/** + * Tracks if it's the first render. + */ +export function useIsInitialMount(): boolean { + const isInitialMountRef = React.useRef(true) + + React.useEffect(() => { + isInitialMountRef.current = false + + return () => { + isInitialMountRef.current = true + } + }, []) + + return isInitialMountRef.current +} diff --git a/src/hooks/utils.dropdown/defaultProps.ts b/src/hooks/utils.dropdown/defaultProps.ts new file mode 100644 index 000000000..a4e32dafa --- /dev/null +++ b/src/hooks/utils.dropdown/defaultProps.ts @@ -0,0 +1,18 @@ +// @ts-expect-error: can't import it otherwise. +import {isReactNative} from '../../is.macro' +import {scrollIntoView} from '../../utils-ts' +import {stateReducer} from '../utils-ts' + +export const defaultProps = { + itemToString(item: unknown) { + return item ? String(item) : '' + }, + itemToKey(item: unknown) { + return item + }, + stateReducer, + scrollIntoView, + environment: + /* istanbul ignore next (ssr) */ + typeof window === 'undefined' || isReactNative ? undefined : window, +} diff --git a/src/hooks/utils.dropdown/defaultStateValues.ts b/src/hooks/utils.dropdown/defaultStateValues.ts new file mode 100644 index 000000000..0cd17127a --- /dev/null +++ b/src/hooks/utils.dropdown/defaultStateValues.ts @@ -0,0 +1,6 @@ +export const defaultStateValues = { + highlightedIndex: -1, + isOpen: false, + selectedItem: null as unknown, + inputValue: '', +} diff --git a/src/hooks/utils.dropdown/index.ts b/src/hooks/utils.dropdown/index.ts new file mode 100644 index 000000000..05c0ed4b1 --- /dev/null +++ b/src/hooks/utils.dropdown/index.ts @@ -0,0 +1,3 @@ +export {propTypes as dropdownPropTypes} from './propTypes' +export {defaultProps as dropdownDefaultProps} from './defaultProps' +export {defaultStateValues as dropdownDefaultStateValues} from './defaultStateValues' diff --git a/src/hooks/utils.dropdown/propTypes.ts b/src/hooks/utils.dropdown/propTypes.ts new file mode 100644 index 000000000..338748077 --- /dev/null +++ b/src/hooks/utils.dropdown/propTypes.ts @@ -0,0 +1,28 @@ +import PropTypes from 'prop-types' + +import {commonPropTypes} from '../utils-ts' + +// Shared between useSelect, useCombobox, Downshift. +export const propTypes = { + ...commonPropTypes, + getA11yStatusMessage: PropTypes.func, + highlightedIndex: PropTypes.number, + defaultHighlightedIndex: PropTypes.number, + initialHighlightedIndex: PropTypes.number, + isOpen: PropTypes.bool, + defaultIsOpen: PropTypes.bool, + initialIsOpen: PropTypes.bool, + selectedItem: PropTypes.any, + initialSelectedItem: PropTypes.any, + defaultSelectedItem: PropTypes.any, + id: PropTypes.string, + labelId: PropTypes.string, + menuId: PropTypes.string, + getItemId: PropTypes.func, + toggleButtonId: PropTypes.string, + onSelectedItemChange: PropTypes.func, + onHighlightedIndexChange: PropTypes.func, + onStateChange: PropTypes.func, + onIsOpenChange: PropTypes.func, + scrollIntoView: PropTypes.func, +} diff --git a/src/hooks/utils.js b/src/hooks/utils.js index a50316388..00ab75050 100644 --- a/src/hooks/utils.js +++ b/src/hooks/utils.js @@ -1,78 +1,13 @@ -import React, { - useRef, - useCallback, - useReducer, - useEffect, - useLayoutEffect, - useMemo, -} from 'react' -import PropTypes from 'prop-types' +import React, {useRef, useCallback, useEffect, useLayoutEffect} from 'react' import {isReactNative} from '../is.macro' import { - scrollIntoView, - getState, - debounce, validateControlledUnchanged, noop, targetWithinDownshift, } from '../utils' -import {cleanupStatusDiv, setStatus} from '../set-a11y-status' -import { generateId } from '../utils-ts' -import { useIsInitialMount } from './utils-ts' - -const dropdownDefaultStateValues = { - highlightedIndex: -1, - isOpen: false, - selectedItem: null, - inputValue: '', -} - -function callOnChangeProps(action, state, newState) { - const {props, type} = action - const changes = {} - - Object.keys(state).forEach(key => { - invokeOnChangeHandler(key, action, state, newState) - - if (newState[key] !== state[key]) { - changes[key] = newState[key] - } - }) - - if (props.onStateChange && Object.keys(changes).length) { - props.onStateChange({type, ...changes}) - } -} - -function invokeOnChangeHandler(key, action, state, newState) { - const {props, type} = action - const handler = `on${capitalizeString(key)}Change` - if ( - props[handler] && - newState[key] !== undefined && - newState[key] !== state[key] - ) { - props[handler]({type, ...newState}) - } -} - -/** - * Default state reducer that returns the changes. - * - * @param {Object} s state. - * @param {Object} a action with changes. - * @returns {Object} changes. - */ -function stateReducer(s, a) { - return a.changes -} - -/** - * Debounced call for updating the a11y message. - */ -const updateA11yStatus = debounce((status, document) => { - setStatus(status, document) -}, 200) +import {generateId} from '../utils-ts' +import {useIsInitialMount, getDefaultValue, getInitialValue} from './utils-ts' +import {dropdownDefaultStateValues} from './utils.dropdown' // istanbul ignore next const useIsomorphicLayoutEffect = @@ -138,154 +73,20 @@ function isAcceptedCharacterKey(key) { return /^\S{1}$/.test(key) } -function capitalizeString(string) { - return `${string.slice(0, 1).toUpperCase()}${string.slice(1)}` -} - -function useLatestRef(val) { - const ref = useRef(val) - // technically this is not "concurrent mode safe" because we're manipulating - // the value during render (so it's not idempotent). However, the places this - // hook is used is to support memoizing callbacks which will be called - // *during* render, so we need the latest values *during* render. - // If not for this, then we'd probably want to use useLayoutEffect instead. - ref.current = val - return ref -} - -/** - * 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. - */ -function useEnhancedReducer(reducer, props, createInitialState, isStateEqual) { - const prevStateRef = useRef() - const actionRef = useRef() - const enhancedReducer = useCallback( - (state, action) => { - actionRef.current = action - state = getState(state, action.props) - - const changes = reducer(state, action) - const newState = action.props.stateReducer(state, {...action, changes}) - - return newState - }, - [reducer], - ) - const [state, dispatch] = useReducer( - enhancedReducer, +function getInitialState(props) { + const selectedItem = getInitialValue( props, - createInitialState, - ) - const propsRef = useLatestRef(props) - const dispatchWithProps = useCallback( - action => dispatch({props: propsRef.current, ...action}), - [propsRef], + 'selectedItem', + dropdownDefaultStateValues, ) - const action = actionRef.current - - useEffect(() => { - const prevState = getState(prevStateRef.current, action?.props) - const shouldCallOnChangeProps = - action && prevStateRef.current && !isStateEqual(prevState, state) - - if (shouldCallOnChangeProps) { - callOnChangeProps(action, prevState, state) - } - - prevStateRef.current = state - }, [state, action, isStateEqual]) - - return [state, dispatchWithProps] -} - -/** - * Wraps the useEnhancedReducer and applies the controlled prop values before - * returning the new state. - * - * @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. - */ -function useControlledReducer( - reducer, - props, - createInitialState, - isStateEqual, -) { - const [state, dispatch] = useEnhancedReducer( - reducer, + const isOpen = getInitialValue(props, 'isOpen', dropdownDefaultStateValues) + const highlightedIndex = getInitialHighlightedIndex(props) + const inputValue = getInitialValue( props, - createInitialState, - isStateEqual, + 'inputValue', + dropdownDefaultStateValues, ) - return [getState(state, props), dispatch] -} - -const defaultProps = { - itemToString(item) { - return item ? String(item) : '' - }, - itemToKey(item) { - return item - }, - stateReducer, - scrollIntoView, - environment: - /* istanbul ignore next (ssr) */ - typeof window === 'undefined' || isReactNative ? undefined : window, -} - -function getDefaultValue( - props, - propKey, - defaultStateValues = dropdownDefaultStateValues, -) { - const defaultValue = props[`default${capitalizeString(propKey)}`] - - if (defaultValue !== undefined) { - return defaultValue - } - - return defaultStateValues[propKey] -} - -function getInitialValue( - props, - propKey, - defaultStateValues = dropdownDefaultStateValues, -) { - const value = props[propKey] - - if (value !== undefined) { - return value - } - - const initialValue = props[`initial${capitalizeString(propKey)}`] - - if (initialValue !== undefined) { - return initialValue - } - - return getDefaultValue(props, propKey, defaultStateValues) -} - -function getInitialState(props) { - const selectedItem = getInitialValue(props, 'selectedItem') - const isOpen = getInitialValue(props, 'isOpen') - const highlightedIndex = getInitialHighlightedIndex(props) - const inputValue = getInitialValue(props, 'inputValue') - return { highlightedIndex: highlightedIndex < 0 && selectedItem && isOpen @@ -491,44 +292,6 @@ if (process.env.NODE_ENV !== 'production') { } } -/** - * Adds an a11y aria live status message if getA11yStatusMessage is passed. - * @param {(options: Object) => string} getA11yStatusMessage The function that builds the status message. - * @param {Object} options The options to be passed to getA11yStatusMessage if called. - * @param {Array} dependencyArray The dependency array that triggers the status message setter via useEffect. - * @param {{document: Document}} environment The environment object containing the document. - */ -function useA11yMessageStatus( - getA11yStatusMessage, - options, - dependencyArray, - environment = {}, -) { - const document = environment.document - const isInitialMount = useIsInitialMount() - - // Adds an a11y aria live status message if getA11yStatusMessage is passed. - useEffect(() => { - if (!getA11yStatusMessage || isInitialMount || isReactNative || !document) { - return - } - - const status = getA11yStatusMessage(options) - - updateA11yStatus(status, document) - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, dependencyArray) - - // Cleanup the status message container. - useEffect(() => { - return () => { - updateA11yStatus.cancel() - cleanupStatusDiv(document) - } - }, [document]) -} - function useScrollIntoView({ highlightedIndex, isOpen, @@ -596,8 +359,12 @@ function getChangesOnSelection(props, highlightedIndex, inputValue = true) { highlightedIndex: -1, ...(shouldSelect && { selectedItem: props.items[highlightedIndex], - isOpen: getDefaultValue(props, 'isOpen'), - highlightedIndex: getDefaultValue(props, 'highlightedIndex'), + isOpen: getDefaultValue(props, 'isOpen', dropdownDefaultStateValues), + highlightedIndex: getDefaultValue( + props, + 'highlightedIndex', + dropdownDefaultStateValues, + ), ...(inputValue && { inputValue: props.itemToString(props.items[highlightedIndex]), }), @@ -629,7 +396,11 @@ function isDropdownsStateEqual(prevState, newState) { * @returns {number} The highlighted index. */ function getDefaultHighlightedIndex(props) { - const highlightedIndex = getDefaultValue(props, 'highlightedIndex') + const highlightedIndex = getDefaultValue( + props, + 'highlightedIndex', + dropdownDefaultStateValues, + ) if ( highlightedIndex > -1 && props.isItemDisabled(props.items[highlightedIndex], highlightedIndex) @@ -647,7 +418,11 @@ function getDefaultHighlightedIndex(props) { * @returns {number} The highlighted index. */ function getInitialHighlightedIndex(props) { - const highlightedIndex = getInitialValue(props, 'highlightedIndex') + const highlightedIndex = getInitialValue( + props, + 'highlightedIndex', + dropdownDefaultStateValues, + ) if ( highlightedIndex > -1 && @@ -659,70 +434,16 @@ function getInitialHighlightedIndex(props) { return highlightedIndex } -// Shared between all exports. -const commonPropTypes = { - environment: PropTypes.shape({ - addEventListener: PropTypes.func.isRequired, - removeEventListener: PropTypes.func.isRequired, - document: PropTypes.shape({ - createElement: PropTypes.func.isRequired, - getElementById: PropTypes.func.isRequired, - activeElement: PropTypes.any.isRequired, - body: PropTypes.any.isRequired, - }).isRequired, - Node: PropTypes.func.isRequired, - }), - itemToString: PropTypes.func, - itemToKey: PropTypes.func, - stateReducer: PropTypes.func, -} - -// Shared between useSelect, useCombobox, Downshift. -const commonDropdownPropTypes = { - ...commonPropTypes, - getA11yStatusMessage: PropTypes.func, - highlightedIndex: PropTypes.number, - defaultHighlightedIndex: PropTypes.number, - initialHighlightedIndex: PropTypes.number, - isOpen: PropTypes.bool, - defaultIsOpen: PropTypes.bool, - initialIsOpen: PropTypes.bool, - selectedItem: PropTypes.any, - initialSelectedItem: PropTypes.any, - defaultSelectedItem: PropTypes.any, - id: PropTypes.string, - labelId: PropTypes.string, - menuId: PropTypes.string, - getItemId: PropTypes.func, - toggleButtonId: PropTypes.string, - onSelectedItemChange: PropTypes.func, - onHighlightedIndexChange: PropTypes.func, - onStateChange: PropTypes.func, - onIsOpenChange: PropTypes.func, - scrollIntoView: PropTypes.func, -} - export { useControlPropsValidator, useScrollIntoView, - updateA11yStatus, useGetterPropsCalledChecker, useMouseAndTouchTracker, getHighlightedIndexOnOpen, - getInitialState, - getInitialValue, - getDefaultValue, - defaultProps, - useControlledReducer, - useEnhancedReducer, - useLatestRef, - capitalizeString, isAcceptedCharacterKey, useElementIds, getChangesOnSelection, isDropdownsStateEqual, - commonDropdownPropTypes, - commonPropTypes, - useA11yMessageStatus, getDefaultHighlightedIndex, + getInitialState, } diff --git a/src/set-a11y-status.js b/src/set-a11y-status.js deleted file mode 100644 index 4ed4799d6..000000000 --- a/src/set-a11y-status.js +++ /dev/null @@ -1,64 +0,0 @@ -import {debounce} from './utils' - -const cleanupStatus = debounce(documentProp => { - getStatusDiv(documentProp).textContent = '' -}, 500) - -/** - * Get the status node or create it if it does not already exist. - * @param {Object} documentProp document passed by the user. - * @return {HTMLElement} the status node. - */ -function getStatusDiv(documentProp) { - let statusDiv = documentProp.getElementById('a11y-status-message') - if (statusDiv) { - return statusDiv - } - - // refactor this for the aria description - - statusDiv = documentProp.createElement('div') - statusDiv.setAttribute('id', 'a11y-status-message') - statusDiv.setAttribute('role', 'status') - statusDiv.setAttribute('aria-live', 'polite') - statusDiv.setAttribute('aria-relevant', 'additions text') - Object.assign(statusDiv.style, { - border: '0', - clip: 'rect(0 0 0 0)', - height: '1px', - margin: '-1px', - overflow: 'hidden', - padding: '0', - position: 'absolute', - width: '1px', - }) - documentProp.body.appendChild(statusDiv) - return statusDiv -} - -/** - * @param {String} status the status message - * @param {Object} documentProp document passed by the user. - */ -export function setStatus(status, documentProp) { - if (!status || !documentProp) { - return - } - - const div = getStatusDiv(documentProp) - - div.textContent = status - cleanupStatus(documentProp) -} - -/** - * Removes the status element from the DOM - * @param {Document} documentProp - */ -export function cleanupStatusDiv(documentProp) { - const statusDiv = documentProp?.getElementById('a11y-status-message') - - if (statusDiv) { - statusDiv.remove() - } -} diff --git a/src/utils-ts.ts b/src/utils-ts.ts deleted file mode 100644 index 69988c1de..000000000 --- a/src/utils-ts.ts +++ /dev/null @@ -1,91 +0,0 @@ -import * as React from 'react' - -let idCounter = 0 - -/** - * This generates a unique ID for an instance of Downshift - * @return {string} the unique ID - */ -export function generateId(): string { - return String(idCounter++) -} - -/** - * This is only used in tests - * @param {number} num the number to set the idCounter to - */ -export function setIdCounter(num: number): void { - idCounter = num -} - -/** - * Resets idCounter to 0. Used for SSR. - */ -export function resetIdCounter() { - // istanbul ignore next - if ('useId' in React) { - console.warn( - `It is not necessary to call resetIdCounter when using React 18+`, - ) - - return - } - - idCounter = 0 -} - -export function useLatestRef(val: T): React.MutableRefObject { - const ref = React.useRef(val) - // technically this is not "concurrent mode safe" because we're manipulating - // the value during render (so it's not idempotent). However, the places this - // hook is used is to support memoizing callbacks which will be called - // *during* render, so we need the latest values *during* render. - // If not for this, then we'd probably want to use useLayoutEffect instead. - ref.current = val - return ref -} - -export function handleRefs( - ...refs: ( - | React.MutableRefObject - | React.RefCallback - | undefined - )[] -) { - return (node: HTMLElement) => { - refs.forEach(ref => { - if (typeof ref === 'function') { - ref(node) - } else if (ref) { - ref.current = node - } - }) - } -} - -/** - * This is intended to be used to compose event handlers. - * They are executed in order until one of them sets - * `event.preventDownshiftDefault = true`. - * @param fns the event handler functions - * @return the event handler to add to an element - */ -export function callAllEventHandlers(...fns: (Function | undefined)[]) { - return ( - event: React.SyntheticEvent & { - preventDownshiftDefault?: boolean - nativeEvent: {preventDownshiftDefault?: boolean} - }, - ...args: unknown[] - ) => - fns.some(fn => { - if (fn) { - fn(event, ...args) - } - return ( - event.preventDownshiftDefault || - (event.hasOwnProperty('nativeEvent') && - event.nativeEvent.preventDownshiftDefault) - ) - }) -} diff --git a/src/utils-ts/callAllEventHandlers.ts b/src/utils-ts/callAllEventHandlers.ts new file mode 100644 index 000000000..4e4116a78 --- /dev/null +++ b/src/utils-ts/callAllEventHandlers.ts @@ -0,0 +1,26 @@ +/** + * This is intended to be used to compose event handlers. + * They are executed in order until one of them sets + * `event.preventDownshiftDefault = true`. + * @param fns the event handler functions + * @return the event handler to add to an element + */ +export function callAllEventHandlers(...fns: (Function | undefined)[]) { + return ( + event: React.SyntheticEvent & { + preventDownshiftDefault?: boolean + nativeEvent: {preventDownshiftDefault?: boolean} + }, + ...args: unknown[] + ) => + fns.some(fn => { + if (fn) { + fn(event, ...args) + } + return ( + event.preventDownshiftDefault || + (event.hasOwnProperty('nativeEvent') && + event.nativeEvent.preventDownshiftDefault) + ) + }) +} diff --git a/src/utils-ts/debounce.ts b/src/utils-ts/debounce.ts new file mode 100644 index 000000000..6f2fccf55 --- /dev/null +++ b/src/utils-ts/debounce.ts @@ -0,0 +1,29 @@ +/** + * Simple debounce implementation. Will call the given + * function once after the time given has passed since + * it was last called. + */ +export function debounce( + fn: Function, + time: number, +): Function & {cancel: Function} { + let timeoutId: NodeJS.Timeout | undefined | null + + function cancel() { + if (timeoutId) { + clearTimeout(timeoutId) + } + } + + function wrapper(...args: unknown[]) { + cancel() + timeoutId = setTimeout(() => { + timeoutId = null + fn(...args) + }, time) + } + + wrapper.cancel = cancel + + return wrapper +} diff --git a/src/utils-ts/generateId.ts b/src/utils-ts/generateId.ts new file mode 100644 index 000000000..9d4a94024 --- /dev/null +++ b/src/utils-ts/generateId.ts @@ -0,0 +1,35 @@ +import * as React from 'react' + +let idCounter = 0 + +/** + * This generates a unique ID for an instance of Downshift + * @return {string} the unique ID + */ +export function generateId(): string { + return String(idCounter++) +} + +/** + * This is only used in tests + * @param {number} num the number to set the idCounter to + */ +export function setIdCounter(num: number): void { + idCounter = num +} + +/** + * Resets idCounter to 0. Used for SSR. + */ +export function resetIdCounter() { + // istanbul ignore next + if ('useId' in React) { + console.warn( + `It is not necessary to call resetIdCounter when using React 18+`, + ) + + return + } + + idCounter = 0 +} diff --git a/src/utils-ts/getState.ts b/src/utils-ts/getState.ts new file mode 100644 index 000000000..d31701e00 --- /dev/null +++ b/src/utils-ts/getState.ts @@ -0,0 +1,35 @@ +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 +} + +export function getState< + S extends State, + P extends Partial & Props, + T, +>(state: S, props?: P): S { + if (!props) { + return state + } + + const keys = Object.keys(state) as (keyof S)[] + + return keys.reduce( + (newState, key) => { + if (props[key] !== undefined) { + newState[key] = props[key] as S[typeof key] + } + return newState + }, + {...state}, + ) +} diff --git a/src/utils-ts/index.ts b/src/utils-ts/index.ts new file mode 100644 index 000000000..3f1c0ae7c --- /dev/null +++ b/src/utils-ts/index.ts @@ -0,0 +1,10 @@ +export {generateId, setIdCounter, resetIdCounter} from './generateId' +export {useLatestRef, handleRefs} from './useLatestRef' +export {callAllEventHandlers} from './callAllEventHandlers' +export {debounce} from './debounce' +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' diff --git a/src/utils-ts/noop.ts b/src/utils-ts/noop.ts new file mode 100644 index 000000000..177804c7a --- /dev/null +++ b/src/utils-ts/noop.ts @@ -0,0 +1 @@ +export function noop() {} diff --git a/src/utils-ts/scrollIntoView.ts b/src/utils-ts/scrollIntoView.ts new file mode 100644 index 000000000..f61c2a41f --- /dev/null +++ b/src/utils-ts/scrollIntoView.ts @@ -0,0 +1,25 @@ +import {compute} from 'compute-scroll-into-view' + +/** + * Scroll node into view if necessary + * @param {HTMLElement} node the element that should scroll into view + * @param {HTMLElement} menuNode the menu element of the component + */ +export function scrollIntoView( + node: HTMLElement | undefined, + menuNode: HTMLElement | undefined, +) { + if (!node) { + return + } + + const actions = compute(node, { + boundary: menuNode, + block: 'nearest', + scrollMode: 'if-needed', + }) + actions.forEach(({el, top, left}) => { + el.scrollTop = top + el.scrollLeft = left + }) +} diff --git a/src/utils-ts/setA11yStatus.ts b/src/utils-ts/setA11yStatus.ts new file mode 100644 index 000000000..1c3f25bd8 --- /dev/null +++ b/src/utils-ts/setA11yStatus.ts @@ -0,0 +1,59 @@ +import {debounce} from '.' + +const cleanupStatus = debounce((document: Document) => { + getStatusDiv(document).textContent = '' +}, 500) + +/** + * Get the status node or create it if it does not already exist. + */ +function getStatusDiv(document: Document) { + let statusDiv = document.getElementById('a11y-status-message') + if (statusDiv) { + return statusDiv + } + + statusDiv = document.createElement('div') + statusDiv.setAttribute('id', 'a11y-status-message') + statusDiv.setAttribute('role', 'status') + statusDiv.setAttribute('aria-live', 'polite') + statusDiv.setAttribute('aria-relevant', 'additions text') + Object.assign(statusDiv.style, { + border: '0', + clip: 'rect(0 0 0 0)', + height: '1px', + margin: '-1px', + overflow: 'hidden', + padding: '0', + position: 'absolute', + width: '1px', + }) + + document.body.appendChild(statusDiv) + return statusDiv +} + +/** + * Sets aria live status to a div element that's visually hidden. + */ +export function setStatus(status: string, document: Document | undefined) { + if (!status || !document) { + return + } + + const div = getStatusDiv(document) + + div.textContent = status + cleanupStatus(document) +} + +/** + * Removes the status element from the DOM + */ +export function cleanupStatusDiv(document: Document | undefined) { + const statusDiv = document?.getElementById('a11y-status-message') + + if (statusDiv) { + statusDiv.remove() + } +} diff --git a/src/utils-ts/useLatestRef.ts b/src/utils-ts/useLatestRef.ts new file mode 100644 index 000000000..638381e67 --- /dev/null +++ b/src/utils-ts/useLatestRef.ts @@ -0,0 +1,30 @@ +import * as React from 'react' + +export function useLatestRef(val: T): React.MutableRefObject { + const ref = React.useRef(val) + // technically this is not "concurrent mode safe" because we're manipulating + // the value during render (so it's not idempotent). However, the places this + // hook is used is to support memoizing callbacks which will be called + // *during* render, so we need the latest values *during* render. + // If not for this, then we'd probably want to use useLayoutEffect instead. + ref.current = val + return ref +} + +export function handleRefs( + ...refs: ( + | React.MutableRefObject + | React.RefCallback + | undefined + )[] +) { + return (node: HTMLElement) => { + refs.forEach(ref => { + if (typeof ref === 'function') { + ref(node) + } else if (ref) { + ref.current = node + } + }) + } +} diff --git a/src/utils-ts/validatePropTypes.ts b/src/utils-ts/validatePropTypes.ts new file mode 100644 index 000000000..9f82137df --- /dev/null +++ b/src/utils-ts/validatePropTypes.ts @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types' +import {noop} from '.' + +// eslint-disable-next-line import/no-mutable-exports +export let validatePropTypes = noop as ( + options: unknown, + caller: Function, + propTypes: Record< + string, + PropTypes.Requireable<(...args: unknown[]) => unknown> + >, +) => void +/* istanbul ignore next */ +if (process.env.NODE_ENV !== 'production') { + validatePropTypes = ( + options: unknown, + caller: Function, + propTypes: Record< + string, + PropTypes.Requireable<(...args: unknown[]) => unknown> + >, + ): void => { + PropTypes.checkPropTypes(propTypes, options, 'prop', caller.name) + } +} diff --git a/src/utils.js b/src/utils.js index 693506580..21a93c885 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,4 +1,3 @@ -import {compute} from 'compute-scroll-into-view' import {isPreact} from './is.macro' /** @@ -15,27 +14,6 @@ function cbToCb(cb) { function noop() {} -/** - * Scroll node into view if necessary - * @param {HTMLElement} node the element that should scroll into view - * @param {HTMLElement} menuNode the menu element of the component - */ -function scrollIntoView(node, menuNode) { - if (!node) { - return - } - - const actions = compute(node, { - boundary: menuNode, - block: 'nearest', - scrollMode: 'if-needed', - }) - actions.forEach(({el, top, left}) => { - el.scrollTop = top - el.scrollLeft = left - }) -} - /** * @param {HTMLElement} parent the parent node * @param {HTMLElement} child the child node @@ -441,7 +419,6 @@ export { callAllEventHandlers, handleRefs, debounce, - scrollIntoView, getA11yStatusMessage, unwrapArray, isDOMElement, From 3b2a688d2c74ddd9ec54e6d9ef9c99edfcb96dde Mon Sep 17 00:00:00 2001 From: Silviu Alexandu Avram Date: Tue, 12 Aug 2025 09:55:34 +0300 Subject: [PATCH 14/40] fix build ts errors --- src/hooks/useTagGroup/index.ts | 5 +++-- src/hooks/useTagGroup/stateChangeTypes.ts | 4 +++- src/hooks/utils-ts/callOnChangeProps.ts | 14 +++++++++++--- src/hooks/utils-ts/useA11yMessageStatus.ts | 5 +++-- src/hooks/utils.dropdown/defaultProps.ts | 5 +++-- src/utils-ts/getState.ts | 2 +- 6 files changed, 24 insertions(+), 11 deletions(-) diff --git a/src/hooks/useTagGroup/index.ts b/src/hooks/useTagGroup/index.ts index 9bf4fa7b2..1339f2b0e 100644 --- a/src/hooks/useTagGroup/index.ts +++ b/src/hooks/useTagGroup/index.ts @@ -6,8 +6,6 @@ import { useLatestRef, validatePropTypes, } from '../../utils-ts' -// @ts-expect-error: can't import it otherwise. -import {isReactNative} from '../../is.macro' import {useControlledReducer, useIsInitialMount} from '../utils-ts' import * as stateChangeTypes from './stateChangeTypes' import { @@ -26,6 +24,9 @@ import { import {useTagGroupReducer} from './reducer' import {getInitialState, isStateEqual, propTypes, useElementIds} from './utils' +// eslint-disable-next-line +const { isReactNative } = require('../../is.macro.js'); + useTagGroup.stateChangeTypes = stateChangeTypes export default function useTagGroup( diff --git a/src/hooks/useTagGroup/stateChangeTypes.ts b/src/hooks/useTagGroup/stateChangeTypes.ts index 034d250af..c533c1193 100644 --- a/src/hooks/useTagGroup/stateChangeTypes.ts +++ b/src/hooks/useTagGroup/stateChangeTypes.ts @@ -1,6 +1,8 @@ -import productionEnum from '../../productionEnum.macro' import {UseTagGroupStateChangeTypes} from './index.types' +// eslint-disable-next-line +const productionEnum = require('../../productionEnum.macro'); + export const TagClick: UseTagGroupStateChangeTypes.TagClick = productionEnum('__tag_click__') diff --git a/src/hooks/utils-ts/callOnChangeProps.ts b/src/hooks/utils-ts/callOnChangeProps.ts index dee36c885..f755892c3 100644 --- a/src/hooks/utils-ts/callOnChangeProps.ts +++ b/src/hooks/utils-ts/callOnChangeProps.ts @@ -28,10 +28,18 @@ function invokeOnChangeHandler< P extends Partial & Props, T, >(key: string, action: Action, props: P, state: S, newState: S) { - const {type} = action + if (newState[key] === state[key]) { + return + } + const handlerKey = `on${capitalizeString(key)}Change` + const handler = props[handlerKey] - if (typeof props[handlerKey] === 'function' && newState[key] !== state[key]) { - props[handlerKey]({type, ...newState}) + if (typeof handler !== 'function') { + return } + + const {type} = action + + handler({type, ...newState}) } diff --git a/src/hooks/utils-ts/useA11yMessageStatus.ts b/src/hooks/utils-ts/useA11yMessageStatus.ts index da5861303..fbfcdd1df 100644 --- a/src/hooks/utils-ts/useA11yMessageStatus.ts +++ b/src/hooks/utils-ts/useA11yMessageStatus.ts @@ -1,10 +1,11 @@ import * as React from 'react' -// @ts-expect-error: can't import it otherwise. -import {isReactNative} from '../../is.macro' import {cleanupStatusDiv, debounce, setStatus} from '../../utils-ts' import {useIsInitialMount} from '.' +// eslint-disable-next-line +const { isReactNative } = require('../../is.macro.js'); + /** * Debounced call for updating the a11y message. */ diff --git a/src/hooks/utils.dropdown/defaultProps.ts b/src/hooks/utils.dropdown/defaultProps.ts index a4e32dafa..5005685b4 100644 --- a/src/hooks/utils.dropdown/defaultProps.ts +++ b/src/hooks/utils.dropdown/defaultProps.ts @@ -1,8 +1,9 @@ -// @ts-expect-error: can't import it otherwise. -import {isReactNative} from '../../is.macro' import {scrollIntoView} from '../../utils-ts' import {stateReducer} from '../utils-ts' +// eslint-disable-next-line +const { isReactNative } = require('../../is.macro.js'); + export const defaultProps = { itemToString(item: unknown) { return item ? String(item) : '' diff --git a/src/utils-ts/getState.ts b/src/utils-ts/getState.ts index d31701e00..e9a594992 100644 --- a/src/utils-ts/getState.ts +++ b/src/utils-ts/getState.ts @@ -26,7 +26,7 @@ export function getState< return keys.reduce( (newState, key) => { if (props[key] !== undefined) { - newState[key] = props[key] as S[typeof key] + newState[key] = (props as Partial)[key] as S[typeof key] } return newState }, From 379077a93c2b39f12557bc8e58f71f0584f22b58 Mon Sep 17 00:00:00 2001 From: Silviu Alexandu Avram Date: Wed, 13 Aug 2025 10:03:39 +0300 Subject: [PATCH 15/40] fix state change types in taggroup --- src/hooks/useTagGroup/index.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/hooks/useTagGroup/index.ts b/src/hooks/useTagGroup/index.ts index 1339f2b0e..0d551c228 100644 --- a/src/hooks/useTagGroup/index.ts +++ b/src/hooks/useTagGroup/index.ts @@ -92,22 +92,22 @@ export default function useTagGroup( switch (e.key) { case 'ArrowLeft': dispatch({ - type: UseTagGroupStateChangeTypes.TagGroupKeyDownArrowLeft, + type: stateChangeTypes.TagGroupKeyDownArrowLeft, }) break case 'ArrowRight': dispatch({ - type: UseTagGroupStateChangeTypes.TagGroupKeyDownArrowRight, + type: stateChangeTypes.TagGroupKeyDownArrowRight, }) break case 'Delete': dispatch({ - type: UseTagGroupStateChangeTypes.TagGroupKeyDownDelete, + type: stateChangeTypes.TagGroupKeyDownDelete, }) break case 'Backspace': dispatch({ - type: UseTagGroupStateChangeTypes.TagGroupKeyDownBackspace, + type: stateChangeTypes.TagGroupKeyDownBackspace, }) break default: @@ -138,7 +138,7 @@ export default function useTagGroup( const latestState = latest.current.state const handleClick = () => { - dispatch({type: UseTagGroupStateChangeTypes.TagClick, index}) + dispatch({type: stateChangeTypes.TagClick, index}) } const id = elementIds.getTagId(index) @@ -166,7 +166,7 @@ export default function useTagGroup( const handleClick = (event: React.MouseEvent) => { event.stopPropagation() - dispatch({type: UseTagGroupStateChangeTypes.TagRemoveClick, index}) + dispatch({type: stateChangeTypes.TagRemoveClick, index}) } const tagId = elementIds.getTagId(index) @@ -185,7 +185,7 @@ export default function useTagGroup( const addItem = useCallback['addItem']>( (item, index): void => { - dispatch({type: UseTagGroupStateChangeTypes.FunctionAddItem, item, index}) + dispatch({type: stateChangeTypes.FunctionAddItem, item, index}) }, [dispatch], ) From 0d10f5598879f283eb1865f7a9ca81b359aea4c5 Mon Sep 17 00:00:00 2001 From: Silviu Alexandu Avram Date: Fri, 15 Aug 2025 19:07:33 +0300 Subject: [PATCH 16/40] some more import fixes --- src/downshift.js | 4 +- .../utils.use-element-ids.r18.test.js | 1 + .../__tests__/utils.use-element-ids.test.js | 1 + .../__tests__/getInputProps.test.js | 2 +- .../__tests__/getMenuProps.test.js | 2 +- src/hooks/useCombobox/index.js | 6 +- src/hooks/useCombobox/testUtils.js | 2 +- src/hooks/useCombobox/utils.js | 14 +---- src/hooks/useMultipleSelection/testUtils.js | 2 +- src/hooks/useMultipleSelection/utils.js | 2 +- .../__tests__/getToggleButtonProps.test.js | 2 +- src/hooks/useSelect/index.js | 6 +- src/hooks/useSelect/testUtils.js | 2 +- src/hooks/useSelect/utils.ts | 57 ------------------- .../utils.use-element-ids.r18.test.ts | 23 ++++++++ .../__tests__/utils.use-element-ids.test.ts | 23 ++++++++ src/hooks/useTagGroup/utils/useElementIds.ts | 4 +- src/hooks/utils.js | 8 +-- src/utils-ts/getState.ts | 11 ++++ src/utils.js | 28 +-------- 20 files changed, 81 insertions(+), 119 deletions(-) delete mode 100644 src/hooks/useSelect/utils.ts create mode 100644 src/hooks/useTagGroup/utils/__tests__/utils.use-element-ids.r18.test.ts create mode 100644 src/hooks/useTagGroup/utils/__tests__/utils.use-element-ids.test.ts diff --git a/src/downshift.js b/src/downshift.js index 4b59c006b..0b78b5ecf 100644 --- a/src/downshift.js +++ b/src/downshift.js @@ -15,18 +15,16 @@ import { isDOMElement, targetWithinDownshift, isPlainObject, - noop, normalizeArrowKey, pickState, requiredProp, unwrapArray, - getState, isControlledProp, validateControlledUnchanged, getHighlightedIndex, getNonDisabledIndex, } from './utils' -import {generateId, scrollIntoView, setStatus} from './utils-ts' +import {generateId, scrollIntoView, setStatus, getState, noop} from './utils-ts' class Downshift extends Component { static propTypes = { diff --git a/src/hooks/__tests__/utils.use-element-ids.r18.test.js b/src/hooks/__tests__/utils.use-element-ids.r18.test.js index 6353219eb..2e76f231b 100644 --- a/src/hooks/__tests__/utils.use-element-ids.r18.test.js +++ b/src/hooks/__tests__/utils.use-element-ids.r18.test.js @@ -21,5 +21,6 @@ describe('useElementIds', () => { menuId: 'downshift-mocked-id-menu', toggleButtonId: 'downshift-mocked-id-toggle-button', }) + expect(result.current.getItemId(5)).toEqual('downshift-mocked-id-item-5') }) }) diff --git a/src/hooks/__tests__/utils.use-element-ids.test.js b/src/hooks/__tests__/utils.use-element-ids.test.js index 32f58a23d..bbc58cc9b 100644 --- a/src/hooks/__tests__/utils.use-element-ids.test.js +++ b/src/hooks/__tests__/utils.use-element-ids.test.js @@ -21,5 +21,6 @@ describe('useElementIds', () => { menuId: 'downshift-test-id-menu', toggleButtonId: 'downshift-test-id-toggle-button', }) + expect(result.current.getItemId(5)).toEqual("downshift-test-id-item-5") }) }) diff --git a/src/hooks/useCombobox/__tests__/getInputProps.test.js b/src/hooks/useCombobox/__tests__/getInputProps.test.js index 50e55b0e7..187fcbf6d 100644 --- a/src/hooks/useCombobox/__tests__/getInputProps.test.js +++ b/src/hooks/useCombobox/__tests__/getInputProps.test.js @@ -1,7 +1,7 @@ import * as React from 'react' import {act, renderHook, fireEvent, createEvent} from '@testing-library/react' import * as stateChangeTypes from '../stateChangeTypes' -import {noop} from '../../../utils' +import {noop} from '../../../utils-ts' import { renderUseCombobox, renderCombobox, diff --git a/src/hooks/useCombobox/__tests__/getMenuProps.test.js b/src/hooks/useCombobox/__tests__/getMenuProps.test.js index 36437c7ba..d723ea383 100644 --- a/src/hooks/useCombobox/__tests__/getMenuProps.test.js +++ b/src/hooks/useCombobox/__tests__/getMenuProps.test.js @@ -1,5 +1,5 @@ import {act, renderHook} from '@testing-library/react' -import {noop} from '../../../utils' +import {noop} from '../../../utils-ts' import {getInput, renderCombobox, renderUseCombobox} from '../testUtils' import { defaultIds, diff --git a/src/hooks/useCombobox/index.js b/src/hooks/useCombobox/index.js index 41c999fa8..1d5370903 100644 --- a/src/hooks/useCombobox/index.js +++ b/src/hooks/useCombobox/index.js @@ -1,7 +1,7 @@ import {useRef, useEffect, useCallback, useMemo} from 'react' import {isPreact, isReactNative, isReactNativeWeb} from '../../is.macro' import {handleRefs, normalizeArrowKey, callAllEventHandlers} from '../../utils' -import {useLatestRef} from '../../utils-ts' +import {useLatestRef, validatePropTypes} from '../../utils-ts' import { useMouseAndTouchTracker, useGetterPropsCalledChecker, @@ -21,7 +21,7 @@ import { getInitialState, defaultProps, useControlledReducer, - validatePropTypes, + propTypes, } from './utils' import downshiftUseComboboxReducer from './reducer' import * as stateChangeTypes from './stateChangeTypes' @@ -29,7 +29,7 @@ import * as stateChangeTypes from './stateChangeTypes' useCombobox.stateChangeTypes = stateChangeTypes function useCombobox(userProps = {}) { - validatePropTypes(userProps, useCombobox) + validatePropTypes(userProps, useCombobox, propTypes) // Props defaults and destructuring. const props = { ...defaultProps, diff --git a/src/hooks/useCombobox/testUtils.js b/src/hooks/useCombobox/testUtils.js index fc5f2ab2b..3e86ac840 100644 --- a/src/hooks/useCombobox/testUtils.js +++ b/src/hooks/useCombobox/testUtils.js @@ -18,7 +18,7 @@ jest.mock('react', () => { jest.mock('../utils', () => { const utils = jest.requireActual('../utils') - const hooksUtils = jest.requireActual('../../utils') + const hooksUtils = jest.requireActual('../../utils-ts') return { ...utils, diff --git a/src/hooks/useCombobox/utils.js b/src/hooks/useCombobox/utils.js index cd154de45..884e10fcf 100644 --- a/src/hooks/useCombobox/utils.js +++ b/src/hooks/useCombobox/utils.js @@ -1,6 +1,7 @@ import {useRef, useEffect} from 'react' import PropTypes from 'prop-types' -import {isControlledProp, getState, noop} from '../../utils' +import {isControlledProp} from '../../utils' +import {getState} from '../../utils-ts' import {getInitialState as getInitialStateCommon} from '../utils' import {dropdownDefaultProps, dropdownPropTypes} from '../utils.dropdown' import {useIsInitialMount, useEnhancedReducer} from '../utils-ts' @@ -27,7 +28,7 @@ export function getInitialState(props) { } } -const propTypes = { +export const propTypes = { ...dropdownPropTypes, items: PropTypes.array.isRequired, isItemDisabled: PropTypes.func, @@ -96,15 +97,6 @@ export function useControlledReducer( return [getState(state, props), dispatch] } -// eslint-disable-next-line import/no-mutable-exports -export let validatePropTypes = noop -/* istanbul ignore next */ -if (process.env.NODE_ENV !== 'production') { - validatePropTypes = (options, caller) => { - PropTypes.checkPropTypes(propTypes, options, 'prop', caller.name) - } -} - export const defaultProps = { ...dropdownDefaultProps, isItemDisabled() { diff --git a/src/hooks/useMultipleSelection/testUtils.js b/src/hooks/useMultipleSelection/testUtils.js index 86db7417c..e9c4a9ffc 100644 --- a/src/hooks/useMultipleSelection/testUtils.js +++ b/src/hooks/useMultipleSelection/testUtils.js @@ -22,7 +22,7 @@ jest.mock('react', () => { jest.mock('../utils', () => { const utils = jest.requireActual('../utils') - const hooksUtils = jest.requireActual('../../utils') + const hooksUtils = jest.requireActual('../../utils-ts') return { ...utils, diff --git a/src/hooks/useMultipleSelection/utils.js b/src/hooks/useMultipleSelection/utils.js index e28bb80d6..3deb27e45 100644 --- a/src/hooks/useMultipleSelection/utils.js +++ b/src/hooks/useMultipleSelection/utils.js @@ -1,6 +1,6 @@ import PropTypes from 'prop-types' -import {noop} from '../../utils' +import {noop} from '../../utils-ts' import { getInitialValue as getInitialValueCommon, getDefaultValue as getDefaultValueCommon, diff --git a/src/hooks/useSelect/__tests__/getToggleButtonProps.test.js b/src/hooks/useSelect/__tests__/getToggleButtonProps.test.js index 3d494a8cf..82637c484 100644 --- a/src/hooks/useSelect/__tests__/getToggleButtonProps.test.js +++ b/src/hooks/useSelect/__tests__/getToggleButtonProps.test.js @@ -6,7 +6,7 @@ import { screen, renderHook, } from '@testing-library/react' -import {noop} from '../../../utils' +import {noop} from '../../../utils-ts' import { renderUseSelect, renderSelect, diff --git a/src/hooks/useSelect/index.js b/src/hooks/useSelect/index.js index 0c236e092..be24372d4 100644 --- a/src/hooks/useSelect/index.js +++ b/src/hooks/useSelect/index.js @@ -1,5 +1,5 @@ import {useRef, useEffect, useCallback, useMemo} from 'react' -import {useLatestRef} from '../../utils-ts' +import {useLatestRef, validatePropTypes} from '../../utils-ts' import { callAllEventHandlers, handleRefs, @@ -25,13 +25,13 @@ import { import {defaultStateValues} from '../utils.dropdown/defaultStateValues' import {isReactNative, isReactNativeWeb} from '../../is.macro' import downshiftSelectReducer from './reducer' -import {validatePropTypes, defaultProps} from './utils' +import {defaultProps, propTypes} from './utils' import * as stateChangeTypes from './stateChangeTypes' useSelect.stateChangeTypes = stateChangeTypes function useSelect(userProps = {}) { - validatePropTypes(userProps, useSelect) + validatePropTypes(userProps, useSelect, propTypes) // Props defaults and destructuring. const props = { ...defaultProps, diff --git a/src/hooks/useSelect/testUtils.js b/src/hooks/useSelect/testUtils.js index ef29b47df..8d5a37b5a 100644 --- a/src/hooks/useSelect/testUtils.js +++ b/src/hooks/useSelect/testUtils.js @@ -18,7 +18,7 @@ export * from '../testUtils' jest.mock('../utils', () => { const utils = jest.requireActual('../utils') - const hooksUtils = jest.requireActual('../../utils') + const hooksUtils = jest.requireActual('../../utils-ts') return { ...utils, diff --git a/src/hooks/useSelect/utils.ts b/src/hooks/useSelect/utils.ts deleted file mode 100644 index a9e9ec170..000000000 --- a/src/hooks/useSelect/utils.ts +++ /dev/null @@ -1,57 +0,0 @@ -import PropTypes from 'prop-types' -import {dropdownPropTypes, dropdownDefaultProps} from '../utils.dropdown' -import {noop} from '../../utils' -import {GetItemIndexByCharacterKeyOptions} from './types' - -export function getItemIndexByCharacterKey({ - keysSoFar, - highlightedIndex, - items, - itemToString, - isItemDisabled, -}: GetItemIndexByCharacterKeyOptions) { - const lowerCasedKeysSoFar = keysSoFar.toLowerCase() - - for (let index = 0; index < items.length; index++) { - // if we already have a search query in progress, we also consider the current highlighted item. - const offsetIndex = - (index + highlightedIndex + (keysSoFar.length < 2 ? 1 : 0)) % items.length - - const item = items[offsetIndex] - - if ( - item !== undefined && - itemToString(item).toLowerCase().startsWith(lowerCasedKeysSoFar) && - !isItemDisabled(item, offsetIndex) - ) { - return offsetIndex - } - } - - return highlightedIndex -} - -const propTypes = { - ...dropdownPropTypes, - items: PropTypes.array.isRequired, - isItemDisabled: PropTypes.func, -} - -export const defaultProps = { - ...dropdownDefaultProps, - isItemDisabled() { - return false - }, -} - -// eslint-disable-next-line import/no-mutable-exports -export let validatePropTypes = noop as ( - options: unknown, - caller: Function, -) => void -/* istanbul ignore next */ -if (process.env.NODE_ENV !== 'production') { - validatePropTypes = (options: unknown, caller: Function): void => { - PropTypes.checkPropTypes(propTypes, options, 'prop', caller.name) - } -} diff --git a/src/hooks/useTagGroup/utils/__tests__/utils.use-element-ids.r18.test.ts b/src/hooks/useTagGroup/utils/__tests__/utils.use-element-ids.r18.test.ts new file mode 100644 index 000000000..b606c90a3 --- /dev/null +++ b/src/hooks/useTagGroup/utils/__tests__/utils.use-element-ids.r18.test.ts @@ -0,0 +1,23 @@ +import {renderHook} from '@testing-library/react' +import {useElementIds} from '..' + +jest.mock('react', () => { + return { + ...jest.requireActual('react'), + useId() { + return 'mocked-id' + }, + } +}) + +describe('useElementIds', () => { + test('uses React.useId for React >= 18', () => { + const {result} = renderHook(() => useElementIds({})) + + expect(result.current).toEqual({ + getTagId: expect.any(Function), + tagGroupId: 'downshift-mocked-id-tag-group', + }) + expect(result.current.getTagId(10)).toEqual("downshift-mocked-id-tag-10") + }) +}) diff --git a/src/hooks/useTagGroup/utils/__tests__/utils.use-element-ids.test.ts b/src/hooks/useTagGroup/utils/__tests__/utils.use-element-ids.test.ts new file mode 100644 index 000000000..12ea31c4e --- /dev/null +++ b/src/hooks/useTagGroup/utils/__tests__/utils.use-element-ids.test.ts @@ -0,0 +1,23 @@ +import {renderHook} from '@testing-library/react' +import {useElementIds} from '..' + +jest.mock('react', () => { + const {useId, ...react} = jest.requireActual('react') + return react +}) + +jest.mock('../../../../utils-ts', () => ({ + generateId: jest.fn().mockReturnValue('test-id'), +})) + +describe('useElementIds', () => { + test('uses React.useId for React < 18', () => { + const {result} = renderHook(() => useElementIds({})) + + expect(result.current).toEqual({ + getTagId: expect.any(Function), + tagGroupId: 'downshift-test-id-tag-group', + }) + expect(result.current.getTagId(12)).toEqual('downshift-test-id-tag-12') + }) +}) diff --git a/src/hooks/useTagGroup/utils/useElementIds.ts b/src/hooks/useTagGroup/utils/useElementIds.ts index 37932ee36..f7abd19bd 100644 --- a/src/hooks/useTagGroup/utils/useElementIds.ts +++ b/src/hooks/useTagGroup/utils/useElementIds.ts @@ -36,8 +36,8 @@ function useElementIdsLegacy({ tagGroupId, }: UseElementIdsProps): UseElementIdsReturnValue { const elementIdsRef = React.useRef({ - tagGroupId: tagGroupId ?? `${id}-menu`, - getTagId: getTagId ?? (index => `${id}-item-${index}`), + tagGroupId: tagGroupId ?? `${id}-tag-group`, + getTagId: getTagId ?? (index => `${id}-tag-${index}`), }) return elementIdsRef.current diff --git a/src/hooks/utils.js b/src/hooks/utils.js index 00ab75050..4ad741d27 100644 --- a/src/hooks/utils.js +++ b/src/hooks/utils.js @@ -1,11 +1,7 @@ import React, {useRef, useCallback, useEffect, useLayoutEffect} from 'react' import {isReactNative} from '../is.macro' -import { - validateControlledUnchanged, - noop, - targetWithinDownshift, -} from '../utils' -import {generateId} from '../utils-ts' +import {validateControlledUnchanged, targetWithinDownshift} from '../utils' +import {generateId, noop} from '../utils-ts' import {useIsInitialMount, getDefaultValue, getInitialValue} from './utils-ts' import {dropdownDefaultStateValues} from './utils.dropdown' diff --git a/src/utils-ts/getState.ts b/src/utils-ts/getState.ts index e9a594992..f49720a5d 100644 --- a/src/utils-ts/getState.ts +++ b/src/utils-ts/getState.ts @@ -12,6 +12,17 @@ export interface Props { ): Partial } +/** + * This will perform a shallow merge of the given state object + * with the state coming from props + * (for the controlled component scenario) + * This is used in state updater functions so they're referencing + * the right state regardless of where it comes from. + * + * @param state The state of the component/hook. + * @param props The props that may contain controlled values. + * @returns The merged controlled state. + */ export function getState< S extends State, P extends Partial & Props, diff --git a/src/utils.js b/src/utils.js index 21a93c885..aa168abe5 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,4 +1,5 @@ import {isPreact} from './is.macro' +import { noop } from './utils-ts' /** * Accepts a parameter and returns it if it's a function @@ -12,8 +13,6 @@ function cbToCb(cb) { return typeof cb === 'function' ? cb : noop } -function noop() {} - /** * @param {HTMLElement} parent the parent node * @param {HTMLElement} child the child node @@ -198,29 +197,6 @@ function pickState(state = {}) { return result } -/** - * This will perform a shallow merge of the given state object - * with the state coming from props - * (for the controlled component scenario) - * This is used in state updater functions so they're referencing - * the right state regardless of where it comes from. - * - * @param {Object} state The state of the component/hook. - * @param {Object} props The props that may contain controlled values. - * @returns {Object} The merged controlled state. - */ -function getState(state, props) { - if (!state || !props) { - return state - } - - return Object.keys(state).reduce((prevState, key) => { - prevState[key] = isControlledProp(props, key) ? props[key] : state[key] - - return prevState - }, {}) -} - /** * This determines whether a prop is a "controlled prop" meaning it is * state which is controlled by the outside of this component rather @@ -423,13 +399,11 @@ export { unwrapArray, isDOMElement, getElementProps, - noop, requiredProp, pickState, isPlainObject, normalizeArrowKey, targetWithinDownshift, - getState, isControlledProp, validateControlledUnchanged, getHighlightedIndex, From 115fe660d23cbcaceadc039e4f0ca547fa2cc06b Mon Sep 17 00:00:00 2001 From: Silviu Alexandu Avram Date: Mon, 18 Aug 2025 10:23:53 +0300 Subject: [PATCH 17/40] move test utils to __tests__ --- .../__tests__/getTagGroupProps.test.ts | 2 +- .../useTagGroup/__tests__/getTagProps.test.ts | 2 +- .../__tests__/getTagRemoveProps.test.ts | 2 +- src/hooks/useTagGroup/__tests__/props.test.ts | 2 +- .../useTagGroup/__tests__/returnProps.test.ts | 2 +- .../useTagGroup/__tests__/utils/defaultIds.ts | 4 ++ .../__tests__/utils/defaultProps.ts | 20 ++++++ .../useTagGroup/__tests__/utils/index.ts | 16 +++++ .../utils/renderTagGroup.tsx} | 65 ++++--------------- .../__tests__/utils/renderUseTagGroup.ts | 14 ++++ 10 files changed, 73 insertions(+), 56 deletions(-) create mode 100644 src/hooks/useTagGroup/__tests__/utils/defaultIds.ts create mode 100644 src/hooks/useTagGroup/__tests__/utils/defaultProps.ts create mode 100644 src/hooks/useTagGroup/__tests__/utils/index.ts rename src/hooks/useTagGroup/{testUtils.tsx => __tests__/utils/renderTagGroup.tsx} (59%) create mode 100644 src/hooks/useTagGroup/__tests__/utils/renderUseTagGroup.ts diff --git a/src/hooks/useTagGroup/__tests__/getTagGroupProps.test.ts b/src/hooks/useTagGroup/__tests__/getTagGroupProps.test.ts index 8c56806f2..fcb7238d8 100644 --- a/src/hooks/useTagGroup/__tests__/getTagGroupProps.test.ts +++ b/src/hooks/useTagGroup/__tests__/getTagGroupProps.test.ts @@ -4,7 +4,7 @@ import { defaultProps, renderTagGroup, renderUseTagGroup, -} from '../testUtils' +} from './utils' describe('getTagGroupProps', () => { describe('hook props', () => { diff --git a/src/hooks/useTagGroup/__tests__/getTagProps.test.ts b/src/hooks/useTagGroup/__tests__/getTagProps.test.ts index b7a434669..3fdd62845 100644 --- a/src/hooks/useTagGroup/__tests__/getTagProps.test.ts +++ b/src/hooks/useTagGroup/__tests__/getTagProps.test.ts @@ -1,4 +1,4 @@ -import {renderTagGroup, renderUseTagGroup, defaultIds, act} from '../testUtils' +import {renderTagGroup, renderUseTagGroup, defaultIds, act} from './utils' describe('getTagProps', () => { describe('hook props', () => { diff --git a/src/hooks/useTagGroup/__tests__/getTagRemoveProps.test.ts b/src/hooks/useTagGroup/__tests__/getTagRemoveProps.test.ts index 9acdb7ab4..dca0c6bb4 100644 --- a/src/hooks/useTagGroup/__tests__/getTagRemoveProps.test.ts +++ b/src/hooks/useTagGroup/__tests__/getTagRemoveProps.test.ts @@ -5,7 +5,7 @@ import { act, defaultProps, screen, -} from '../testUtils' +} from './utils' describe('getTagRemoveProps', () => { describe('hook props', () => { diff --git a/src/hooks/useTagGroup/__tests__/props.test.ts b/src/hooks/useTagGroup/__tests__/props.test.ts index 94b416149..170c97145 100644 --- a/src/hooks/useTagGroup/__tests__/props.test.ts +++ b/src/hooks/useTagGroup/__tests__/props.test.ts @@ -1,4 +1,4 @@ -import {renderTagGroup} from '../testUtils' +import {renderTagGroup} from './utils' describe('props', () => { test('id if passed will override downshift default', () => { diff --git a/src/hooks/useTagGroup/__tests__/returnProps.test.ts b/src/hooks/useTagGroup/__tests__/returnProps.test.ts index 85f231897..fe1c0cbd3 100644 --- a/src/hooks/useTagGroup/__tests__/returnProps.test.ts +++ b/src/hooks/useTagGroup/__tests__/returnProps.test.ts @@ -1,5 +1,5 @@ import useTagGroup from '..' -import {renderUseTagGroup, act, defaultProps} from '../testUtils' +import {renderUseTagGroup, act, defaultProps} from './utils' import * as stateChangeTypes from '../stateChangeTypes' diff --git a/src/hooks/useTagGroup/__tests__/utils/defaultIds.ts b/src/hooks/useTagGroup/__tests__/utils/defaultIds.ts new file mode 100644 index 000000000..4c098d140 --- /dev/null +++ b/src/hooks/useTagGroup/__tests__/utils/defaultIds.ts @@ -0,0 +1,4 @@ +export const defaultIds = { + tagGroupId: 'downshift-test-id-tag-group', + getTagId: (index: number) => `downshift-test-id-tag-${index}`, +} diff --git a/src/hooks/useTagGroup/__tests__/utils/defaultProps.ts b/src/hooks/useTagGroup/__tests__/utils/defaultProps.ts new file mode 100644 index 000000000..6cebd668a --- /dev/null +++ b/src/hooks/useTagGroup/__tests__/utils/defaultProps.ts @@ -0,0 +1,20 @@ +const colors = [ + 'Black', + 'Red', + 'Green', + 'Blue', + 'Orange', + 'Purple', + 'Pink', + 'Orchid', + 'Aqua', + 'Lime', + 'Gray', + 'Brown', + 'Teal', + 'Skyblue', +] + +export const defaultProps = { + initialItems: colors.slice(0, 5), +} \ No newline at end of file diff --git a/src/hooks/useTagGroup/__tests__/utils/index.ts b/src/hooks/useTagGroup/__tests__/utils/index.ts new file mode 100644 index 000000000..8941595a3 --- /dev/null +++ b/src/hooks/useTagGroup/__tests__/utils/index.ts @@ -0,0 +1,16 @@ +export * from '@testing-library/react' + +// We are using React 18. +jest.mock('react', () => { + return { + ...jest.requireActual('react'), + useId() { + return 'test-id' + }, + } +}) + +export {defaultProps} from './defaultProps' +export {renderTagGroup} from './renderTagGroup' +export {renderUseTagGroup} from './renderUseTagGroup' +export {defaultIds} from './defaultIds' diff --git a/src/hooks/useTagGroup/testUtils.tsx b/src/hooks/useTagGroup/__tests__/utils/renderTagGroup.tsx similarity index 59% rename from src/hooks/useTagGroup/testUtils.tsx rename to src/hooks/useTagGroup/__tests__/utils/renderTagGroup.tsx index e1cead25d..51a62533e 100644 --- a/src/hooks/useTagGroup/testUtils.tsx +++ b/src/hooks/useTagGroup/__tests__/utils/renderTagGroup.tsx @@ -1,47 +1,11 @@ +/* eslint-disable testing-library/prefer-screen-queries */ import * as React from 'react' -import {render, renderHook} from '@testing-library/react' +import {render} from '@testing-library/react' import userEvent from '@testing-library/user-event' -import {UseTagGroupProps} from './index.types' -import useTagGroup from '.' - -export * from '@testing-library/react' - -// We are using React 18. -jest.mock('react', () => { - return { - ...jest.requireActual('react'), - useId() { - return 'test-id' - }, - } -}) - -const colors = [ - 'Black', - 'Red', - 'Green', - 'Blue', - 'Orange', - 'Purple', - 'Pink', - 'Orchid', - 'Aqua', - 'Lime', - 'Gray', - 'Brown', - 'Teal', - 'Skyblue', -] - -export const defaultProps = { - initialItems: colors.slice(0, 5), -} - -export const defaultIds = { - tagGroupId: 'downshift-test-id-tag-group', - getTagId: (index: number) => `downshift-test-id-tag-${index}`, -} +import useTagGroup from '../..' +import {UseTagGroupProps} from '../../index.types' +import {defaultProps} from './defaultProps' export function renderTagGroup(props: Partial> = {}) { const utils = render() @@ -72,16 +36,15 @@ export function renderTagGroup(props: Partial> = {}) { await user.click(removeButtons[index] as HTMLElement) } - return {...utils, getTags, getTagGroup, clickOnTag, clickOnRemoveTag, getTagsRemoves, user} -} - -export function renderUseTagGroup( - initialProps: Partial> = {}, -) { - return renderHook( - (props: Partial> = {}) => useTagGroup(props), - {initialProps: {...defaultProps, ...initialProps}}, - ) + return { + ...utils, + getTags, + getTagGroup, + clickOnTag, + clickOnRemoveTag, + getTagsRemoves, + user, + } } function TagGroup(props: Partial> = {}) { diff --git a/src/hooks/useTagGroup/__tests__/utils/renderUseTagGroup.ts b/src/hooks/useTagGroup/__tests__/utils/renderUseTagGroup.ts new file mode 100644 index 000000000..f9df64f06 --- /dev/null +++ b/src/hooks/useTagGroup/__tests__/utils/renderUseTagGroup.ts @@ -0,0 +1,14 @@ +import {renderHook} from '@testing-library/react' + +import {UseTagGroupProps} from '../../index.types' +import useTagGroup from '../..' +import {defaultProps} from './defaultProps' + +export function renderUseTagGroup( + initialProps: Partial> = {}, +) { + return renderHook( + (props: Partial> = {}) => useTagGroup(props), + {initialProps: {...defaultProps, ...initialProps}}, + ) +} From 4aae9fba44f9986175d2fbe9aefd13c862ad61b6 Mon Sep 17 00:00:00 2001 From: Silviu Alexandu Avram Date: Wed, 20 Aug 2025 10:05:07 +0300 Subject: [PATCH 18/40] add accessible description --- docusaurus/pages/useTagGroup.tsx | 3 +- .../__tests__/getTagGroupProps.test.ts | 10 +++++ .../useTagGroup/__tests__/getTagProps.test.ts | 11 +++++ .../__tests__/getTagRemoveProps.test.ts | 13 +++++- .../useTagGroup/__tests__/utils/index.ts | 10 ----- src/hooks/useTagGroup/index.ts | 27 +++++++++--- src/hooks/useTagGroup/index.types.ts | 7 ++- .../useAccessibleDescription.test.ts | 43 +++++++++++++++++++ src/hooks/useTagGroup/utils/index.ts | 4 ++ .../utils/useAccessibleDescription.ts | 26 +++++++++++ 10 files changed, 136 insertions(+), 18 deletions(-) create mode 100644 src/hooks/useTagGroup/utils/__tests__/useAccessibleDescription.test.ts create mode 100644 src/hooks/useTagGroup/utils/useAccessibleDescription.ts diff --git a/docusaurus/pages/useTagGroup.tsx b/docusaurus/pages/useTagGroup.tsx index 21eb96fc5..3a6d9b9cc 100644 --- a/docusaurus/pages/useTagGroup.tsx +++ b/docusaurus/pages/useTagGroup.tsx @@ -29,7 +29,8 @@ export default function TagGroup() { {color} diff --git a/src/hooks/useTagGroup/__tests__/getTagGroupProps.test.ts b/src/hooks/useTagGroup/__tests__/getTagGroupProps.test.ts index fcb7238d8..69c9266ed 100644 --- a/src/hooks/useTagGroup/__tests__/getTagGroupProps.test.ts +++ b/src/hooks/useTagGroup/__tests__/getTagGroupProps.test.ts @@ -6,6 +6,16 @@ import { renderUseTagGroup, } from './utils' +// We are using React 18. +jest.mock('react', () => { + return { + ...jest.requireActual('react'), + useId() { + return 'test-id' + }, + } +}) + describe('getTagGroupProps', () => { describe('hook props', () => { test('assign assigns a role of "grid" and aria live attributes', () => { diff --git a/src/hooks/useTagGroup/__tests__/getTagProps.test.ts b/src/hooks/useTagGroup/__tests__/getTagProps.test.ts index 3fdd62845..107bea0cc 100644 --- a/src/hooks/useTagGroup/__tests__/getTagProps.test.ts +++ b/src/hooks/useTagGroup/__tests__/getTagProps.test.ts @@ -1,5 +1,15 @@ +import { A11Y_DESCRIPTION_ELEMENT_ID } from '../utils' import {renderTagGroup, renderUseTagGroup, defaultIds, act} from './utils' +jest.mock('react', () => { + return { + ...jest.requireActual('react'), + useId() { + return 'test-id' + }, + } +}) + describe('getTagProps', () => { describe('hook props', () => { test('assign assigns a role of "row"', () => { @@ -8,6 +18,7 @@ describe('getTagProps', () => { expect(tagProps.role).toEqual('row') expect(tagProps.tabIndex).toEqual(-1) + expect(tagProps['aria-describedby']).toEqual(A11Y_DESCRIPTION_ELEMENT_ID) }) test('assign default value to id', () => { diff --git a/src/hooks/useTagGroup/__tests__/getTagRemoveProps.test.ts b/src/hooks/useTagGroup/__tests__/getTagRemoveProps.test.ts index dca0c6bb4..c1d421a1d 100644 --- a/src/hooks/useTagGroup/__tests__/getTagRemoveProps.test.ts +++ b/src/hooks/useTagGroup/__tests__/getTagRemoveProps.test.ts @@ -7,15 +7,26 @@ import { screen, } from './utils' +// We are using React 18. +jest.mock('react', () => { + return { + ...jest.requireActual('react'), + useId() { + return 'test-id' + }, + } +}) + describe('getTagRemoveProps', () => { describe('hook props', () => { test('assign assigns tabindex of -1 and aria-labelledby', () => { const {result} = renderUseTagGroup() const tagRemoveProps = result.current.getTagRemoveProps({index: 0}) + const tagId = defaultIds.getTagId(0) expect(tagRemoveProps.tabIndex).toEqual(-1) expect(tagRemoveProps['aria-labelledby']).toEqual( - `${defaultIds.tagGroupId} ${defaultIds.getTagId(0)}`, + `${tagId}-remove ${tagId}`, ) }) diff --git a/src/hooks/useTagGroup/__tests__/utils/index.ts b/src/hooks/useTagGroup/__tests__/utils/index.ts index 8941595a3..ed186c783 100644 --- a/src/hooks/useTagGroup/__tests__/utils/index.ts +++ b/src/hooks/useTagGroup/__tests__/utils/index.ts @@ -1,15 +1,5 @@ export * from '@testing-library/react' -// We are using React 18. -jest.mock('react', () => { - return { - ...jest.requireActual('react'), - useId() { - return 'test-id' - }, - } -}) - export {defaultProps} from './defaultProps' export {renderTagGroup} from './renderTagGroup' export {renderUseTagGroup} from './renderUseTagGroup' diff --git a/src/hooks/useTagGroup/index.ts b/src/hooks/useTagGroup/index.ts index 0d551c228..f7c3cdd2d 100644 --- a/src/hooks/useTagGroup/index.ts +++ b/src/hooks/useTagGroup/index.ts @@ -22,10 +22,17 @@ import { UseTagGroupStateChangeTypes, } from './index.types' import {useTagGroupReducer} from './reducer' -import {getInitialState, isStateEqual, propTypes, useElementIds} from './utils' +import { + getInitialState, + isStateEqual, + propTypes, + useElementIds, + useAccessibleDescription, + A11Y_DESCRIPTION_ELEMENT_ID, +} from './utils' // eslint-disable-next-line -const { isReactNative } = require('../../is.macro.js'); +const {isReactNative} = require('../../is.macro.js') useTagGroup.stateChangeTypes = stateChangeTypes @@ -36,7 +43,7 @@ export default function useTagGroup( // Props defaults and destructuring. const defaultProps: Pick< UseTagGroupProps, - 'stateReducer' | 'environment' + 'stateReducer' | 'environment' | 'removeElementDescription' > = { stateReducer(_s, {changes}) { return changes @@ -44,18 +51,22 @@ export default function useTagGroup( environment: /* istanbul ignore next (ssr) */ typeof window === 'undefined' || isReactNative ? undefined : window, + removeElementDescription: 'Press Delete to remove tag.', } const props = { ...defaultProps, ...userProps, } + const [state, dispatch] = useControlledReducer< UseTagGroupState, UseTagGroupProps, UseTagGroupStateChangeTypes, UseTagGroupReducerAction >(useTagGroupReducer, props, getInitialState, isStateEqual) + const {activeIndex, items} = state + // utility callback to get item element. const latest = useLatestRef({state, props}) // prevent id re-generation between renders. @@ -64,6 +75,11 @@ export default function useTagGroup( const previousItemsLengthRef = useRef(items.length) const isInitialMount = useIsInitialMount() + useAccessibleDescription( + props.environment?.document, + props.removeElementDescription, + ) + useEffect(() => { if (isInitialMount) { return @@ -143,9 +159,10 @@ export default function useTagGroup( const id = elementIds.getTagId(index) return { + 'aria-describedby': A11Y_DESCRIPTION_ELEMENT_ID, [refKey]: handleRefs(ref, itemNode => { if (itemNode) { - itemRefs.current[elementIds.getTagId(index)] = itemNode + itemRefs.current[id] = itemNode } }), role: 'row', @@ -175,7 +192,7 @@ export default function useTagGroup( return { id, tabIndex: -1, - 'aria-labelledby': `${elementIds.tagGroupId} ${tagId}`, + 'aria-labelledby': `${id} ${tagId}`, onClick: callAllEventHandlers(onClick, handleClick), ...rest, } diff --git a/src/hooks/useTagGroup/index.types.ts b/src/hooks/useTagGroup/index.types.ts index 3bb89c956..7589150f0 100644 --- a/src/hooks/useTagGroup/index.types.ts +++ b/src/hooks/useTagGroup/index.types.ts @@ -22,6 +22,7 @@ export interface UseTagGroupProps }, ): Partial> environment?: Environment + removeElementDescription: string } export interface UseTagGroupReturnValue { @@ -40,6 +41,7 @@ export interface GetTagPropsOptions extends React.HTMLProps { } export interface GetTagPropsReturnValue { + 'aria-describedby': string id: string role: 'row' onPress?: (event: React.BaseSyntheticEvent) => void @@ -59,7 +61,10 @@ export interface GetTagRemovePropsReturnValue { tabIndex: -1 } -export type GetTagGroupPropsOptions = React.HTMLProps +export interface GetTagGroupPropsOptions extends React.HTMLProps { + refKey?: string + ref?: React.MutableRefObject +} export interface GetTagGroupPropsReturnValue { id: string diff --git a/src/hooks/useTagGroup/utils/__tests__/useAccessibleDescription.test.ts b/src/hooks/useTagGroup/utils/__tests__/useAccessibleDescription.test.ts new file mode 100644 index 000000000..b1f047487 --- /dev/null +++ b/src/hooks/useTagGroup/utils/__tests__/useAccessibleDescription.test.ts @@ -0,0 +1,43 @@ +import {renderHook, act} from '@testing-library/react' +import {A11Y_DESCRIPTION_ELEMENT_ID, useAccessibleDescription} from '..' + +describe('useAccessibleDescription', () => { + test('adds a div element to the document that serves as accessible description', () => { + const divElement = { + setAttribute: jest.fn(), + remove: jest.fn(), + style: {display: ''}, + textContent: '', + } + + const document: Document = { + createElement: jest.fn().mockReturnValue(divElement), + body: { + appendChild: jest.fn(), + }, + } as unknown as Document + const description = 'press delete to remove' + + const {unmount} = renderHook(() => useAccessibleDescription(document, description)) + + expect(document.createElement).toHaveBeenCalledTimes(1) + expect(document.createElement).toHaveBeenCalledWith('div') + expect(divElement.setAttribute).toHaveBeenCalledTimes(1) + expect(divElement.setAttribute).toHaveBeenCalledWith( + 'id', + A11Y_DESCRIPTION_ELEMENT_ID, + ) + // eslint-disable-next-line jest-dom/prefer-to-have-style + expect(divElement.style.display).toEqual('none') + // eslint-disable-next-line jest-dom/prefer-to-have-text-content + expect(divElement.textContent).toEqual(description) + expect(document.body.appendChild).toHaveBeenCalledTimes(1) + expect(document.body.appendChild).toHaveBeenCalledWith(divElement) + + act(() => { + unmount() + }) + + expect(divElement.remove).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/hooks/useTagGroup/utils/index.ts b/src/hooks/useTagGroup/utils/index.ts index 796f35328..08bbc108c 100644 --- a/src/hooks/useTagGroup/utils/index.ts +++ b/src/hooks/useTagGroup/utils/index.ts @@ -7,6 +7,10 @@ export { type UseElementIdsReturnValue, } from './getInitialState' export {isStateEqual} from './isStateEqual' +export { + useAccessibleDescription, + A11Y_DESCRIPTION_ELEMENT_ID, +} from './useAccessibleDescription' export const propTypes: Record< string, diff --git a/src/hooks/useTagGroup/utils/useAccessibleDescription.ts b/src/hooks/useTagGroup/utils/useAccessibleDescription.ts new file mode 100644 index 000000000..cf0744df2 --- /dev/null +++ b/src/hooks/useTagGroup/utils/useAccessibleDescription.ts @@ -0,0 +1,26 @@ +import * as React from 'react' + +export const A11Y_DESCRIPTION_ELEMENT_ID = 'tag-group-a11y-description' + +export function useAccessibleDescription( + document: Document | undefined, + description: string, +) { + React.useEffect(() => { + if (!document) { + return + } + + const accessibleDescriptionElement = document.createElement('div') + + accessibleDescriptionElement.setAttribute('id', A11Y_DESCRIPTION_ELEMENT_ID) + accessibleDescriptionElement.style.display = 'none' + accessibleDescriptionElement.textContent = description + + document.body.appendChild(accessibleDescriptionElement) + + return () => { + accessibleDescriptionElement.remove() + } + }, [description, document]) +} From c1864548975423838f7c9f329de308938fa3b77c Mon Sep 17 00:00:00 2001 From: Silviu Alexandu Avram Date: Tue, 26 Aug 2025 10:27:45 +0300 Subject: [PATCH 19/40] make coverage 100% --- .../__tests__/getTagGroupProps.test.ts | 53 +++++++++++++++---- .../useTagGroup/__tests__/getTagProps.test.ts | 10 +++- .../__tests__/getTagRemoveProps.test.ts | 30 ++++++++++- src/hooks/useTagGroup/__tests__/props.test.ts | 33 +++++++----- .../useTagGroup/__tests__/reducer.test.ts | 17 ++++++ .../useTagGroup/__tests__/returnProps.test.ts | 32 +++++++++++ .../useAccessibleDescription.test.ts | 12 ++++- .../__tests__/getItemAndIndex.test.ts | 47 ++++++++++++++++ src/utils-ts/__tests__/getState.test.ts | 14 +++++ src/utils-ts/__tests__/handleRefs.test.ts | 17 ++++++ src/utils-ts/handleRefs.ts | 20 +++++++ src/utils-ts/index.ts | 3 +- src/utils-ts/useLatestRef.ts | 18 ------- 13 files changed, 261 insertions(+), 45 deletions(-) create mode 100644 src/hooks/useTagGroup/__tests__/reducer.test.ts create mode 100644 src/hooks/utils-ts/__tests__/getItemAndIndex.test.ts create mode 100644 src/utils-ts/__tests__/getState.test.ts create mode 100644 src/utils-ts/__tests__/handleRefs.test.ts create mode 100644 src/utils-ts/handleRefs.ts diff --git a/src/hooks/useTagGroup/__tests__/getTagGroupProps.test.ts b/src/hooks/useTagGroup/__tests__/getTagGroupProps.test.ts index 69c9266ed..6ea2a55e4 100644 --- a/src/hooks/useTagGroup/__tests__/getTagGroupProps.test.ts +++ b/src/hooks/useTagGroup/__tests__/getTagGroupProps.test.ts @@ -1,10 +1,5 @@ -import { act } from 'react-dom/test-utils' -import { - screen, - defaultProps, - renderTagGroup, - renderUseTagGroup, -} from './utils' +import {act} from 'react-dom/test-utils' +import {screen, defaultProps, renderTagGroup, renderUseTagGroup} from './utils' // We are using React 18. jest.mock('react', () => { @@ -24,9 +19,9 @@ describe('getTagGroupProps', () => { expect(tagGroupProps.role).toEqual('grid') expect(tagGroupProps.id).toEqual('downshift-test-id-tag-group') - expect(tagGroupProps["aria-live"]).toEqual('polite') - expect(tagGroupProps["aria-atomic"]).toEqual('false') - expect(tagGroupProps["aria-relevant"]).toEqual('additions') + expect(tagGroupProps['aria-live']).toEqual('polite') + expect(tagGroupProps['aria-atomic']).toEqual('false') + expect(tagGroupProps['aria-relevant']).toEqual('additions') }) }) @@ -163,6 +158,30 @@ describe('getTagGroupProps', () => { expect( screen.queryByRole('tag', {name: defaultProps.initialItems[2]}), ).not.toBeInTheDocument() + expect(getTags()[2]).toHaveAttribute('tabindex', '0') + }) + + test('delete removes the active last item and the second to last item becomes active', async () => { + const {clickOnTag, user, getTags} = renderTagGroup() + + const tagsCount = getTags().length + + await clickOnTag(tagsCount - 1) + await user.keyboard('{Delete}') + + expect(getTags()[tagsCount - 2]).toHaveAttribute('tabindex', '0') + }) + + test('delete removes the only active item', async () => { + const {clickOnTag, user, queryByRole} = renderTagGroup({ + initialItems: [defaultProps.initialItems[0] as string], + }) + + await clickOnTag(0) + await user.keyboard('{Delete}') + + // eslint-disable-next-line testing-library/prefer-screen-queries + expect(queryByRole('tag')).not.toBeInTheDocument() }) test('backspace removes the active item', async () => { @@ -180,5 +199,19 @@ describe('getTagGroupProps', () => { screen.queryByRole('tag', {name: defaultProps.initialItems[2]}), ).not.toBeInTheDocument() }) + + test('any other key does nothing', async () => { + const {clickOnTag, user, getTags} = renderTagGroup({ + defaultActiveIndex: 2, + }) + + await clickOnTag(2) + await user.keyboard('{Space}') + + const tags = getTags() + + expect(tags).toHaveLength(defaultProps.initialItems.length) + expect(tags[2]).toHaveAttribute('tabindex', '0') + }) }) }) diff --git a/src/hooks/useTagGroup/__tests__/getTagProps.test.ts b/src/hooks/useTagGroup/__tests__/getTagProps.test.ts index 107bea0cc..712831466 100644 --- a/src/hooks/useTagGroup/__tests__/getTagProps.test.ts +++ b/src/hooks/useTagGroup/__tests__/getTagProps.test.ts @@ -1,4 +1,4 @@ -import { A11Y_DESCRIPTION_ELEMENT_ID } from '../utils' +import {A11Y_DESCRIPTION_ELEMENT_ID} from '../utils' import {renderTagGroup, renderUseTagGroup, defaultIds, act} from './utils' jest.mock('react', () => { @@ -35,6 +35,14 @@ describe('getTagProps', () => { expect(result.current.getTagProps({index: 0}).id).toEqual(getTagId(0)) }) + + test('calling it without index results in error', () => { + const {result} = renderUseTagGroup() + + expect(() => + result.current.getTagProps({}), + ).toThrowErrorMatchingInlineSnapshot(`Pass index to getTagProps!`) + }) }) describe('user props', () => { diff --git a/src/hooks/useTagGroup/__tests__/getTagRemoveProps.test.ts b/src/hooks/useTagGroup/__tests__/getTagRemoveProps.test.ts index c1d421a1d..25f3ca07b 100644 --- a/src/hooks/useTagGroup/__tests__/getTagRemoveProps.test.ts +++ b/src/hooks/useTagGroup/__tests__/getTagRemoveProps.test.ts @@ -46,6 +46,14 @@ describe('getTagRemoveProps', () => { `${getTagId(0)}-remove`, ) }) + + test('calling it without index results in error', () => { + const {result} = renderUseTagGroup() + + expect(() => + result.current.getTagRemoveProps({}), + ).toThrowErrorMatchingInlineSnapshot(`Pass index to getTagRemoveProps!`) + }) }) describe('user props', () => { @@ -109,6 +117,26 @@ describe('getTagRemoveProps', () => { ).toBeInTheDocument() }) - + + test('click removes the active last item and the second to last item becomes active', async () => { + const {clickOnRemoveTag, getTags} = renderTagGroup() + + const tagsCount = getTags().length + + await clickOnRemoveTag(tagsCount - 1) + + expect(getTags()[tagsCount - 2]).toHaveAttribute('tabindex', '0') + }) + + test('click removes the only active item', async () => { + const {clickOnRemoveTag, queryByRole} = renderTagGroup({ + initialItems: [defaultProps.initialItems[0] as string], + }) + + await clickOnRemoveTag(0) + + // eslint-disable-next-line testing-library/prefer-screen-queries + expect(queryByRole('tag')).not.toBeInTheDocument() + }) }) }) diff --git a/src/hooks/useTagGroup/__tests__/props.test.ts b/src/hooks/useTagGroup/__tests__/props.test.ts index 170c97145..ac3a015fd 100644 --- a/src/hooks/useTagGroup/__tests__/props.test.ts +++ b/src/hooks/useTagGroup/__tests__/props.test.ts @@ -1,17 +1,24 @@ -import {renderTagGroup} from './utils' +import useTagGroup from '..' +import {renderTagGroup, renderHook} from './utils' describe('props', () => { - test('id if passed will override downshift default', () => { - const {getTagGroup, getTags} = renderTagGroup({ - id: 'my-custom-little-id', - }) - const elements = [getTagGroup(), getTags()[0]] - - elements.forEach(element => { - expect(element).toHaveAttribute( - 'id', - expect.stringContaining('my-custom-little-id'), - ) - }) + test('passing no props object will still work', () => { + const result = renderHook(() => useTagGroup()) + + expect(result.result.current.getTagGroupProps).toBeDefined() + }) + + test('id if passed will override downshift default', () => { + const {getTagGroup, getTags} = renderTagGroup({ + id: 'my-custom-little-id', + }) + const elements = [getTagGroup(), getTags()[0]] + + elements.forEach(element => { + expect(element).toHaveAttribute( + 'id', + expect.stringContaining('my-custom-little-id'), + ) }) + }) }) diff --git a/src/hooks/useTagGroup/__tests__/reducer.test.ts b/src/hooks/useTagGroup/__tests__/reducer.test.ts new file mode 100644 index 000000000..ec62a615b --- /dev/null +++ b/src/hooks/useTagGroup/__tests__/reducer.test.ts @@ -0,0 +1,17 @@ +import {UseTagGroupReducerAction} 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, + ) + }).toThrowError('Invalid useTagGroup reducer action.') +}) diff --git a/src/hooks/useTagGroup/__tests__/returnProps.test.ts b/src/hooks/useTagGroup/__tests__/returnProps.test.ts index fe1c0cbd3..ec960e490 100644 --- a/src/hooks/useTagGroup/__tests__/returnProps.test.ts +++ b/src/hooks/useTagGroup/__tests__/returnProps.test.ts @@ -30,6 +30,38 @@ describe('returnProps', () => { expect(result.current.items).toEqual([...previousItems, 'test']) }) + + test('addItem keeps the active item if previously set', () => { + const {result} = renderUseTagGroup({initialActiveIndex: 2}) + + act(() => { + result.current.addItem('test') + }) + + expect(result.current.activeIndex).toEqual(2) + }) + + test('addItem makes the last item active if there is no previous active item', () => { + const {result} = renderUseTagGroup({initialActiveIndex: -1}) + + act(() => { + result.current.addItem('test') + }) + + expect(result.current.activeIndex).toEqual(result.current.items.length - 1) + }) + + test('addItem adds an item to the group at the index specified', () => { + const {result} = renderUseTagGroup() + + const previousItems = result.current.items + + act(() => { + result.current.addItem('test', 0) + }) + + expect(result.current.items).toEqual(['test', ...previousItems]) + }) }) describe('state and props', () => { diff --git a/src/hooks/useTagGroup/utils/__tests__/useAccessibleDescription.test.ts b/src/hooks/useTagGroup/utils/__tests__/useAccessibleDescription.test.ts index b1f047487..41ace534b 100644 --- a/src/hooks/useTagGroup/utils/__tests__/useAccessibleDescription.test.ts +++ b/src/hooks/useTagGroup/utils/__tests__/useAccessibleDescription.test.ts @@ -2,6 +2,14 @@ import {renderHook, act} from '@testing-library/react' import {A11Y_DESCRIPTION_ELEMENT_ID, useAccessibleDescription} from '..' describe('useAccessibleDescription', () => { + test('does nothing if document is undefined', () => { + const {result} = renderHook(() => + useAccessibleDescription(undefined, 'description'), + ) + + expect(result.current).toBeUndefined() + }) + test('adds a div element to the document that serves as accessible description', () => { const divElement = { setAttribute: jest.fn(), @@ -18,7 +26,9 @@ describe('useAccessibleDescription', () => { } as unknown as Document const description = 'press delete to remove' - const {unmount} = renderHook(() => useAccessibleDescription(document, description)) + const {unmount} = renderHook(() => + useAccessibleDescription(document, description), + ) expect(document.createElement).toHaveBeenCalledTimes(1) expect(document.createElement).toHaveBeenCalledWith('div') diff --git a/src/hooks/utils-ts/__tests__/getItemAndIndex.test.ts b/src/hooks/utils-ts/__tests__/getItemAndIndex.test.ts new file mode 100644 index 000000000..c9341fec7 --- /dev/null +++ b/src/hooks/utils-ts/__tests__/getItemAndIndex.test.ts @@ -0,0 +1,47 @@ +import {getItemAndIndex} from '../getItemAndIndex' + +test('returns the props if both are passed', () => { + const item = {hi: 'hello'} + const index = 5 + + expect(getItemAndIndex(item, index, [item], 'bla')).toEqual([item, index]) +}) + +test('throws error when index is not passed and item is not found in the array', () => { + const item = {hi: 'hello'} + const errorMessage = 'no item found' + + expect(() => getItemAndIndex(item, undefined, [], errorMessage)).toThrowError( + errorMessage, + ) +}) + +test('returns the item and the index found', () => { + const item = {hi: 'hello'} + + expect(getItemAndIndex(item, undefined, [item], 'bla')).toEqual([item, 0]) +}) + +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), + ).toThrowError(errorMessage) +}) + +test('returns the index and the item found', () => { + const item = {hi: 'hello'} + const index = 0 + + expect(getItemAndIndex(undefined, index, [item], 'bla')).toEqual([item, 0]) +}) + +test('throws error when both index and item are not passed', () => { + const errorMessage = 'it is all wrong' + + expect(() => + getItemAndIndex(undefined, undefined, [{item: 'bla'}], errorMessage), + ).toThrowError(errorMessage) +}) diff --git a/src/utils-ts/__tests__/getState.test.ts b/src/utils-ts/__tests__/getState.test.ts new file mode 100644 index 000000000..4feaac8af --- /dev/null +++ b/src/utils-ts/__tests__/getState.test.ts @@ -0,0 +1,14 @@ +import {getState, Props} from '../getState' + +test('returns state if no props are passed', () => { + const state = {a: 'b'} + + expect(getState(state, undefined)).toEqual(state) +}) + +test('merges state with props', () => { + const state = {a: 'b', c: 'd'} + const props = {b: 'e', c: 'f'} as unknown as Props + + expect(getState(state, props)).toEqual({a: 'b', c: 'f'}) +}) diff --git a/src/utils-ts/__tests__/handleRefs.test.ts b/src/utils-ts/__tests__/handleRefs.test.ts new file mode 100644 index 000000000..78437df1f --- /dev/null +++ b/src/utils-ts/__tests__/handleRefs.test.ts @@ -0,0 +1,17 @@ +import {handleRefs} from '../handleRefs' + +test('handleRefs handles both ref functions and objects', () => { + const refFunction = jest.fn() as unknown as React.RefCallback + const refObject = { + current: null, + } as unknown as React.MutableRefObject + const refs = [refFunction, refObject] + const node = {} as unknown as HTMLElement + const ref = handleRefs(...refs) + + ref(node) + + expect(refFunction).toHaveBeenCalledTimes(1) + expect(refFunction).toHaveBeenCalledWith(node) + expect(refObject.current).toEqual(node) +}) diff --git a/src/utils-ts/handleRefs.ts b/src/utils-ts/handleRefs.ts new file mode 100644 index 000000000..0fd76cfbb --- /dev/null +++ b/src/utils-ts/handleRefs.ts @@ -0,0 +1,20 @@ +import * as React from 'react' + +export function handleRefs( + ...refs: ( + | React.MutableRefObject + | React.RefCallback + | undefined + )[] +) { + return (node: HTMLElement) => { + refs.forEach(ref => { + console.log(ref, typeof ref) + if (typeof ref === 'function') { + ref(node) + } else if (ref) { + ref.current = node + } + }) + } +} diff --git a/src/utils-ts/index.ts b/src/utils-ts/index.ts index 3f1c0ae7c..afd68dce9 100644 --- a/src/utils-ts/index.ts +++ b/src/utils-ts/index.ts @@ -1,5 +1,6 @@ export {generateId, setIdCounter, resetIdCounter} from './generateId' -export {useLatestRef, handleRefs} from './useLatestRef' +export {useLatestRef} from './useLatestRef' +export {handleRefs} from './handleRefs' export {callAllEventHandlers} from './callAllEventHandlers' export {debounce} from './debounce' export {setStatus, cleanupStatusDiv} from './setA11yStatus' diff --git a/src/utils-ts/useLatestRef.ts b/src/utils-ts/useLatestRef.ts index 638381e67..cd23e096a 100644 --- a/src/utils-ts/useLatestRef.ts +++ b/src/utils-ts/useLatestRef.ts @@ -10,21 +10,3 @@ export function useLatestRef(val: T): React.MutableRefObject { ref.current = val return ref } - -export function handleRefs( - ...refs: ( - | React.MutableRefObject - | React.RefCallback - | undefined - )[] -) { - return (node: HTMLElement) => { - refs.forEach(ref => { - if (typeof ref === 'function') { - ref(node) - } else if (ref) { - ref.current = node - } - }) - } -} From 34f23970e58eee777937c0f19495591eab2daaf4 Mon Sep 17 00:00:00 2001 From: Silviu Alexandu Avram Date: Mon, 1 Sep 2025 11:10:16 +0300 Subject: [PATCH 20/40] change to listbox + tests --- docusaurus/pages/useTagGroup.tsx | 2 +- .../__tests__/getTagGroupProps.test.ts | 4 +- .../useTagGroup/__tests__/getTagProps.test.ts | 6 +- .../__tests__/getTagRemoveProps.test.ts | 2 +- src/hooks/useTagGroup/__tests__/props.test.ts | 293 +++++++++++++++++- .../useTagGroup/__tests__/returnProps.test.ts | 8 +- .../__tests__/utils/defaultProps.ts | 4 +- .../useTagGroup/__tests__/utils/index.ts | 2 +- .../__tests__/utils/renderTagGroup.tsx | 4 +- src/hooks/useTagGroup/index.ts | 5 +- src/hooks/useTagGroup/index.types.ts | 9 +- .../useTagGroup/utils/getInitialState.ts | 5 +- src/utils-ts/handleRefs.ts | 1 - 13 files changed, 314 insertions(+), 31 deletions(-) diff --git a/docusaurus/pages/useTagGroup.tsx b/docusaurus/pages/useTagGroup.tsx index 3a6d9b9cc..8b55eeb48 100644 --- a/docusaurus/pages/useTagGroup.tsx +++ b/docusaurus/pages/useTagGroup.tsx @@ -24,7 +24,7 @@ export default function TagGroup() { {color} + + ))} +
    +
    + +
    + + +
    +
    +
      + {isOpen + ? itemsToAdd.map((item, index) => ( +
    • + {item} +
    • + )) + : null} +
    +
    + ) +} From 2c4d30ed9ea2f337d2e3a52775abc757e25de883 Mon Sep 17 00:00:00 2001 From: Silviu Alexandru Avram Date: Fri, 12 Dec 2025 16:39:25 +0200 Subject: [PATCH 34/40] change types export to modules again --- src/hooks/useTagGroup/index.types.ts | 7 + src/{index.js => index.ts} | 0 test/basic.test.tsx | 1 + test/custom.test.tsx | 1 + test/downshift.test.tsx | 1 + test/tsconfig.json | 3 +- test/useCombobox.test.tsx | 1 + test/useMultipleSelect.test.tsx | 2 + test/useSelect.test.tsx | 2 + tsconfig.json | 7 +- typings/index.d.ts | 126 +---------------- typings/index.legacy.d.ts | 200 +++++++++++++-------------- 12 files changed, 124 insertions(+), 227 deletions(-) rename src/{index.js => index.ts} (100%) diff --git a/src/hooks/useTagGroup/index.types.ts b/src/hooks/useTagGroup/index.types.ts index e8cc1abe2..122ccef94 100644 --- a/src/hooks/useTagGroup/index.types.ts +++ b/src/hooks/useTagGroup/index.types.ts @@ -5,6 +5,13 @@ export interface UseTagGroupState extends State { items: Item[] } +export interface Environment { + addEventListener: typeof window.addEventListener + removeEventListener: typeof window.removeEventListener + document: Document + Node: typeof window.Node +} + export interface UseTagGroupProps extends Partial< UseTagGroupState > { diff --git a/src/index.js b/src/index.ts similarity index 100% rename from src/index.js rename to src/index.ts diff --git a/test/basic.test.tsx b/test/basic.test.tsx index 6c54d4889..4270e67d0 100644 --- a/test/basic.test.tsx +++ b/test/basic.test.tsx @@ -1,4 +1,5 @@ import * as React from 'react' +import Downshift, {StateChangeOptions} from '..' type Item = string diff --git a/test/custom.test.tsx b/test/custom.test.tsx index a03b6e5b9..90f8aaa0f 100644 --- a/test/custom.test.tsx +++ b/test/custom.test.tsx @@ -1,4 +1,5 @@ import * as React from 'react' +import Downshift, { ControllerStateAndHelpers } from '..' type Item = string diff --git a/test/downshift.test.tsx b/test/downshift.test.tsx index fa52b0f0a..32dc27587 100644 --- a/test/downshift.test.tsx +++ b/test/downshift.test.tsx @@ -1,4 +1,5 @@ import * as React from 'react' +import Downshift from '..' export const colors = [ 'Black', diff --git a/test/tsconfig.json b/test/tsconfig.json index 9076e4fe4..e78e4d116 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -3,5 +3,6 @@ "compilerOptions": { "noEmit": true, }, - "include": ["./**/*.tsx*", "../typings/**/*.d.ts"] + "include": ["../typings/index.d.ts"], + "exclude": [] } \ No newline at end of file diff --git a/test/useCombobox.test.tsx b/test/useCombobox.test.tsx index cf64d3e0d..d911770ed 100644 --- a/test/useCombobox.test.tsx +++ b/test/useCombobox.test.tsx @@ -1,4 +1,5 @@ import * as React from 'react' +import {useCombobox} from '..' export const colors = [ 'Black', diff --git a/test/useMultipleSelect.test.tsx b/test/useMultipleSelect.test.tsx index 4d1f99cfa..c2d9b3b46 100644 --- a/test/useMultipleSelect.test.tsx +++ b/test/useMultipleSelect.test.tsx @@ -1,5 +1,7 @@ import * as React from 'react' +import {useMultipleSelection, useSelect} from '..' + export const colors = [ 'Black', 'Red', diff --git a/test/useSelect.test.tsx b/test/useSelect.test.tsx index 00cbb3a1a..80e2b8b29 100644 --- a/test/useSelect.test.tsx +++ b/test/useSelect.test.tsx @@ -1,5 +1,7 @@ import * as React from 'react' +import {useSelect} from '..' + export const colors = [ 'Black', 'Red', diff --git a/tsconfig.json b/tsconfig.json index 724ee2e92..baeaf112d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,9 +24,6 @@ "*": ["*"] } }, - "include": [ - "src/**/*.ts", - "src/**/*.tsx", - "typings/**/*.d.ts", - ] + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["typings"] } diff --git a/typings/index.d.ts b/typings/index.d.ts index fdb4cbcd2..a53512ba2 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -1,125 +1,11 @@ -/// - -/* ---------------- Interfaces ---------------- */ - -export interface DownshiftState extends globalThis.DownshiftState {} -export interface DownshiftProps extends globalThis.DownshiftProps {} -export interface Environment extends globalThis.Environment {} -export interface A11yStatusMessageOptions extends globalThis.A11yStatusMessageOptions {} -export interface StateChangeOptions extends globalThis.StateChangeOptions {} -export interface GetRootPropsOptions extends globalThis.GetRootPropsOptions {} -export interface GetRootPropsReturnValue extends globalThis.GetRootPropsReturnValue {} -export interface GetInputPropsOptions extends globalThis.GetInputPropsOptions {} -export interface GetInputPropsReturnValue extends globalThis.GetInputPropsReturnValue {} -export interface GetLabelPropsOptions extends globalThis.GetLabelPropsOptions {} -export interface GetLabelPropsReturnValue extends globalThis.GetLabelPropsReturnValue {} -export interface GetToggleButtonPropsOptions extends globalThis.GetToggleButtonPropsOptions {} -export interface GetMenuPropsOptions extends globalThis.GetMenuPropsOptions {} -export interface GetMenuPropsReturnValue extends globalThis.GetMenuPropsReturnValue {} -export interface GetPropsCommonOptions extends globalThis.GetPropsCommonOptions {} -export interface GetPropsWithRefKey extends globalThis.GetPropsWithRefKey {} -export interface GetItemPropsOptions extends globalThis.GetItemPropsOptions {} -export interface GetItemPropsReturnValue extends globalThis.GetItemPropsReturnValue {} -export interface PropGetters extends globalThis.PropGetters {} -export interface Actions extends globalThis.Actions {} - -/* ---------------- Hooks / Class / Functions ---------------- */ - -export const Downshift: typeof globalThis.Downshift; -export default Downshift; - -export const resetIdCounter: typeof globalThis.resetIdCounter; -export const useSelect: typeof globalThis.useSelect; -export const useCombobox: typeof globalThis.useCombobox; -export const useMultipleSelection: typeof globalThis.useMultipleSelection; - -/* ---------------- Enums (value + type) ---------------- */ - -export const StateChangeTypes: typeof globalThis.StateChangeTypes; -export type StateChangeTypes = globalThis.StateChangeTypes; - -export const UseSelectStateChangeTypes: typeof globalThis.UseSelectStateChangeTypes; -export type UseSelectStateChangeTypes = globalThis.UseSelectStateChangeTypes; - -export const UseComboboxStateChangeTypes: typeof globalThis.UseComboboxStateChangeTypes; -export type UseComboboxStateChangeTypes = globalThis.UseComboboxStateChangeTypes; - -export const UseMultipleSelectionStateChangeTypes: typeof globalThis.UseMultipleSelectionStateChangeTypes; -export type UseMultipleSelectionStateChangeTypes = globalThis.UseMultipleSelectionStateChangeTypes; - -/* ---------------- useSelect interfaces ---------------- */ - -export interface UseSelectState extends globalThis.UseSelectState {} -export interface UseSelectProps extends globalThis.UseSelectProps {} -export interface UseSelectStateChangeOptions extends globalThis.UseSelectStateChangeOptions {} -export interface UseSelectDispatchAction extends globalThis.UseSelectDispatchAction {} -export interface UseSelectStateChange extends globalThis.UseSelectStateChange {} -export interface UseSelectSelectedItemChange extends globalThis.UseSelectSelectedItemChange {} -export interface UseSelectHighlightedIndexChange extends globalThis.UseSelectHighlightedIndexChange {} -export interface UseSelectIsOpenChange extends globalThis.UseSelectIsOpenChange {} -export interface UseSelectGetMenuPropsOptions extends globalThis.UseSelectGetMenuPropsOptions {} -export interface UseSelectGetMenuReturnValue extends globalThis.UseSelectGetMenuReturnValue {} -export interface UseSelectGetToggleButtonPropsOptions extends globalThis.UseSelectGetToggleButtonPropsOptions {} -export interface UseSelectGetToggleButtonReturnValue extends globalThis.UseSelectGetToggleButtonReturnValue {} -export interface UseSelectGetLabelPropsOptions extends globalThis.UseSelectGetLabelPropsOptions {} -export interface UseSelectGetLabelPropsReturnValue extends globalThis.UseSelectGetLabelPropsReturnValue {} -export interface UseSelectGetItemPropsOptions extends globalThis.UseSelectGetItemPropsOptions {} -export interface UseSelectGetItemPropsReturnValue extends globalThis.UseSelectGetItemPropsReturnValue {} -export interface UseSelectPropGetters extends globalThis.UseSelectPropGetters {} -export interface UseSelectActions extends globalThis.UseSelectActions {} -export interface UseSelectReturnValue extends globalThis.UseSelectReturnValue {} -export interface UseSelectInterface extends globalThis.UseSelectInterface {} - -/* ---------------- useCombobox interfaces ---------------- */ - -export interface UseComboboxState extends globalThis.UseComboboxState {} -export interface UseComboboxProps extends globalThis.UseComboboxProps {} -export interface UseComboboxStateChangeOptions extends globalThis.UseComboboxStateChangeOptions {} -export interface UseComboboxDispatchAction extends globalThis.UseComboboxDispatchAction {} -export interface UseComboboxStateChange extends globalThis.UseComboboxStateChange {} -export interface UseComboboxSelectedItemChange extends globalThis.UseComboboxSelectedItemChange {} -export interface UseComboboxHighlightedIndexChange extends globalThis.UseComboboxHighlightedIndexChange {} -export interface UseComboboxIsOpenChange extends globalThis.UseComboboxIsOpenChange {} -export interface UseComboboxInputValueChange extends globalThis.UseComboboxInputValueChange {} -export interface UseComboboxGetMenuPropsOptions extends globalThis.UseComboboxGetMenuPropsOptions {} -export interface UseComboboxGetMenuPropsReturnValue extends globalThis.UseComboboxGetMenuPropsReturnValue {} -export interface UseComboboxGetToggleButtonPropsOptions extends globalThis.UseComboboxGetToggleButtonPropsOptions {} -export interface UseComboboxGetToggleButtonReturnValue extends globalThis.UseComboboxGetToggleButtonReturnValue {} -export interface UseComboboxGetItemPropsOptions extends globalThis.UseComboboxGetItemPropsOptions {} -export interface UseComboboxGetItemPropsReturnValue extends globalThis.UseComboboxGetItemPropsReturnValue {} -export interface UseComboboxGetInputPropsOptions extends globalThis.UseComboboxGetInputPropsOptions {} -export interface UseComboboxGetInputPropsReturnValue extends globalThis.UseComboboxGetInputPropsReturnValue {} -export interface UseComboboxPropGetters extends globalThis.UseComboboxPropGetters {} -export interface UseComboboxActions extends globalThis.UseComboboxActions {} -export interface UseComboboxReturnValue extends globalThis.UseComboboxReturnValue {} -export interface UseComboboxInterface extends globalThis.UseComboboxInterface {} - -/* ---------------- useMultipleSelection interfaces ---------------- */ - -export interface UseMultipleSelectionState extends globalThis.UseMultipleSelectionState {} -export interface UseMultipleSelectionProps extends globalThis.UseMultipleSelectionProps {} -export interface UseMultipleSelectionStateChangeOptions extends globalThis.UseMultipleSelectionStateChangeOptions {} -export interface UseMultipleSelectionDispatchAction extends globalThis.UseMultipleSelectionDispatchAction {} -export interface UseMultipleSelectionStateChange extends globalThis.UseMultipleSelectionStateChange {} -export interface UseMultipleSelectionActiveIndexChange extends globalThis.UseMultipleSelectionActiveIndexChange {} -export interface UseMultipleSelectionSelectedItemsChange extends globalThis.UseMultipleSelectionSelectedItemsChange {} -export interface A11yRemovalMessage extends globalThis.A11yRemovalMessage {} -export interface UseMultipleSelectionGetSelectedItemPropsOptions extends globalThis.UseMultipleSelectionGetSelectedItemPropsOptions {} -export interface UseMultipleSelectionGetSelectedItemReturnValue extends globalThis.UseMultipleSelectionGetSelectedItemReturnValue {} -export interface UseMultipleSelectionGetDropdownPropsOptions extends globalThis.UseMultipleSelectionGetDropdownPropsOptions {} -export interface UseMultipleSelectionGetDropdownReturnValue extends globalThis.UseMultipleSelectionGetDropdownReturnValue {} -export interface UseMultipleSelectionPropGetters extends globalThis.UseMultipleSelectionPropGetters {} -export interface UseMultipleSelectionActions extends globalThis.UseMultipleSelectionActions {} -export interface UseMultipleSelectionReturnValue extends globalThis.UseMultipleSelectionReturnValue {} -export interface UseMultipleSelectionInterface extends globalThis.UseMultipleSelectionInterface {} - -/* ------- Re-export generated TS declarations ------- */ +export * from './index.legacy' +import Downshift from './index.legacy' +export default Downshift export { UseTagGroupState, UseTagGroupProps, UseTagGroupReturnValue, - UseTagGroupInterface, GetTagGroupProps, GetTagGroupPropsOptions, GetTagGroupPropsReturnValue, @@ -130,6 +16,8 @@ export { GetTagRemovePropsOptions, GetTagRemovePropsReturnValue, UseTagGroupStateChangeTypes, -} from '../dist/src/hooks/useTagGroup/index.types' +} from '../dist/hooks/useTagGroup/index.types' -export const useTagGroup: UseTagGroupInterface; +import {UseTagGroupInterface} from '../dist/hooks/useTagGroup/index.types' +export const useTagGroup: UseTagGroupInterface +export {UseTagGroupInterface} diff --git a/typings/index.legacy.d.ts b/typings/index.legacy.d.ts index b8fd0c0c4..7d543581d 100644 --- a/typings/index.legacy.d.ts +++ b/typings/index.legacy.d.ts @@ -1,17 +1,21 @@ -/// +import React from "react" -type Callback = () => void +import {Environment} from '../dist/hooks/useTagGroup/index.types' -type Overwrite = Pick> & U +export {Environment} -interface DownshiftState { +export type Callback = () => void + +export type Overwrite = Pick> & U + +export interface DownshiftState { highlightedIndex: number | null inputValue: string | null isOpen: boolean selectedItem: Item | null } -declare enum StateChangeTypes { +export enum StateChangeTypes { unknown = '__autocomplete_unknown__', mouseUp = '__autocomplete_mouseup__', itemMouseEnter = '__autocomplete_item_mouseenter__', @@ -29,7 +33,7 @@ declare enum StateChangeTypes { touchEnd = '__autocomplete_touchend__', } -interface DownshiftProps { +export interface DownshiftProps { initialSelectedItem?: Item initialInputValue?: string initialHighlightedIndex?: number | null @@ -80,14 +84,7 @@ interface DownshiftProps { suppressRefError?: boolean } -interface Environment { - addEventListener: typeof window.addEventListener - removeEventListener: typeof window.removeEventListener - document: Document - Node: typeof window.Node -} - -interface A11yStatusMessageOptions { +export interface A11yStatusMessageOptions { highlightedIndex: number | null inputValue: string isOpen: boolean @@ -98,20 +95,20 @@ interface A11yStatusMessageOptions { selectedItem: Item | null } -interface StateChangeOptions extends Partial> { +export interface StateChangeOptions extends Partial> { type: StateChangeTypes } -type StateChangeFunction = ( +export type StateChangeFunction = ( state: DownshiftState, ) => Partial> -interface GetRootPropsOptions { +export interface GetRootPropsOptions { refKey?: string ref?: React.RefObject } -interface GetRootPropsReturnValue { +export interface GetRootPropsReturnValue { 'aria-expanded': boolean 'aria-haspopup': 'listbox' 'aria-labelledby': string @@ -120,11 +117,11 @@ interface GetRootPropsReturnValue { role: 'combobox' } -interface GetInputPropsOptions extends React.HTMLProps { +export interface GetInputPropsOptions extends React.HTMLProps { disabled?: boolean } -interface GetInputPropsReturnValue { +export interface GetInputPropsReturnValue { 'aria-autocomplete': 'list' 'aria-activedescendant': string | undefined 'aria-controls': string | undefined @@ -139,19 +136,19 @@ interface GetInputPropsReturnValue { value: string } -interface GetLabelPropsOptions extends React.HTMLProps {} +export interface GetLabelPropsOptions extends React.HTMLProps {} -interface GetLabelPropsReturnValue { +export interface GetLabelPropsReturnValue { htmlFor: string id: string } -interface GetToggleButtonPropsOptions extends React.HTMLProps { +export interface GetToggleButtonPropsOptions extends React.HTMLProps { disabled?: boolean onPress?: (event: React.BaseSyntheticEvent) => void } -interface GetToggleButtonPropsReturnValue { +export interface GetToggleButtonPropsReturnValue { 'aria-label': 'close menu' | 'open menu' 'aria-haspopup': true 'data-toggle': true @@ -164,34 +161,34 @@ interface GetToggleButtonPropsReturnValue { type: 'button' } -interface GetMenuPropsOptions +export interface GetMenuPropsOptions extends React.HTMLProps, GetPropsWithRefKey { ['aria-label']?: string } -interface GetMenuPropsReturnValue { +export interface GetMenuPropsReturnValue { 'aria-labelledby': string | undefined ref?: React.RefObject role: 'listbox' id: string } -interface GetPropsCommonOptions { +export interface GetPropsCommonOptions { suppressRefError?: boolean } -interface GetPropsWithRefKey { +export interface GetPropsWithRefKey { refKey?: string } -interface GetItemPropsOptions extends React.HTMLProps { +export interface GetItemPropsOptions extends React.HTMLProps { index?: number item: Item isSelected?: boolean disabled?: boolean } -interface GetItemPropsReturnValue { +export interface GetItemPropsReturnValue { 'aria-selected': boolean id: string onClick?: React.MouseEventHandler @@ -201,7 +198,7 @@ interface GetItemPropsReturnValue { role: 'option' } -interface PropGetters { +export interface PropGetters { getRootProps: ( options?: GetRootPropsOptions & Options, otherOptions?: GetPropsCommonOptions, @@ -224,7 +221,7 @@ interface PropGetters { ) => Omit, 'index' | 'item'> } -interface Actions { +export interface Actions { reset: ( otherStateToSet?: Partial>, cb?: Callback, @@ -262,19 +259,18 @@ interface Actions { stateToSet: Partial> | StateChangeFunction, cb?: Callback, ) => void - // props itemToString: (item: Item | null) => string } -type ControllerStateAndHelpers = DownshiftState & +export type ControllerStateAndHelpers = DownshiftState & PropGetters & Actions -type ChildrenFunction = ( +export type ChildrenFunction = ( options: ControllerStateAndHelpers, ) => React.ReactNode -declare class Downshift extends React.Component< +export default class Downshift extends React.Component< DownshiftProps > { static stateChangeTypes: { @@ -296,18 +292,18 @@ declare class Downshift extends React.Component< } } -declare function resetIdCounter(): void +export function resetIdCounter(): void /* useSelect Types */ -interface UseSelectState { +export interface UseSelectState { highlightedIndex: number selectedItem: Item | null isOpen: boolean inputValue: string } -declare enum UseSelectStateChangeTypes { +export enum UseSelectStateChangeTypes { ToggleButtonClick = '__togglebutton_click__', ToggleButtonKeyDownArrowDown = '__togglebutton_keydown_arrow_down__', ToggleButtonKeyDownArrowUp = '__togglebutton_keydown_arrow_up__', @@ -332,7 +328,7 @@ declare enum UseSelectStateChangeTypes { FunctionReset = '__function_reset__', } -interface UseSelectProps { +export interface UseSelectProps { items: Item[] isItemDisabled?(item: Item, index: number): boolean itemToString?: (item: Item | null) => string @@ -366,13 +362,13 @@ interface UseSelectProps { environment?: Environment } -interface UseSelectStateChangeOptions< +export interface UseSelectStateChangeOptions< Item, > extends UseSelectDispatchAction { changes: Partial> } -interface UseSelectDispatchAction { +export interface UseSelectDispatchAction { type: UseSelectStateChangeTypes altKey?: boolean key?: string @@ -382,37 +378,37 @@ interface UseSelectDispatchAction { inputValue?: string } -interface UseSelectStateChange extends Partial> { +export interface UseSelectStateChange extends Partial> { type: UseSelectStateChangeTypes } -interface UseSelectSelectedItemChange extends UseSelectStateChange { +export interface UseSelectSelectedItemChange extends UseSelectStateChange { selectedItem: Item | null } -interface UseSelectHighlightedIndexChange< +export interface UseSelectHighlightedIndexChange< Item, > extends UseSelectStateChange { highlightedIndex: number } -interface UseSelectIsOpenChange extends UseSelectStateChange { +export interface UseSelectIsOpenChange extends UseSelectStateChange { isOpen: boolean } -interface UseSelectGetMenuPropsOptions +export interface UseSelectGetMenuPropsOptions extends GetPropsWithRefKey, GetMenuPropsOptions {} -interface UseSelectGetMenuReturnValue extends GetMenuPropsReturnValue { +export interface UseSelectGetMenuReturnValue extends GetMenuPropsReturnValue { onMouseLeave: React.MouseEventHandler } -interface UseSelectGetToggleButtonPropsOptions +export interface UseSelectGetToggleButtonPropsOptions extends GetPropsWithRefKey, React.HTMLProps { onPress?: (event: React.BaseSyntheticEvent) => void } -interface UseSelectGetToggleButtonReturnValue extends Pick< +export interface UseSelectGetToggleButtonReturnValue extends Pick< GetToggleButtonPropsReturnValue, 'onBlur' | 'onClick' | 'onPress' | 'onKeyDown' > { @@ -427,21 +423,21 @@ interface UseSelectGetToggleButtonReturnValue extends Pick< tabIndex: 0 } -interface UseSelectGetLabelPropsOptions extends GetLabelPropsOptions {} +export interface UseSelectGetLabelPropsOptions extends GetLabelPropsOptions {} -interface UseSelectGetLabelPropsReturnValue extends GetLabelPropsReturnValue { +export interface UseSelectGetLabelPropsReturnValue extends GetLabelPropsReturnValue { onClick: React.MouseEventHandler } -interface UseSelectGetItemPropsOptions +export interface UseSelectGetItemPropsOptions extends Omit, 'disabled'>, GetPropsWithRefKey {} -interface UseSelectGetItemPropsReturnValue extends GetItemPropsReturnValue { +export interface UseSelectGetItemPropsReturnValue extends GetItemPropsReturnValue { 'aria-disabled': boolean ref?: React.RefObject } -interface UseSelectPropGetters { +export interface UseSelectPropGetters { getToggleButtonProps: ( options?: UseSelectGetToggleButtonPropsOptions & Options, otherOptions?: GetPropsCommonOptions, @@ -461,7 +457,7 @@ interface UseSelectPropGetters { > } -interface UseSelectActions { +export interface UseSelectActions { reset: () => void openMenu: () => void closeMenu: () => void @@ -470,11 +466,11 @@ interface UseSelectActions { setHighlightedIndex: (index: number) => void } -type UseSelectReturnValue = UseSelectState & +export type UseSelectReturnValue = UseSelectState & UseSelectPropGetters & UseSelectActions -interface UseSelectInterface { +export interface UseSelectInterface { (props: UseSelectProps): UseSelectReturnValue stateChangeTypes: { ToggleButtonClick: UseSelectStateChangeTypes.ToggleButtonClick @@ -502,18 +498,18 @@ interface UseSelectInterface { } } -declare const useSelect: UseSelectInterface +export const useSelect: UseSelectInterface /* useCombobox Types */ -interface UseComboboxState { +export interface UseComboboxState { highlightedIndex: number selectedItem: Item | null isOpen: boolean inputValue: string } -declare enum UseComboboxStateChangeTypes { +export enum UseComboboxStateChangeTypes { InputKeyDownArrowDown = '__input_keydown_arrow_down__', InputKeyDownArrowUp = '__input_keydown_arrow_up__', InputKeyDownEscape = '__input_keydown_escape__', @@ -539,7 +535,7 @@ declare enum UseComboboxStateChangeTypes { ControlledPropUpdatedSelectedItem = '__controlled_prop_updated_selected_item__', } -interface UseComboboxProps { +export interface UseComboboxProps { items: Item[] isItemDisabled?(item: Item, index: number): boolean itemToString?: (item: Item | null) => string @@ -578,13 +574,13 @@ interface UseComboboxProps { environment?: Environment } -interface UseComboboxStateChangeOptions< +export interface UseComboboxStateChangeOptions< Item, > extends UseComboboxDispatchAction { changes: Partial> } -interface UseComboboxDispatchAction { +export interface UseComboboxDispatchAction { type: UseComboboxStateChangeTypes altKey?: boolean inputValue?: string @@ -594,41 +590,41 @@ interface UseComboboxDispatchAction { selectItem?: boolean } -interface UseComboboxStateChange extends Partial> { +export interface UseComboboxStateChange extends Partial> { type: UseComboboxStateChangeTypes } -interface UseComboboxSelectedItemChange< +export interface UseComboboxSelectedItemChange< Item, > extends UseComboboxStateChange { selectedItem: Item | null } -interface UseComboboxHighlightedIndexChange< +export interface UseComboboxHighlightedIndexChange< Item, > extends UseComboboxStateChange { highlightedIndex: number } -interface UseComboboxIsOpenChange extends UseComboboxStateChange { +export interface UseComboboxIsOpenChange extends UseComboboxStateChange { isOpen: boolean } -interface UseComboboxInputValueChange< +export interface UseComboboxInputValueChange< Item, > extends UseComboboxStateChange { inputValue: string } -interface UseComboboxGetMenuPropsOptions +export interface UseComboboxGetMenuPropsOptions extends GetPropsWithRefKey, GetMenuPropsOptions {} -interface UseComboboxGetMenuPropsReturnValue extends UseSelectGetMenuReturnValue {} +export interface UseComboboxGetMenuPropsReturnValue extends UseSelectGetMenuReturnValue {} -interface UseComboboxGetToggleButtonPropsOptions +export interface UseComboboxGetToggleButtonPropsOptions extends GetPropsWithRefKey, GetToggleButtonPropsOptions {} -interface UseComboboxGetToggleButtonPropsReturnValue { +export interface UseComboboxGetToggleButtonPropsReturnValue { 'aria-controls': string 'aria-expanded': boolean id: string @@ -638,22 +634,22 @@ interface UseComboboxGetToggleButtonPropsReturnValue { tabIndex: -1 } -interface UseComboboxGetLabelPropsOptions extends GetLabelPropsOptions {} +export interface UseComboboxGetLabelPropsOptions extends GetLabelPropsOptions {} -interface UseComboboxGetLabelPropsReturnValue extends GetLabelPropsReturnValue {} +export interface UseComboboxGetLabelPropsReturnValue extends GetLabelPropsReturnValue {} -interface UseComboboxGetItemPropsOptions +export interface UseComboboxGetItemPropsOptions extends Omit, 'disabled'>, GetPropsWithRefKey {} -interface UseComboboxGetItemPropsReturnValue extends GetItemPropsReturnValue { +export interface UseComboboxGetItemPropsReturnValue extends GetItemPropsReturnValue { 'aria-disabled': boolean ref?: React.RefObject } -interface UseComboboxGetInputPropsOptions +export interface UseComboboxGetInputPropsOptions extends GetInputPropsOptions, GetPropsWithRefKey {} -interface UseComboboxGetInputPropsReturnValue extends GetInputPropsReturnValue { +export interface UseComboboxGetInputPropsReturnValue extends GetInputPropsReturnValue { 'aria-activedescendant': string 'aria-controls': string 'aria-expanded': boolean @@ -661,7 +657,7 @@ interface UseComboboxGetInputPropsReturnValue extends GetInputPropsReturnValue { onClick: React.MouseEventHandler } -interface UseComboboxPropGetters { +export interface UseComboboxPropGetters { getToggleButtonProps: ( options?: UseComboboxGetToggleButtonPropsOptions & Options, ) => Overwrite @@ -684,7 +680,7 @@ interface UseComboboxPropGetters { ) => Overwrite } -interface UseComboboxActions { +export interface UseComboboxActions { reset: () => void openMenu: () => void closeMenu: () => void @@ -694,11 +690,11 @@ interface UseComboboxActions { setInputValue: (inputValue: string) => void } -type UseComboboxReturnValue = UseComboboxState & +export type UseComboboxReturnValue = UseComboboxState & UseComboboxPropGetters & UseComboboxActions -interface UseComboboxInterface { +export interface UseComboboxInterface { (props: UseComboboxProps): UseComboboxReturnValue stateChangeTypes: { InputKeyDownArrowDown: UseComboboxStateChangeTypes.InputKeyDownArrowDown @@ -727,16 +723,16 @@ interface UseComboboxInterface { } } -declare const useCombobox: UseComboboxInterface +export const useCombobox: UseComboboxInterface // useMultipleSelection types. -interface UseMultipleSelectionState { +export interface UseMultipleSelectionState { selectedItems: Item[] activeIndex: number } -declare enum UseMultipleSelectionStateChangeTypes { +export enum UseMultipleSelectionStateChangeTypes { SelectedItemClick = '__selected_item_click__', SelectedItemKeyDownDelete = '__selected_item_keydown_delete__', SelectedItemKeyDownBackspace = '__selected_item_keydown_backspace__', @@ -752,7 +748,7 @@ declare enum UseMultipleSelectionStateChangeTypes { FunctionReset = '__function_reset__', } -interface UseMultipleSelectionProps { +export interface UseMultipleSelectionProps { selectedItems?: Item[] initialSelectedItems?: Item[] defaultSelectedItems?: Item[] @@ -777,13 +773,13 @@ interface UseMultipleSelectionProps { environment?: Environment } -interface UseMultipleSelectionStateChangeOptions< +export interface UseMultipleSelectionStateChangeOptions< Item, > extends UseMultipleSelectionDispatchAction { changes: Partial> } -interface UseMultipleSelectionDispatchAction { +export interface UseMultipleSelectionDispatchAction { type: UseMultipleSelectionStateChangeTypes index?: number selectedItem?: Item | null @@ -791,25 +787,25 @@ interface UseMultipleSelectionDispatchAction { activeIndex?: number } -interface UseMultipleSelectionStateChange extends Partial< +export interface UseMultipleSelectionStateChange extends Partial< UseMultipleSelectionState > { type: UseMultipleSelectionStateChangeTypes } -interface UseMultipleSelectionActiveIndexChange< +export interface UseMultipleSelectionActiveIndexChange< Item, > extends UseMultipleSelectionStateChange { activeIndex: number } -interface UseMultipleSelectionSelectedItemsChange< +export interface UseMultipleSelectionSelectedItemsChange< Item, > extends UseMultipleSelectionStateChange { selectedItems: Item[] } -interface A11yRemovalMessage { +export interface A11yRemovalMessage { itemToString: (item: Item) => string resultCount: number activeSelectedItem: Item @@ -817,30 +813,30 @@ interface A11yRemovalMessage { activeIndex: number } -interface UseMultipleSelectionGetSelectedItemPropsOptions +export interface UseMultipleSelectionGetSelectedItemPropsOptions extends React.HTMLProps, GetPropsWithRefKey { index?: number selectedItem: Item } -interface UseMultipleSelectionGetSelectedItemReturnValue { +export interface UseMultipleSelectionGetSelectedItemReturnValue { ref?: React.RefObject tabIndex: 0 | -1 onClick: React.MouseEventHandler onKeyDown: React.KeyboardEventHandler } -interface UseMultipleSelectionGetDropdownPropsOptions extends React.HTMLProps { +export interface UseMultipleSelectionGetDropdownPropsOptions extends React.HTMLProps { preventKeyAction?: boolean } -interface UseMultipleSelectionGetDropdownReturnValue { +export interface UseMultipleSelectionGetDropdownReturnValue { ref?: React.RefObject onClick?: React.MouseEventHandler onKeyDown?: React.KeyboardEventHandler } -interface UseMultipleSelectionPropGetters { +export interface UseMultipleSelectionPropGetters { getDropdownProps: ( options?: UseMultipleSelectionGetDropdownPropsOptions & Options, extraOptions?: GetPropsCommonOptions, @@ -856,7 +852,7 @@ interface UseMultipleSelectionPropGetters { > } -interface UseMultipleSelectionActions { +export interface UseMultipleSelectionActions { reset: () => void addSelectedItem: (item: Item) => void removeSelectedItem: (item: Item) => void @@ -864,11 +860,11 @@ interface UseMultipleSelectionActions { setActiveIndex: (index: number) => void } -type UseMultipleSelectionReturnValue = UseMultipleSelectionState & +export type UseMultipleSelectionReturnValue = UseMultipleSelectionState & UseMultipleSelectionPropGetters & UseMultipleSelectionActions -interface UseMultipleSelectionInterface { +export interface UseMultipleSelectionInterface { ( props?: UseMultipleSelectionProps, ): UseMultipleSelectionReturnValue @@ -889,4 +885,4 @@ interface UseMultipleSelectionInterface { } } -declare const useMultipleSelection: UseMultipleSelectionInterface +export const useMultipleSelection: UseMultipleSelectionInterface From de1dc3c079fcc93523d7b654d7a5e5a80da8f11c Mon Sep 17 00:00:00 2001 From: Silviu Alexandru Avram Date: Sun, 14 Dec 2025 17:35:19 +0200 Subject: [PATCH 35/40] fix initial focus --- docusaurus/pages/useTagGroupCombobox.tsx | 2 +- .../__tests__/getTagGroupProps.test.ts | 38 ++++++++++++++++++- src/hooks/useTagGroup/__tests__/props.test.ts | 10 +++++ src/hooks/useTagGroup/index.ts | 23 ++++------- 4 files changed, 55 insertions(+), 18 deletions(-) diff --git a/docusaurus/pages/useTagGroupCombobox.tsx b/docusaurus/pages/useTagGroupCombobox.tsx index 4d8c04f77..ba5771d03 100644 --- a/docusaurus/pages/useTagGroupCombobox.tsx +++ b/docusaurus/pages/useTagGroupCombobox.tsx @@ -1,6 +1,6 @@ import * as React from 'react' -import {useTagGroup, useCombobox} from '../..' +import {useTagGroup, useCombobox} from '../../src' import {colors} from '../utils' import './useTagGroupCombobox.css' diff --git a/src/hooks/useTagGroup/__tests__/getTagGroupProps.test.ts b/src/hooks/useTagGroup/__tests__/getTagGroupProps.test.ts index 35cfe56fb..9865eecde 100644 --- a/src/hooks/useTagGroup/__tests__/getTagGroupProps.test.ts +++ b/src/hooks/useTagGroup/__tests__/getTagGroupProps.test.ts @@ -1,4 +1,10 @@ -import {act, screen, defaultProps, renderTagGroup, renderUseTagGroup} from './utils' +import { + act, + screen, + defaultProps, + renderTagGroup, + renderUseTagGroup, +} from './utils' // We are using React 18. jest.mock('react', () => { @@ -158,6 +164,7 @@ describe('getTagGroupProps', () => { screen.queryByRole('tag', {name: defaultProps.initialItems[2]}), ).not.toBeInTheDocument() expect(getTags()[2]).toHaveAttribute('tabindex', '0') + expect(getTags()[2]).toHaveFocus() }) test('delete removes the active last item and the second to last item becomes active', async () => { @@ -169,6 +176,7 @@ describe('getTagGroupProps', () => { await user.keyboard('{Delete}') expect(getTags()[tagsCount - 2]).toHaveAttribute('tabindex', '0') + expect(getTags()[tagsCount - 2]).toHaveFocus() }) test('delete removes the only active item', async () => { @@ -183,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 @@ -197,6 +205,32 @@ describe('getTagGroupProps', () => { expect( screen.queryByRole('tag', {name: defaultProps.initialItems[2]}), ).not.toBeInTheDocument() + expect(getTags()[2]).toHaveAttribute('tabindex', '0') + expect(getTags()[2]).toHaveFocus() + }) + + test('backspace removes the active last item and the second to last item becomes active', async () => { + const {clickOnTag, user, getTags} = renderTagGroup() + + const tagsCount = getTags().length + + await clickOnTag(tagsCount - 1) + await user.keyboard('{Backspace}') + + expect(getTags()[tagsCount - 2]).toHaveAttribute('tabindex', '0') + expect(getTags()[tagsCount - 2]).toHaveFocus() + }) + + test('backspace removes the only active item', async () => { + const {clickOnTag, user, queryByRole} = renderTagGroup({ + initialItems: [defaultProps.initialItems[0] as string], + }) + + await clickOnTag(0) + await user.keyboard('{Backspace}') + + // eslint-disable-next-line testing-library/prefer-screen-queries + expect(queryByRole('tag')).not.toBeInTheDocument() }) test('any other key does nothing', async () => { diff --git a/src/hooks/useTagGroup/__tests__/props.test.ts b/src/hooks/useTagGroup/__tests__/props.test.ts index 7ea470b08..216fcdb67 100644 --- a/src/hooks/useTagGroup/__tests__/props.test.ts +++ b/src/hooks/useTagGroup/__tests__/props.test.ts @@ -28,6 +28,16 @@ describe('props', () => { expect(tags[2]).toHaveAttribute('tabindex', '-1') }) + test('initialActiveIndex does not focus active item at mount', () => { + const {getTags} = renderTagGroup({ + initialActiveIndex: 1, + }) + + const tags = getTags() + + expect(tags[1]).not.toHaveFocus() + }) + test('activeIndex controls the activeIndex state', async () => { const {getTags, clickOnTag} = renderTagGroup({ activeIndex: 1, diff --git a/src/hooks/useTagGroup/index.ts b/src/hooks/useTagGroup/index.ts index 712e5a0c3..4b14ee5c2 100644 --- a/src/hooks/useTagGroup/index.ts +++ b/src/hooks/useTagGroup/index.ts @@ -6,7 +6,7 @@ import { useLatestRef, validatePropTypes, } from '../../utils-ts' -import {useControlledReducer, useIsInitialMount} from '../utils-ts' +import {useControlledReducer} from '../utils-ts' import * as stateChangeTypes from './stateChangeTypes' import { GetTagGroupPropsOptions, @@ -61,8 +61,8 @@ const useTagGroup: UseTagGroupInterface = ( tagGroupId: props.tagGroupId, }) const itemRefs = useRef>({}) + const previousActiveIndexRef = useRef(activeIndex) const previousItemsLengthRef = useRef(items.length) - const isInitialMount = useIsInitialMount() /* Effects */ @@ -72,25 +72,18 @@ const useTagGroup: UseTagGroupInterface = ( ) useEffect(() => { - if (isInitialMount) { - return - } - - if (previousItemsLengthRef.current < items.length) { - return - } - if ( - activeIndex >= 0 && - activeIndex < Object.keys(itemRefs.current).length + (activeIndex !== -1 && + previousActiveIndexRef.current !== -1 && + activeIndex !== previousActiveIndexRef.current) || + previousItemsLengthRef.current === items.length + 1 ) { itemRefs.current[elementIds.getTagId(activeIndex)]?.focus() } - }, [activeIndex, elementIds, isInitialMount, items.length]) - useEffect(() => { + previousActiveIndexRef.current = activeIndex previousItemsLengthRef.current = items.length - }) + }, [activeIndex, elementIds, items]) /* Getter functions */ From c74dfc8c1874341da49df0e43710f1f6b82a1874 Mon Sep 17 00:00:00 2001 From: Silviu Alexandru Avram Date: Thu, 15 Jan 2026 19:12:50 +0200 Subject: [PATCH 36/40] add onChange --- docusaurus/pages/useCombobox.js | 6 - docusaurus/pages/useMultipleCombobox.js | 8 +- src/hooks/useTagGroup/__tests__/props.test.ts | 128 +++++++++++++++++- src/hooks/useTagGroup/index.types.ts | 31 ++++- src/hooks/useTagGroup/utils/getMergedProps.ts | 2 +- 5 files changed, 153 insertions(+), 22 deletions(-) diff --git a/docusaurus/pages/useCombobox.js b/docusaurus/pages/useCombobox.js index 04ab50b44..c5a25c8b0 100644 --- a/docusaurus/pages/useCombobox.js +++ b/docusaurus/pages/useCombobox.js @@ -24,12 +24,6 @@ export default function DropdownCombobox() { ), ) }, - onSelectedItemChange(changes) { - console.log(changes) - }, - onIsOpenChange(changes) { - console.log(changes) - } }) return (
    diff --git a/docusaurus/pages/useMultipleCombobox.js b/docusaurus/pages/useMultipleCombobox.js index d33c79493..d63a71230 100644 --- a/docusaurus/pages/useMultipleCombobox.js +++ b/docusaurus/pages/useMultipleCombobox.js @@ -5,8 +5,8 @@ import { colors, containerStyles, menuStyles, - selectedItemsContainerSyles, - selectedItemStyles, + tagGroupSyles, + tagStyles, } from '../utils' const initialSelectedItems = [colors[0], colors[1]] @@ -105,14 +105,14 @@ export default function DropdownMultipleCombobox() { > Choose an element: -
    +
    {selectedItems.map(function renderSelectedItem( selectedItemForRender, index, ) { return ( { } as unknown as Document, addEventListener: jest.fn(), removeEventListener: jest.fn(), - Node: {} as unknown as (typeof window.Node) + Node: {} as unknown as typeof window.Node, } const {unmount} = renderTagGroup({ environment, @@ -311,16 +312,131 @@ describe('props', () => { removeElementDescription, }) - // eslint-disable-next-line testing-library/prefer-screen-queries expect(getByText(removeElementDescription)).toBeInTheDocument() - // eslint-disable-next-line testing-library/prefer-screen-queries - expect(queryByText('Press Delete 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() - // eslint-disable-next-line testing-library/prefer-screen-queries - expect(getByText('Press Delete to remove tag.')).toBeInTheDocument() + expect(getByText('Press Delete or Backspace to remove tag.')).toBeInTheDocument() + }) + + test('onStateChange is called after adding an item', () => { + const onStateChange = jest.fn() + const {result} = renderUseTagGroup({onStateChange}) + + act(() => { + result.current.addItem('test') + }) + + expect(onStateChange).toHaveBeenCalledTimes(1) + expect(onStateChange).toHaveBeenNthCalledWith(1, { + items: [...defaultProps.initialItems, 'test'], + type: useTagGroup.stateChangeTypes.FunctionAddItem, + }) + }) + + test('onStateChange is called after each user action', async () => { + const onStateChange = jest.fn() + const {clickOnTag, clickOnRemoveTag, user} = renderTagGroup({onStateChange}) + + await clickOnTag(2) + + expect(onStateChange).toHaveBeenCalledTimes(1) + expect(onStateChange).toHaveBeenCalledWith({ + type: useTagGroup.stateChangeTypes.TagClick, + activeIndex: 2, + }) + + await clickOnRemoveTag(4) + + expect(onStateChange).toHaveBeenCalledTimes(2) + expect(onStateChange).toHaveBeenNthCalledWith(2, { + type: useTagGroup.stateChangeTypes.TagRemoveClick, + activeIndex: 4, + items: [ + ...defaultProps.initialItems.slice(0, 4), + ...defaultProps.initialItems.slice(5), + ], + }) + + await user.keyboard('{ArrowLeft}') + + expect(onStateChange).toHaveBeenCalledTimes(3) + expect(onStateChange).toHaveBeenNthCalledWith(3, { + type: useTagGroup.stateChangeTypes.TagGroupKeyDownArrowLeft, + activeIndex: 3, + }) + + await user.keyboard('{ArrowRight}') + + expect(onStateChange).toHaveBeenCalledTimes(4) + expect(onStateChange).toHaveBeenNthCalledWith(4, { + type: useTagGroup.stateChangeTypes.TagGroupKeyDownArrowRight, + activeIndex: 4, + }) + + await user.keyboard('{Delete}') + + expect(onStateChange).toHaveBeenCalledTimes(5) + expect(onStateChange).toHaveBeenNthCalledWith(5, { + type: useTagGroup.stateChangeTypes.TagGroupKeyDownDelete, + items: [ + ...defaultProps.initialItems.slice(0, 4), + ...defaultProps.initialItems.slice(6), + ], + }) + + await user.keyboard('{Backspace}') + + expect(onStateChange).toHaveBeenCalledTimes(6) + expect(onStateChange).toHaveBeenNthCalledWith(6, { + type: useTagGroup.stateChangeTypes.TagGroupKeyDownBackspace, + items: [ + ...defaultProps.initialItems.slice(0, 4), + ...defaultProps.initialItems.slice(7), + ], + }) + }) + + test('onActiveIndexChange is called after active index changes', async () => { + const onActiveIndexChange = jest.fn() + const {clickOnTag, clickOnRemoveTag} = renderTagGroup({onActiveIndexChange}) + + await clickOnTag(2) + + expect(onActiveIndexChange).toHaveBeenCalledTimes(1) + expect(onActiveIndexChange).toHaveBeenCalledWith({ + type: useTagGroup.stateChangeTypes.TagClick, + activeIndex: 2, + items: defaultProps.initialItems, + }) + + onActiveIndexChange.mockClear() + await clickOnRemoveTag(2) + + expect(onActiveIndexChange).not.toHaveBeenCalled() + }) + + test('onItemsChange is called after items change', async () => { + const onItemsChange = jest.fn() + const {clickOnTag, clickOnRemoveTag} = renderTagGroup({onItemsChange}) + + await clickOnTag(2) + + expect(onItemsChange).not.toHaveBeenCalled() + + await clickOnRemoveTag(2) + + expect(onItemsChange).toHaveBeenCalledTimes(1) + expect(onItemsChange).toHaveBeenCalledWith({ + type: useTagGroup.stateChangeTypes.TagRemoveClick, + activeIndex: 2, + items: [ + ...defaultProps.initialItems.slice(0, 2), + ...defaultProps.initialItems.slice(3), + ], + }) }) }) diff --git a/src/hooks/useTagGroup/index.types.ts b/src/hooks/useTagGroup/index.types.ts index 122ccef94..b2f000cc1 100644 --- a/src/hooks/useTagGroup/index.types.ts +++ b/src/hooks/useTagGroup/index.types.ts @@ -12,22 +12,43 @@ export interface Environment { Node: typeof window.Node } +export interface UseTagGroupStateChange extends Partial< + UseTagGroupState +> { + type: UseTagGroupStateChangeTypes +} + +export interface UseTagGroupActiveIndexChange< + Item, +> extends UseTagGroupStateChange { + activeIndex: number +} + +export interface UseTagGroupItemsChange< + Item, +> extends UseTagGroupStateChange { + items: Item[] +} + export interface UseTagGroupProps extends Partial< UseTagGroupState > { - initialActiveIndex?: number - initialItems?: Item[] - tagGroupId?: string + environment?: Environment getTagId?: (index: number) => string id?: string + initialActiveIndex?: number + initialItems?: Item[] + onActiveIndexChange?: (changes: UseTagGroupActiveIndexChange) => void + onItemsChange?: (changes: UseTagGroupItemsChange) => void + onStateChange?: (changes: UseTagGroupStateChange) => void + removeElementDescription?: string stateReducer?( state: UseTagGroupState, actionAndChanges: Action & { changes: Partial> }, ): Partial> - environment?: Environment - removeElementDescription?: string + tagGroupId?: string } export type UseTagGroupMergedProps = Required< diff --git a/src/hooks/useTagGroup/utils/getMergedProps.ts b/src/hooks/useTagGroup/utils/getMergedProps.ts index 2af2f4b63..811956e42 100644 --- a/src/hooks/useTagGroup/utils/getMergedProps.ts +++ b/src/hooks/useTagGroup/utils/getMergedProps.ts @@ -12,7 +12,7 @@ export function getMergedProps( environment: /* istanbul ignore next (ssr) */ typeof window === 'undefined' || isReactNative ? undefined : window, - removeElementDescription: 'Press Delete to remove tag.', + removeElementDescription: 'Press Delete or Backspace to remove tag.', ...userProps, } } From e0562e879152e44a76f69ac555ec85e25deeda65 Mon Sep 17 00:00:00 2001 From: Silviu Alexandru Avram Date: Thu, 15 Jan 2026 19:13:01 +0200 Subject: [PATCH 37/40] wip readme --- src/hooks/useTagGroup/README.md | 771 ++++++++++++++++++++++++++++++++ 1 file changed, 771 insertions(+) create mode 100644 src/hooks/useTagGroup/README.md diff --git a/src/hooks/useTagGroup/README.md b/src/hooks/useTagGroup/README.md new file mode 100644 index 000000000..175c96ebd --- /dev/null +++ b/src/hooks/useTagGroup/README.md @@ -0,0 +1,771 @@ +# useSelect + +## The problem + +You want to build a tag group component in your app that's accessible and offers +a great user experience. There is no dedicated ARIA design pattern for this +component, but since it's widely used, we compiled the list of specifications +and implemented them through a React hook that's compliant with Downshift's +principles. + +## This solution + +`useTagGroup` is a React hook that manages all the stateful logic needed to make +the tag group functional and accessible. It returns a set of props that are +meant to be called and their results destructured on the tag group's elements: +its container, tag item and tag remove button. The props are similar to the ones +provided by vanilla `` to the children render prop. + +These props are called getter props, and their return values are destructured as +a set of ARIA attributes and event listeners. Together with the action props and +state props, they create all the stateful logic needed for the tag group to +implement the list of requirements. Every functionality needed should be +provided out-of-the-box: item removal and selection, and left/right arrow +movement between items, screen reader support etc. + +## Table of Contents + + + + +- [Usage](#usage) +- [Basic Props](#basic-props) + - [items](#items) + - [itemToString](#itemtostring) + - [onSelectedItemChange](#onselecteditemchange) + - [stateReducer](#statereducer) +- [Advanced Props](#advanced-props) + - [isItemDisabled](#isitemdisabled) + - [initialSelectedItem](#initialselecteditem) + - [initialIsOpen](#initialisopen) + - [initialHighlightedIndex](#initialhighlightedindex) + - [defaultSelectedItem](#defaultselecteditem) + - [defaultIsOpen](#defaultisopen) + - [defaultHighlightedIndex](#defaulthighlightedindex) + - [itemToKey](#itemtokey) + - [getA11yStatusMessage](#geta11ystatusmessage) + - [onHighlightedIndexChange](#onhighlightedindexchange) + - [onIsOpenChange](#onisopenchange) + - [onStateChange](#onstatechange) + - [highlightedIndex](#highlightedindex) + - [isOpen](#isopen) + - [selectedItem](#selecteditem) + - [id](#id) + - [labelId](#labelid) + - [menuId](#menuid) + - [toggleButtonId](#togglebuttonid) + - [getItemId](#getitemid) + - [environment](#environment) +- [stateChangeTypes](#statechangetypes) +- [Control Props](#control-props) +- [Returned props](#returned-props) + - [prop getters](#prop-getters) + - [actions](#actions) + - [state](#state) +- [Event Handlers](#event-handlers) + - [Default handlers](#default-handlers) + - [Customizing Handlers](#customizing-handlers) +- [Examples](#examples) + + + +## Usage + +> [Try it out in the browser][sandbox-example] + +```jsx +import * as React from 'react' +import {render} from 'react-dom' +import {useSelect} from 'downshift' + +const colors = [ + 'Black', + 'Red', + 'Green', + 'Blue', + 'Orange', + 'Purple', + 'Pink', + 'Orchid', + 'Aqua', + 'Lime', + 'Gray', + 'Brown', + 'Teal', + 'Skyblue', +] + +function TagGroup() { + const initialItems = colors.slice(0, 5) + const { + addItem, + getTagProps, + getTagRemoveProps, + getTagGroupProps, + items, + activeIndex, + } = useTagGroup({initialItems}) + + const itemsToAdd = colors.filter(color => !items.includes(color)) + + return ( +
    +
    + {items.map((color, index) => ( + + {color} + + + ))} +
    + +
    Add more items:
    + +
      + {itemsToAdd.map(item => ( +
    • + +
    • + ))} +
    +
    + ) +} + +render(, document.getElementById('root')) +``` + +## Basic Props + +This is the list of props that you should probably know about. There are some +[advanced props](#advanced-props) below as well. + +### removeElementDescription + +> `string` | defaults to: `"Press Delete or Backspace to remove tag."` + +An accessible description that gets added to the DOM in an invisible element and +gets picked up by screen readers when encountering tags. It should instruct +users how to remove tags with the keyboard. Useful for localized messages. + +### onItemsChange + +> `function(changes: object)` | optional, no useful default + +Called each time the items in state changed. Adding items can be done using +`addItem`, while removing items could be done with mouse and keyboard actions. + +- `changes`: These are the properties that actually have changed since the last + state change. This object is guaranteed to contain the `items` property with + the newly selected value. This also has a `type` property which you can learn + more about in the [`stateChangeTypes`](#statechangetypes) section. This + property will be part of the actions that can trigger a `items` change, for + example `useTagGroup.stateChangeTypes.FunctionAddItem`. + +### stateReducer + +> `function(state: object, actionAndChanges: object)` | optional + +**🚨 This is a really handy power feature 🚨** + +This function will be called each time `useTagGroup` sets its internal state (or +calls your `onStateChange` handler for control props). It allows you to modify +the state change that will take place which can give you fine grain control over +how the component interacts with user updates. It gives you the current state +and the state that will be set, and you return the state that you want to set. + +- `state`: The full current state of useTagGroup. +- `actionAndChanges`: Object that contains the action `type`, props needed to + return a new state based on that type and the changes suggested by the + useTagGroup default reducer. About the `type` property you can learn more + about in the [`stateChangeTypes`](#statechangetypes) section. + +```javascript +import {useTagGroup} from 'downshift' +import {items} from './utils' + +const {getTagGroupProps, getTagProps, getTagRemoveProps, ...rest} = useTagGroup( + { + initialItems: items.slice(0, 4), + stateReducer, + }, +) + +function stateReducer(state, actionAndChanges) { + const {type, changes} = actionAndChanges + // resets active item to the first when removing an item + switch (type) { + case useSelect.stateChangeTypes.TagGroupKeyDownBackspace: + case useSelect.stateChangeTypes.TagGroupKeyDownDelete: + return { + ...changes, // default tagGroup new state changes on item removal. + activeIndex: changes.items.length === 0 ? -1 : 0, + } + default: + return changes // otherwise business as usual. + } +} +``` + +> NOTE: This is only called when state actually changes. You should not attempt +> use this to handle events. If you wish to handle events, put your event +> handlers directly on the elements (make sure to use the prop getters though! +> For example ` +
    +
    +) +``` + +> NOTE: In this example we used both a getter prop `getTagGroupProps` and an +> action prop `addItem`. The properties of `useTagGroup` can be split into three +> categories as indicated below: + +### prop getters + +> See [the blog post about prop getters][blog-post-prop-getters] + +> NOTE: These prop-getters provide `aria-` attributes which are very important +> to your component being accessible. It's recommended that you utilize these +> functions and apply the props they give you to your components. + +These functions are used to apply props to the elements that you render. This +gives you maximum flexibility to render what, when, and wherever you like. You +call these on the element in question, for example on the toggle button: +` + +``` + +Required properties: + +- `index`: This is how `useTagRemoveGroup` keeps track of your item when + labelling the remove button. By default, `useTagRemoveGroup` will assume the + `index` is the order in which you're calling `getItemProps`. This is often + good enough, but if you find odd behavior, try setting this explicitly. It's + probably best to be explicit about `index` when using a windowing library like + `react-virtualized`. + +Optional properties: + +- `ref`: if you need to access the item element via a ref object, you'd call the + function like this: `getTagRemoveProps({ref: yourTagRef})`. As a result, the + tag element will receive a composed `ref` property, which guarantees that both + your code and `useSelect` use the same correct reference to the element. + +- `refKey`: if you're rendering a composite component, that component will need + to accept a prop which it forwards to the root DOM element. Commonly, folks + call this `innerRef`. So you'd call: `getTagRemoveProps({refKey: 'innerRef'})` + and your composite component would forward like: + `
  • Downshift