diff --git a/src/components/List/List.stories.tsx b/src/components/List/List.stories.tsx index 85671af24..f942209fa 100644 --- a/src/components/List/List.stories.tsx +++ b/src/components/List/List.stories.tsx @@ -1,6 +1,14 @@ /* eslint-disable react/no-multi-comp */ /* eslint-disable react/display-name */ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, + Dispatch, + SetStateAction, +} from 'react'; import { MultiTemplate, Template } from '../../storybook/helper.stories.templates'; import { DocumentationPage } from '../../storybook/helper.stories.docs'; import StyleDocs from '../../storybook/docs.stories.style.mdx'; @@ -26,7 +34,14 @@ import ButtonHyperlink from '../ButtonHyperlink'; import Badge from '../Badge'; import Menu from '../Menu'; import { Item } from '@react-stately/collections'; -import { AriaToolbar, AriaToolbarItem, ListItemBaseSection, MenuTrigger, SearchInput } from '..'; +import { + AriaToolbar, + AriaToolbarItem, + ButtonSimple, + ListItemBaseSection, + MenuTrigger, + SearchInput, +} from '..'; import { omit } from 'lodash'; import { ListRefObject } from './List.types'; @@ -649,6 +664,82 @@ const ListWithTextSelectWrapper = () => { const ListWithTextSelect = Template(ListWithTextSelectWrapper).bind({}); +const ListWithNonDefaultSetNextFocusWrapper = () => { + const [itemIndices, setItemIndices]: any[] = useState(['a', 'b', 'c', 'd', 'e', 'f', 'g']); + const [itemIndexType, setItemIndexType] = useState('string'); + + const setNextFocus = useCallback( + ( + isBackward: boolean, + listSize: number, + currentFocus: number | string, + noLoop: boolean, + setFocus: Dispatch> + ) => { + const currentIndex = itemIndices.indexOf(currentFocus); + + let nextIndex: number; + + if (isBackward) { + nextIndex = (listSize + currentIndex - 1) % listSize; + + if (noLoop && nextIndex > currentIndex) { + return; + } + } else { + nextIndex = (listSize + currentIndex + 1) % listSize; + + if (noLoop && nextIndex < currentIndex) { + return; + } + } + + setFocus(itemIndices[nextIndex]); + }, + [itemIndices] + ); + + const listItems = itemIndices.map((value, index) => ( + + {`Item: ${value}`} + + )); + + return ( + <> + + {listItems} + + setItemIndices(['c', 'd', 'e', 'f', 'g'])}> + Remove First Two Items + + setItemIndices(['a', 'b', 'c', 'd', 'e'])}> + Remove Last Two Items + + setItemIndices(['a', 'b', 'c', 'd', 'e', 'f', 'g'])}> + Reset + + setItemIndexType(itemIndexType === 'string' ? 'number' : 'string')} + > + {`Swap itemIndexType, current type: ${itemIndexType}`} + + + ); +}; + +const ListWithNonDefaultSetNextFocus = Template( + ListWithNonDefaultSetNextFocusWrapper +).bind({}); + export { Example, Common, @@ -667,4 +758,5 @@ export { DynamicListWithInitialFocus3, SingleItemList, ListWithTextSelect, + ListWithNonDefaultSetNextFocus, }; diff --git a/src/components/List/List.tsx b/src/components/List/List.tsx index 3af81e5d6..9164bb573 100644 --- a/src/components/List/List.tsx +++ b/src/components/List/List.tsx @@ -21,6 +21,7 @@ const List = forwardRef((props: Props, ref: RefObject) => { noLoop, orientation = DEFAULTS.ORIENTATION, initialFocus = DEFAULTS.INITIAL_FOCUS, + setNextFocus, ...rest } = props; @@ -29,6 +30,7 @@ const List = forwardRef((props: Props, ref: RefObject) => { orientation, noLoop, initialFocus, + setNextFocus, contextProps: { shouldFocusOnPress, shouldItemFocusBeInset }, }); diff --git a/src/components/List/List.types.ts b/src/components/List/List.types.ts index 8e3b7dad5..b09692f27 100644 --- a/src/components/List/List.types.ts +++ b/src/components/List/List.types.ts @@ -64,14 +64,25 @@ export interface Props { /** * The index of the item that should be focused initially */ - initialFocus?: number; + initialFocus?: number | string; + + /** + * Optional function to control the focus behavior when up/down keys are pressed + */ + setNextFocus?: ( + isBackward: boolean, + listSize: number, + currentFocus: number | string, + noLoop: boolean, + setFocus: Dispatch> + ) => void; } export interface ListContextValue { - currentFocus?: number; + currentFocus?: number | string; shouldFocusOnPress?: boolean; shouldItemFocusBeInset?: boolean; - setCurrentFocus?: Dispatch>; + setCurrentFocus?: Dispatch>; listSize?: number; noLoop?: boolean; updateFocusBlocked?: boolean; @@ -81,5 +92,5 @@ export interface ListContextValue { export interface ListRefObject { listRef: React.RefObject; focusOnIndex: (index: number) => void; - getCurrentFocusIndex: () => number; + getCurrentFocusIndex: () => number | string; } diff --git a/src/components/ListItemBase/ListItemBase.tsx b/src/components/ListItemBase/ListItemBase.tsx index 0e423cf68..19bd136b2 100644 --- a/src/components/ListItemBase/ListItemBase.tsx +++ b/src/components/ListItemBase/ListItemBase.tsx @@ -23,6 +23,7 @@ import { useMutationObservable } from '../../hooks/useMutationObservable'; import { usePrevious } from '../../hooks/usePrevious'; import { getKeyboardFocusableElements } from '../../utils/navigation'; import { useFocusAndFocusWithinState } from '../../hooks/useFocusState'; +import { isNumber } from 'lodash'; type RefOrCallbackRef = RefObject | ((instance: HTMLLIElement) => void); @@ -265,9 +266,10 @@ const ListItemBase = (props: Props, providedRef: RefOrCallbackRef) => { * we want to silently update the currentFocus back to the first element in * case the index of the element focused before the list shrink is now outside * the size of the new list size (shrinked size) + * Only works with numeric itemIndex */ useLayoutEffect(() => { - if (!!listSize && currentFocus >= listSize) { + if (!!listSize && isNumber(currentFocus) && currentFocus >= listSize) { // set focus to last item listContext.setCurrentFocus(listSize - 1); updateTabIndexes(); diff --git a/src/hooks/useOrientationBasedKeyboardNavigation.ts b/src/hooks/useOrientationBasedKeyboardNavigation.ts index cbe1e216c..81fe79ebc 100644 --- a/src/hooks/useOrientationBasedKeyboardNavigation.ts +++ b/src/hooks/useOrientationBasedKeyboardNavigation.ts @@ -7,7 +7,7 @@ import { useState, } from 'react'; import { useKeyboard } from '@react-aria/interactions'; -import { setNextFocus } from '../components/List/List.utils'; +import { setNextFocus as defaultSetNextFocus } from '../components/List/List.utils'; import { ListOrientation } from '../components/List/List.types'; import { useFocusWithinState } from './useFocusState'; @@ -16,8 +16,8 @@ type IUseOrientationBasedKeyboardNavigationReturn = { focusWithinProps: HTMLAttributes; getContext: () => { listSize: number; - currentFocus: number; - setCurrentFocus: Dispatch>; + currentFocus: number | string; + setCurrentFocus: Dispatch>; shouldFocusOnPress?: boolean; shouldItemFocusBeInset?: boolean; noLoop?: boolean; @@ -30,7 +30,14 @@ export type IUseOrientationBasedKeyboardNavigationProps = { listSize: number; orientation: ListOrientation; noLoop?: boolean; - initialFocus?: number; + initialFocus?: number | string; + setNextFocus?: ( + isBackward: boolean, + listSize: number, + currentFocus: number | string, + noLoop: boolean, + setFocus: Dispatch> + ) => void; contextProps?: { shouldFocusOnPress?: boolean; shouldItemFocusBeInset?: boolean; @@ -40,8 +47,15 @@ export type IUseOrientationBasedKeyboardNavigationProps = { const useOrientationBasedKeyboardNavigation = ( props: IUseOrientationBasedKeyboardNavigationProps ): IUseOrientationBasedKeyboardNavigationReturn => { - const { listSize, orientation, noLoop, contextProps, initialFocus = 0 } = props; - const [currentFocus, setCurrentFocus] = useState(-1); + const { + listSize, + orientation, + noLoop, + contextProps, + initialFocus = 0, + setNextFocus = defaultSetNextFocus, + } = props; + const [currentFocus, setCurrentFocus] = useState(-1); const [updateFocusBlocked, setUpdateFocusBlocked] = useState(true); const { isFocusedWithin, focusWithinProps } = useFocusWithinState({});