Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 94 additions & 2 deletions src/components/List/List.stories.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';

Expand Down Expand Up @@ -649,6 +664,82 @@ const ListWithTextSelectWrapper = () => {

const ListWithTextSelect = Template<unknown>(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<SetStateAction<number>>
) => {
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) => (
<ListItemBase
allowTextSelection
itemIndex={itemIndexType === 'string' ? value : index}
key={value}
>
{`Item: ${value}`}
</ListItemBase>
));

return (
<>
<List
shouldFocusOnPress
setNextFocus={itemIndexType === 'string' ? setNextFocus : undefined}
listSize={listItems.length}
>
{listItems}
</List>
<ButtonSimple onPress={() => setItemIndices(['c', 'd', 'e', 'f', 'g'])}>
Remove First Two Items
</ButtonSimple>
<ButtonSimple onPress={() => setItemIndices(['a', 'b', 'c', 'd', 'e'])}>
Remove Last Two Items
</ButtonSimple>
<ButtonSimple onPress={() => setItemIndices(['a', 'b', 'c', 'd', 'e', 'f', 'g'])}>
Reset
</ButtonSimple>
<ButtonSimple
onPress={() => setItemIndexType(itemIndexType === 'string' ? 'number' : 'string')}
>
{`Swap itemIndexType, current type: ${itemIndexType}`}
</ButtonSimple>
</>
);
};

const ListWithNonDefaultSetNextFocus = Template<unknown>(
ListWithNonDefaultSetNextFocusWrapper
).bind({});

export {
Example,
Common,
Expand All @@ -667,4 +758,5 @@ export {
DynamicListWithInitialFocus3,
SingleItemList,
ListWithTextSelect,
ListWithNonDefaultSetNextFocus,
};
2 changes: 2 additions & 0 deletions src/components/List/List.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const List = forwardRef((props: Props, ref: RefObject<ListRefObject>) => {
noLoop,
orientation = DEFAULTS.ORIENTATION,
initialFocus = DEFAULTS.INITIAL_FOCUS,
setNextFocus,
...rest
} = props;

Expand All @@ -29,6 +30,7 @@ const List = forwardRef((props: Props, ref: RefObject<ListRefObject>) => {
orientation,
noLoop,
initialFocus,
setNextFocus,
contextProps: { shouldFocusOnPress, shouldItemFocusBeInset },
});

Expand Down
19 changes: 15 additions & 4 deletions src/components/List/List.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SetStateAction<number | string>>
) => void;
}

export interface ListContextValue {
currentFocus?: number;
currentFocus?: number | string;
shouldFocusOnPress?: boolean;
shouldItemFocusBeInset?: boolean;
setCurrentFocus?: Dispatch<SetStateAction<number>>;
setCurrentFocus?: Dispatch<SetStateAction<number | string>>;
listSize?: number;
noLoop?: boolean;
updateFocusBlocked?: boolean;
Expand All @@ -81,5 +92,5 @@ export interface ListContextValue {
export interface ListRefObject {
listRef: React.RefObject<HTMLUListElement>;
focusOnIndex: (index: number) => void;
getCurrentFocusIndex: () => number;
getCurrentFocusIndex: () => number | string;
}
4 changes: 3 additions & 1 deletion src/components/ListItemBase/ListItemBase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLLIElement> | ((instance: HTMLLIElement) => void);

Expand Down Expand Up @@ -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();
Expand Down
26 changes: 20 additions & 6 deletions src/hooks/useOrientationBasedKeyboardNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -16,8 +16,8 @@ type IUseOrientationBasedKeyboardNavigationReturn = {
focusWithinProps: HTMLAttributes<HTMLElement>;
getContext: () => {
listSize: number;
currentFocus: number;
setCurrentFocus: Dispatch<SetStateAction<number>>;
currentFocus: number | string;
setCurrentFocus: Dispatch<SetStateAction<number | string>>;
shouldFocusOnPress?: boolean;
shouldItemFocusBeInset?: boolean;
noLoop?: boolean;
Expand All @@ -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<SetStateAction<number | string>>
) => void;
contextProps?: {
shouldFocusOnPress?: boolean;
shouldItemFocusBeInset?: boolean;
Expand All @@ -40,8 +47,15 @@ export type IUseOrientationBasedKeyboardNavigationProps = {
const useOrientationBasedKeyboardNavigation = (
props: IUseOrientationBasedKeyboardNavigationProps
): IUseOrientationBasedKeyboardNavigationReturn => {
const { listSize, orientation, noLoop, contextProps, initialFocus = 0 } = props;
const [currentFocus, setCurrentFocus] = useState<number>(-1);
const {
listSize,
orientation,
noLoop,
contextProps,
initialFocus = 0,
setNextFocus = defaultSetNextFocus,
} = props;
const [currentFocus, setCurrentFocus] = useState<number | string>(-1);
const [updateFocusBlocked, setUpdateFocusBlocked] = useState<boolean>(true);

const { isFocusedWithin, focusWithinProps } = useFocusWithinState({});
Expand Down
Loading