Skip to content

Commit

Permalink
feat: spatial navigation provider
Browse files Browse the repository at this point in the history
- add spatial navigation provider
- update complex component to work with the new provider

issue: https://jira-eng-gpk2.cisco.com/jira/browse/SPARK-596397
  • Loading branch information
maxinteger committed Feb 11, 2025
1 parent e53b838 commit 4f57755
Show file tree
Hide file tree
Showing 39 changed files with 2,274 additions and 325 deletions.
3 changes: 2 additions & 1 deletion config/typescript/tsconfig.base.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"preserveConstEnums": true,
"removeComments": false,
"sourceMap": true,
"target": "ES5"
"target": "ES5",
"lib": ["ESNext", "dom"]
}
}
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@
"dom-helpers": "^3.4.0",
"globby": "^11.0.4",
"handlebars": "^4.1.0",
"lodash": "^4.17.11",
"lodash": "4.17.21",
"lottie-web": "5.10.2",
"moment": "^2.24.0",
"node-sass-tilde-importer": "^1.0.2",
Expand Down Expand Up @@ -187,7 +187,7 @@
"@testing-library/user-event": "^14.4.3",
"@types/enzyme": "^3.10.8",
"@types/jest": "^29.5.13",
"@types/lodash": "4.14.171",
"@types/lodash": "4.14.202",
"@types/uuid": "^9.0.1",
"@typescript-eslint/eslint-plugin": "^4.28.3",
"@typescript-eslint/parser": "^4.28.3",
Expand Down
12 changes: 7 additions & 5 deletions src/components/AriaToolbar/AriaToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,12 +75,14 @@ const AriaToolbar: FC<Props> = (props: Props) => {
// When the toolbar is rendered inside a list, only the first item in the toolbar
// should be focusable. This is to preserve the tab order as the
// List uses a roving tab index.
getKeyboardFocusableElements(ref.current, false).forEach((el, index) => {
if (index === 0) {
return;
getKeyboardFocusableElements(ref.current, { includeTabbableOnly: false }).forEach(
(el, index) => {
if (index === 0) {
return;
}
el.setAttribute('data-exclude-focus', 'true');
}
el.setAttribute('data-exclude-focus', 'true');
});
);
}, [ref]);

return (
Expand Down
7 changes: 7 additions & 0 deletions src/components/ComboBox/ComboBox.unit.test.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ exports[`ComboBox snapshot should match snapshot label 1`] = `
data-focus={false}
data-level="none"
onClick={[Function]}
onKeyDown={[Function]}
>
<div
className="md-text-input-container"
Expand Down Expand Up @@ -260,6 +261,7 @@ exports[`ComboBox snapshot should match snapshot with className 1`] = `
data-focus={false}
data-level="none"
onClick={[Function]}
onKeyDown={[Function]}
>
<div
className="md-text-input-container"
Expand Down Expand Up @@ -452,6 +454,7 @@ exports[`ComboBox snapshot should match snapshot with noResultText 1`] = `
data-focus={false}
data-level="none"
onClick={[Function]}
onKeyDown={[Function]}
>
<div
className="md-text-input-container"
Expand Down Expand Up @@ -644,6 +647,7 @@ exports[`ComboBox snapshot should match snapshot with placeholder 1`] = `
data-focus={false}
data-level="none"
onClick={[Function]}
onKeyDown={[Function]}
>
<div
className="md-text-input-container"
Expand Down Expand Up @@ -841,6 +845,7 @@ exports[`ComboBox snapshot should match snapshot with style 1`] = `
data-focus={false}
data-level="none"
onClick={[Function]}
onKeyDown={[Function]}
>
<div
className="md-text-input-container"
Expand Down Expand Up @@ -1033,6 +1038,7 @@ exports[`ComboBox snapshot should match snapshot with width 1`] = `
data-focus={false}
data-level="none"
onClick={[Function]}
onKeyDown={[Function]}
>
<div
className="md-text-input-container"
Expand Down Expand Up @@ -1238,6 +1244,7 @@ exports[`ComboBox snapshot should match snapshot withSection 1`] = `
data-focus={false}
data-level="none"
onClick={[Function]}
onKeyDown={[Function]}
>
<div
className="md-text-input-container"
Expand Down
60 changes: 60 additions & 0 deletions src/components/List/List.utils.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import React from 'react';
import { ListContext, setNextFocus, useListContext } from './List.utils';
import { renderHook } from '@testing-library/react-hooks';

describe('List utils', () => {
describe('useListContext', () => {
it('should return undefined when the context is not available', () => {
const { result } = renderHook(() => useListContext());
expect(result.current).toBeNull();
});

it('should return the context when it is available', () => {
const contextValue = {};
const { result } = renderHook(() => useListContext(), {
wrapper: ({ children }) => (
<ListContext.Provider value={contextValue}>{children}</ListContext.Provider>
),
});

expect(result.current).toBe(contextValue);
});
});

describe('setNextFocus', () => {
describe('in no loop mode', () => {
it('should return with the next item when moving forward', () => {
expect(setNextFocus(5, 10, false, true)).toBe(6);

Check failure on line 27 in src/components/List/List.utils.test.tsx

View workflow job for this annotation

GitHub Actions / Test - static

Expected 5 arguments, but got 4.
});

it('should return undefined when reached the end of the list', () => {
expect(setNextFocus(9, 10, false, true)).toBe(undefined);

Check failure on line 31 in src/components/List/List.utils.test.tsx

View workflow job for this annotation

GitHub Actions / Test - static

Expected 5 arguments, but got 4.
});

it('should return with the previous item when moving backward', () => {
expect(setNextFocus(5, 10, true, true)).toBe(4);

Check failure on line 35 in src/components/List/List.utils.test.tsx

View workflow job for this annotation

GitHub Actions / Test - static

Expected 5 arguments, but got 4.
});

it('should return undefined when reached the beginning of the list', () => {
expect(setNextFocus(0, 10, true, true)).toBe(undefined);

Check failure on line 39 in src/components/List/List.utils.test.tsx

View workflow job for this annotation

GitHub Actions / Test - static

Expected 5 arguments, but got 4.
});
});
describe('in loop mode', () => {
it('should return with the next item when moving forward', () => {
expect(setNextFocus(5, 10, false, false)).toBe(6);

Check failure on line 44 in src/components/List/List.utils.test.tsx

View workflow job for this annotation

GitHub Actions / Test - static

Expected 5 arguments, but got 4.
});

it('should return the first index when reached the end of the list', () => {
expect(setNextFocus(9, 10, false, false)).toBe(0);

Check failure on line 48 in src/components/List/List.utils.test.tsx

View workflow job for this annotation

GitHub Actions / Test - static

Expected 5 arguments, but got 4.
});

it('should return with the previous item when moving backward', () => {
expect(setNextFocus(5, 10, true, false)).toBe(4);

Check failure on line 52 in src/components/List/List.utils.test.tsx

View workflow job for this annotation

GitHub Actions / Test - static

Expected 5 arguments, but got 4.
});

it('should return the last index when reached the beginning of the list', () => {
expect(setNextFocus(0, 10, true, false)).toBe(9);

Check failure on line 56 in src/components/List/List.utils.test.tsx

View workflow job for this annotation

GitHub Actions / Test - static

Expected 5 arguments, but got 4.
});
});
});
});
25 changes: 16 additions & 9 deletions src/components/List/List.utils.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { Dispatch, SetStateAction, useContext } from 'react';
import React, { useContext } from 'react';
import { ListContextValue } from './List.types';
import { ListItemBaseIndex } from '../ListItemBase/ListItemBase.types';
import { isNumber } from 'lodash';
Expand All @@ -7,14 +7,22 @@ export const ListContext = React.createContext<ListContextValue>(null);

export const useListContext = (): ListContextValue => useContext(ListContext);

/**
* Calculate the next (or previous) index of a list
*
* @param isBackward Increase or decrease the index
* @param listSize List size
* @param currentFocus Current index
* @param noLoop loop back to the front or the back of the list
* @param allItemIndexes available indexes in order
*/
export const setNextFocus = (
isBackward: boolean,
listSize: number,
currentFocus: ListItemBaseIndex,
listSize: number,
isBackward: boolean,
noLoop: boolean,
setFocus: Dispatch<SetStateAction<ListItemBaseIndex>>,
allItemIndexes: ListItemBaseIndex[]
): void => {
): ListItemBaseIndex => {
let nextIndex: ListItemBaseIndex;
let currentIndex: number;

Expand Down Expand Up @@ -45,15 +53,14 @@ export const setNextFocus = (
if (allItemIndexes) {
nextIndex = allItemIndexes[nextIndex];
}
setFocus(nextIndex);
return nextIndex;
};

export const onCurrentFocusNotFound = (
currentFocus: ListItemBaseIndex,
allItemIndexes: ListItemBaseIndex[],
previousAllItemIndexes: ListItemBaseIndex[],
setFocus: Dispatch<SetStateAction<ListItemBaseIndex>>
): void => {
): ListItemBaseIndex => {
const previousIndexOfCurrentFocus = previousAllItemIndexes.indexOf(currentFocus);

const allItemIndexesPositions = allItemIndexes.map((itemIndex) =>
Expand All @@ -66,5 +73,5 @@ export const onCurrentFocusNotFound = (

const nextIndex = allItemIndexes[differences.lastIndexOf(Math.min(...differences))];

setFocus(nextIndex);
return nextIndex;
};
10 changes: 9 additions & 1 deletion src/components/ListBoxBase/ListBoxBase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,21 @@ const ListBoxBase = <T extends object>(props: Props<T>, ref: RefObject<HTMLUList
});
};

const menuListProps = {
...listBoxProps,
onKeyDown: (e: React.KeyboardEvent<HTMLElement>) => {
e.nativeEvent.stopImmediatePropagation();
listBoxProps.onKeyDown(e);
},
};

// ListContext is necessary to prevent changes in parent ListContext
// for example when Menu is inside a list row
return (
<ListBoxContext.Provider value={state}>
<ListContext.Provider value={{}}>
<MenuListBackground
{...listBoxProps}
{...menuListProps}
color={'primary'}
ref={ref}
style={style}
Expand Down
6 changes: 4 additions & 2 deletions src/components/ListItemBase/ListItemBase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ const ListItemBase = (props: Props, providedRef: RefOrCallbackRef) => {
}, [focusChild, isPressed, itemIndex, listContext, ref, setCurrentFocus, shouldFocusOnPress]);

const updateTabIndexes = useCallback(() => {
getKeyboardFocusableElements(ref.current, false)
getKeyboardFocusableElements(ref.current, { includeTabbableOnly: false })
.filter((el) => el.closest(`.${STYLE.wrapper}`) === ref.current)
.forEach((el) =>
el.setAttribute(
Expand Down Expand Up @@ -250,7 +250,9 @@ const ListItemBase = (props: Props, providedRef: RefOrCallbackRef) => {
return;
}

const firstFocusable = getKeyboardFocusableElements(ref.current, false).filter(
const firstFocusable = getKeyboardFocusableElements(ref.current, {
includeTabbableOnly: false,
}).filter(
(el) => el.closest(`.${STYLE.wrapper}`) === ref.current
)[0];

Expand Down
7 changes: 6 additions & 1 deletion src/components/MenuTrigger/MenuTrigger.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import Popover from '../Popover';
import type { PopoverInstance, VariantType } from '../Popover/Popover.types';
import type { FocusStrategy } from '@react-types/shared';
import type { PlacementType } from '../ModalArrow/ModalArrow.types';
import { useSpatialNavigationContext } from '../SpatialNavigationProvider/SpatialNavigationProvider.utils';

const MenuTrigger = forwardRef(
(props: Props, ref: ForwardedRef<{ triggerComponentRef: RefObject<HTMLButtonElement> }>) => {
Expand All @@ -47,6 +48,8 @@ const MenuTrigger = forwardRef(

const buttonRef = useRef<HTMLButtonElement>();

const spatialNav = useSpatialNavigationContext();

useImperativeHandle(
ref,
() => ({
Expand Down Expand Up @@ -100,8 +103,10 @@ const MenuTrigger = forwardRef(
*/
const { keyboardProps } = useKeyboard({
onKeyDown: (event) => {
if (event.key === 'Escape') {
if (event.key === 'Escape' || (spatialNav && event.key === spatialNav.backKey)) {
closeMenuTrigger();
event.preventDefault();
event.nativeEvent.stopImmediatePropagation();
}
},
});
Expand Down
27 changes: 27 additions & 0 deletions src/components/MenuTrigger/MenuTrigger.unit.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { ROUNDS } from '../ModalContainer/ModalContainer.constants';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom';
import SpatialNavigationProvider from '../SpatialNavigationProvider';
import { DEFAULTS } from '../SpatialNavigationProvider/SpatialNavigationProvider.constants';

jest.mock('uuid', () => {
return {
Expand Down Expand Up @@ -617,5 +619,30 @@ describe('<MenuTrigger /> - React Testing Library', () => {
expect(await screen.findByRole('menuitemcheckbox', { name: 'Five' })).toHaveFocus();
});
});

describe('spatial navigation', () => {
it('closes the menu on Escape', async () => {
const user = userEvent.setup();

render(
<SpatialNavigationProvider
navigationKeyMapping={{
...DEFAULTS.SPATIAL_NAVIGATION_KEY_MAPPING,
back: 'GoBack',
}}
>
<MenuTrigger {...defaultProps} />
</SpatialNavigationProvider>
);

await openMenu(user, screen);

await user.keyboard('{GoBack}');

await waitFor(() => {
expect(screen.queryByRole('menu', { name: 'Single Menu' })).not.toBeInTheDocument();
});
});
});
});
});
Loading

0 comments on commit 4f57755

Please sign in to comment.