diff --git a/plop/stories/index.js b/plop/stories/index.js index 0d837bd0cc..e760e123a4 100644 --- a/plop/stories/index.js +++ b/plop/stories/index.js @@ -2,7 +2,7 @@ const fs = require("fs"); module.exports = plop => { plop.setGenerator("Stories", { - description: "New stories files fore existing component", + description: "New stories files for existing component", prompts: [ { type: "input", diff --git a/src/hooks/index.js b/src/hooks/index.js index 556810d24c..e1c9d05169 100644 --- a/src/hooks/index.js +++ b/src/hooks/index.js @@ -8,3 +8,4 @@ export { default as useTimeout } from "./useTimeout"; export { default as usePrevious } from "./usePrevious"; export { default as useMergeRefs } from "./useMergeRefs"; export { default as museIsMouseOver } from "./useIsMouseOver"; +export { default as useGridKeyboardNavigation } from "./useGridKeyboardNavigation/useGridKeyboardNavigation"; diff --git a/src/hooks/useFullKeyboardListeners.js b/src/hooks/useFullKeyboardListeners.js new file mode 100644 index 0000000000..7c8e2ec43a --- /dev/null +++ b/src/hooks/useFullKeyboardListeners.js @@ -0,0 +1,85 @@ +import { useMemo, useCallback, useEffect } from "react"; +import useKeyEvent from "./useKeyEvent"; + +export const NAV_DIRECTIONS = { + UP: "up", + DOWN: "down", + LEFT: "left", + RIGHT: "right" +}; + +const ARROW_DOWN_KEYS = ["ArrowDown"]; +const ARROW_UP_KEYS = ["ArrowUp"]; +const ARROW_RIGHT_KEYS = ["ArrowRight"]; +const ARROW_LEFT_KEYS = ["ArrowLeft"]; +const SELECTION_KEYS = ["Enter", " "]; +const ESCAPE_KEYS = ["Escape"]; + +const NOOP = () => {}; + +export default function useFullKeyboardListeners({ + ref, // the reference for the component that listens to keyboard + onSelectionKey = NOOP, + onArrowNavigation = NOOP, + onEscape = NOOP, + useDocumentEventListeners = false, + focusOnMount = false +}) { + const listenerOptions = useMemo(() => { + if (useDocumentEventListeners) return undefined; + + return { + ref, + preventDefault: true, + stopPropagation: true + }; + }, [useDocumentEventListeners, ref]); + + const onArrowDown = useCallback(() => onArrowNavigation(NAV_DIRECTIONS.DOWN), [onArrowNavigation]); + const onArrowUp = useCallback(() => onArrowNavigation(NAV_DIRECTIONS.UP), [onArrowNavigation]); + const onArrowRight = useCallback(() => onArrowNavigation(NAV_DIRECTIONS.RIGHT), [onArrowNavigation]); + const onArrowLeft = useCallback(() => onArrowNavigation(NAV_DIRECTIONS.LEFT), [onArrowNavigation]); + + useKeyEvent({ + keys: ARROW_DOWN_KEYS, + callback: onArrowDown, + ...listenerOptions + }); + + useKeyEvent({ + keys: ARROW_UP_KEYS, + callback: onArrowUp, + ...listenerOptions + }); + + useKeyEvent({ + keys: ARROW_RIGHT_KEYS, + callback: onArrowRight, + ...listenerOptions + }); + + useKeyEvent({ + keys: ARROW_LEFT_KEYS, + callback: onArrowLeft, + ...listenerOptions + }); + + useKeyEvent({ + keys: SELECTION_KEYS, + callback: onSelectionKey, + ...listenerOptions + }); + + useKeyEvent({ + keys: ESCAPE_KEYS, + callback: onEscape, + ...listenerOptions + }); + + useEffect(() => { + if (!focusOnMount || useDocumentEventListeners) return; + requestAnimationFrame(() => { + ref?.current?.focus(); + }); + }, [focusOnMount, ref, useDocumentEventListeners]); +} diff --git a/src/hooks/useGridKeyboardNavigation/__stories__/useGridKeyboardNavigation.stories.mdx b/src/hooks/useGridKeyboardNavigation/__stories__/useGridKeyboardNavigation.stories.mdx new file mode 100644 index 0000000000..eb823762d3 --- /dev/null +++ b/src/hooks/useGridKeyboardNavigation/__stories__/useGridKeyboardNavigation.stories.mdx @@ -0,0 +1,131 @@ +import { useRef, useCallback, useState, useMemo } from "react"; +import { ArgsTable, Story, Canvas, Meta } from "@storybook/addon-docs"; +import useGridKeyboardNavigation from "../useGridKeyboardNavigation"; +import { action } from "@storybook/addon-actions"; +import cx from "classnames"; +import range from "lodash/range"; +import "./useGridKeyboardNavigation.stories.scss"; +import Button from "../../../../src/components/Button/Button"; + + + +# useGridKeyboardNavigation + +- [Overview](#overview) +- [Usage](#usage) +- [Arguments](#arguments) +- [Returns](#returns) +- [Feedback](#feedback) + +## Overview + +Used for accessible keyboard navigation. Useful for components rendering items that can be navigated and selected with a keyboard. + + + +export const ELEMENT_WIDTH_PX = 72; +export const PADDING_PX = 24; + +export const ON_CLICK = action('item selected'); + + + + {() => { + const ref = useRef(null); + const [itemsCount, setItemsCount] = useState(15); + const [numberOfItemsInLine, setNumberOfItemsInLine] = useState(4); + const width = useMemo(() => numberOfItemsInLine * ELEMENT_WIDTH_PX + 2 * PADDING_PX, [numberOfItemsInLine]); + const items = useMemo(() => range(itemsCount).map(num => `${num}.`), [itemsCount]); + const getItemByIndex = useCallback(index => items[index], [items]); + const { activeIndex, onSelectionAction } = useGridKeyboardNavigation({ + ref, + numberOfItemsInLine, + itemsCount, + getItemByIndex, + onItemClicked: ON_CLICK, + }); + const onClickByIndex = useCallback((index) => () => onSelectionAction(index), [onSelectionAction]); + return ( +
+
+ {items.map((item, index) => ( + + ))} +
+
+
Items count: setItemsCount(e.target.value)} type="number" min={1}/>
+
Number of items in line: setNumberOfItemsInLine(e.target.value)} type="number" min={1}/>
+
+
+ ); +}} +
+
+ +## Usage + + + +## Arguments + + + + A React ref object. The reference for the component that listens to keyboard.
+ Important: the referred element must have a tabIndex={-1} for the focus to work properly.} + required + /> + + + + + + +
+ + +## Returns + + + + + A wrapper around the passed onItemClicked function. Use it as the handler for selecting items (e.g. onClick)} + /> + + diff --git a/src/hooks/useGridKeyboardNavigation/__stories__/useGridKeyboardNavigation.stories.scss b/src/hooks/useGridKeyboardNavigation/__stories__/useGridKeyboardNavigation.stories.scss new file mode 100644 index 0000000000..6a128c2a34 --- /dev/null +++ b/src/hooks/useGridKeyboardNavigation/__stories__/useGridKeyboardNavigation.stories.scss @@ -0,0 +1,33 @@ +@import "../../__stories__/general-hooks-stories.scss"; +@import "../../../styles/states.scss"; +@import "../../../styles/typography.scss"; +@import "../../../styles/global-css-settings.scss"; + +.use-grid-keyboard-nav-comp-wrapper { + padding: 24px; + display: flex; + flex-wrap: wrap; + outline: none; + text-align: center; +} + +.use-grid-keyboard-nav-item { + width: 60px; + margin: $spacing-xs-small; + + &.active-item { + @include focus-style-css(); + } +} + +.use-grid-keyboard-nav-controls { + display: flex; + + & :not(:first-child) { + margin-left: var(--spacing-medium); + } + + input { + width: 60px; + } +} diff --git a/src/hooks/useGridKeyboardNavigation/__tests__/gridKeyboardNavigationHelper.jest.js b/src/hooks/useGridKeyboardNavigation/__tests__/gridKeyboardNavigationHelper.jest.js new file mode 100644 index 0000000000..01e970541d --- /dev/null +++ b/src/hooks/useGridKeyboardNavigation/__tests__/gridKeyboardNavigationHelper.jest.js @@ -0,0 +1,310 @@ +import { NAV_DIRECTIONS } from "../../useFullKeyboardListeners"; +import { calcActiveIndexAfterArrowNavigation, getActiveIndexFromInboundNavigation } from "../gridKeyboardNavigationHelper"; + +describe("getActiveIndexFromInboundNavigation", () => { + describe("direction - left", () => { + const direction = NAV_DIRECTIONS.LEFT; + + it("should return the last item in the 2nd row when there are 4 rows", () => { + const itemsCount = 12; + const numberOfItemsInLine = 3; + const expectedResult = 5; + + const result = getActiveIndexFromInboundNavigation({ direction, itemsCount, numberOfItemsInLine }); + + expect(result).toEqual(expectedResult); + }); + + it("should return the last item in the 2rd row when there are 3 rows", () => { + const itemsCount = 9; + const numberOfItemsInLine = 3; + const expectedResult = 5; + + const result = getActiveIndexFromInboundNavigation({ direction, itemsCount, numberOfItemsInLine }); + + expect(result).toEqual(expectedResult); + }); + + it("should return the last item in the first row when there is one row which is full", () => { + const itemsCount = 5; + const numberOfItemsInLine = 5; + const expectedResult = 4; + + const result = getActiveIndexFromInboundNavigation({ direction, itemsCount, numberOfItemsInLine }); + + expect(result).toEqual(expectedResult); + }); + + it("should return the last item in the first row when there is one row which is not full", () => { + const itemsCount = 3; + const numberOfItemsInLine = 5; + const expectedResult = 2; + + const result = getActiveIndexFromInboundNavigation({ direction, itemsCount, numberOfItemsInLine }); + + expect(result).toEqual(expectedResult); + }); + }); + + describe("direction - right", () => { + const direction = NAV_DIRECTIONS.RIGHT; + + it("should return the first item in the 2nd row when there are 4 rows", () => { + const itemsCount = 12; + const numberOfItemsInLine = 3; + const expectedResult = 3; + + const result = getActiveIndexFromInboundNavigation({ direction, itemsCount, numberOfItemsInLine }); + + expect(result).toEqual(expectedResult); + }); + + it("should return the first item in the 2nd row when there are 3 rows", () => { + const itemsCount = 9; + const numberOfItemsInLine = 3; + const expectedResult = 3; + + const result = getActiveIndexFromInboundNavigation({ direction, itemsCount, numberOfItemsInLine }); + + expect(result).toEqual(expectedResult); + }); + + it("should return the first item in the first row when there is one row which is full", () => { + const itemsCount = 7; + const numberOfItemsInLine = 7; + const expectedResult = 0; + + const result = getActiveIndexFromInboundNavigation({ direction, itemsCount, numberOfItemsInLine }); + + expect(result).toEqual(expectedResult); + }); + + it("should return the first item in the first row when there is one row which is not full", () => { + const itemsCount = 3; + const numberOfItemsInLine = 7; + const expectedResult = 0; + + const result = getActiveIndexFromInboundNavigation({ direction, itemsCount, numberOfItemsInLine }); + + expect(result).toEqual(expectedResult); + }); + }); + + describe("direction - up", () => { + const direction = NAV_DIRECTIONS.UP; + + it("should return the third item in the last row when the last row has 5 elements out of 5", () => { + const itemsCount = 15; + const numberOfItemsInLine = 5; + const expectedResult = 12; + + const result = getActiveIndexFromInboundNavigation({ direction, itemsCount, numberOfItemsInLine }); + + expect(result).toEqual(expectedResult); + }); + + it("should return the third item in the last row when the last row has 3 elements out of 5", () => { + const itemsCount = 5; + const numberOfItemsInLine = 5; + const expectedResult = 2; + + const result = getActiveIndexFromInboundNavigation({ direction, itemsCount, numberOfItemsInLine }); + + expect(result).toEqual(expectedResult); + }); + + it("should return the first item in the last row when the last row has 1 element out of 5", () => { + const itemsCount = 6; + const numberOfItemsInLine = 5; + const expectedResult = 5; + + const result = getActiveIndexFromInboundNavigation({ direction, itemsCount, numberOfItemsInLine }); + + expect(result).toEqual(expectedResult); + }); + }); + + describe("direction - down", () => { + const direction = NAV_DIRECTIONS.DOWN; + + it("should return the third item in the first row when the first row has 5 elements out of 5", () => { + const itemsCount = 10; + const numberOfItemsInLine = 5; + const expectedResult = 2; + + const result = getActiveIndexFromInboundNavigation({ direction, itemsCount, numberOfItemsInLine }); + + expect(result).toEqual(expectedResult); + }); + + it("should return the third item in the first row when the first row has 3 elements out of 5", () => { + const itemsCount = 3; + const numberOfItemsInLine = 5; + const expectedResult = 2; + + const result = getActiveIndexFromInboundNavigation({ direction, itemsCount, numberOfItemsInLine }); + + expect(result).toEqual(expectedResult); + }); + + it("should return the first item in the first row when the first row has 1 element out of 5", () => { + const itemsCount = 1; + const numberOfItemsInLine = 5; + const expectedResult = 0; + + const result = getActiveIndexFromInboundNavigation({ direction, itemsCount, numberOfItemsInLine }); + + expect(result).toEqual(expectedResult); + }); + }); +}); + +describe("calcActiveIndexAfterArrowNavigation", () => { + describe("direction - up", () => { + const direction = NAV_DIRECTIONS.UP; + + it("should return the 3rd index of the first row when navigating from 3rd item of second row", () => { + const itemsCount = 12; + const numberOfItemsInLine = 3; + const activeIndex = 5; + const expectedResult = { isOutbound: false, nextIndex: 2 }; + + const result = calcActiveIndexAfterArrowNavigation({ direction, itemsCount, numberOfItemsInLine, activeIndex }); + + expect(result).toEqual(expectedResult); + }); + + it("should return 'isOutbound = true' when navigating from 3rd item of first row", () => { + const itemsCount = 5; + const numberOfItemsInLine = 4; + const activeIndex = 2; + const expectedResult = { isOutbound: true }; + + const result = calcActiveIndexAfterArrowNavigation({ direction, itemsCount, numberOfItemsInLine, activeIndex }); + + expect(result).toEqual(expectedResult); + }); + + it("should return 'isOutbound = true' when navigating from the first item of first row", () => { + const itemsCount = 5; + const numberOfItemsInLine = 4; + const activeIndex = 0; + const expectedResult = { isOutbound: true }; + + const result = calcActiveIndexAfterArrowNavigation({ direction, itemsCount, numberOfItemsInLine, activeIndex }); + + expect(result).toEqual(expectedResult); + }); + }); + + describe("direction - down", () => { + const direction = NAV_DIRECTIONS.DOWN; + + it("should return the 3rd index of the second row when navigating from 3rd item of first row", () => { + const itemsCount = 12; + const numberOfItemsInLine = 3; + const activeIndex = 2; + const expectedResult = { isOutbound: false, nextIndex: 5 }; + + const result = calcActiveIndexAfterArrowNavigation({ direction, itemsCount, numberOfItemsInLine, activeIndex }); + + expect(result).toEqual(expectedResult); + }); + + it("should return 'isOutbound = true' when navigating from the 3rd index of the first row, and second row has only 2 items", () => { + const itemsCount = 5; + const numberOfItemsInLine = 3; + const activeIndex = 2; + const expectedResult = { isOutbound: true }; + + const result = calcActiveIndexAfterArrowNavigation({ direction, itemsCount, numberOfItemsInLine, activeIndex }); + + expect(result).toEqual(expectedResult); + }); + + it("should return 'isOutbound = true' when navigating from first item of the last row", () => { + const itemsCount = 5; + const numberOfItemsInLine = 3; + const activeIndex = 3; + const expectedResult = { isOutbound: true }; + + const result = calcActiveIndexAfterArrowNavigation({ direction, itemsCount, numberOfItemsInLine, activeIndex }); + + expect(result).toEqual(expectedResult); + }); + }); + + describe("direction - left", () => { + const direction = NAV_DIRECTIONS.LEFT; + + it("should return the second index of the second row when navigating from the third index of the second row", () => { + const itemsCount = 9; + const numberOfItemsInLine = 4; + const activeIndex = 6; + const expectedResult = { isOutbound: false, nextIndex: 5 }; + + const result = calcActiveIndexAfterArrowNavigation({ direction, itemsCount, numberOfItemsInLine, activeIndex }); + + expect(result).toEqual(expectedResult); + }); + + it("should return 'isOutbound = true' when navigating from the first index of the second row", () => { + const itemsCount = 9; + const numberOfItemsInLine = 4; + const activeIndex = 4; + const expectedResult = { isOutbound: true }; + + const result = calcActiveIndexAfterArrowNavigation({ direction, itemsCount, numberOfItemsInLine, activeIndex }); + + expect(result).toEqual(expectedResult); + }); + + it("should return 'isOutbound = true' when navigating from the first index of the first row", () => { + const itemsCount = 9; + const numberOfItemsInLine = 4; + const activeIndex = 0; + const expectedResult = { isOutbound: true }; + + const result = calcActiveIndexAfterArrowNavigation({ direction, itemsCount, numberOfItemsInLine, activeIndex }); + + expect(result).toEqual(expectedResult); + }); + }); + + describe("direction - right", () => { + const direction = NAV_DIRECTIONS.RIGHT; + + it("should return the third index of the second row when navigating from the second index of the second row", () => { + const itemsCount = 9; + const numberOfItemsInLine = 4; + const activeIndex = 5; + const expectedResult = { isOutbound: false, nextIndex: 6 }; + + const result = calcActiveIndexAfterArrowNavigation({ direction, itemsCount, numberOfItemsInLine, activeIndex }); + + expect(result).toEqual(expectedResult); + }); + + it("should return 'isOutbound = true' when navigating from the last index of the second row, when second row is full", () => { + const itemsCount = 9; + const numberOfItemsInLine = 4; + const activeIndex = 7; + const expectedResult = { isOutbound: true }; + + const result = calcActiveIndexAfterArrowNavigation({ direction, itemsCount, numberOfItemsInLine, activeIndex }); + + expect(result).toEqual(expectedResult); + }); + + it("should return 'isOutbound = true' when navigating from the last index of the last row, when last row is not completely full", () => { + const itemsCount = 5; + const numberOfItemsInLine = 3; + const activeIndex = 4; + const expectedResult = { isOutbound: true }; + + const result = calcActiveIndexAfterArrowNavigation({ direction, itemsCount, numberOfItemsInLine, activeIndex }); + + expect(result).toEqual(expectedResult); + }); + }); +}); diff --git a/src/hooks/useGridKeyboardNavigation/__tests__/useGridKeyboardNavigation.jest.js b/src/hooks/useGridKeyboardNavigation/__tests__/useGridKeyboardNavigation.jest.js new file mode 100644 index 0000000000..64716f7170 --- /dev/null +++ b/src/hooks/useGridKeyboardNavigation/__tests__/useGridKeyboardNavigation.jest.js @@ -0,0 +1,100 @@ +import { renderHook, cleanup, act } from "@testing-library/react-hooks"; +import { fireEvent } from "@testing-library/react"; +import range from "lodash/range"; +import useGridKeyboardNavigation from "../useGridKeyboardNavigation"; +import { NAV_DIRECTIONS } from "../../useFullKeyboardListeners"; + +describe("useGridKeyboardNavigation", () => { + let element; + + afterEach(() => { + element.remove(); + cleanup(); + }); + + it("should set the active index to 0 when focusing for the first time", () => { + const { result } = renderHookForTest({ }); + + act(() => element.dispatchEvent(new Event('focus'))); + + expect(result.current.activeIndex).toBe(0); + }); + + it("should consider the navigation direction when focusing the element with a custom event", () => { + const items = itemsArray(9); + const { result } = renderHookForTest({ items, numberOfItemsInLine: 3 }); + + act(() => element.dispatchEvent(new CustomEvent("focus", { detail: { keyboardDirection: NAV_DIRECTIONS.LEFT } }))); + + expect(result.current.activeIndex).toBe(5); // last index of the right-most line + }); + + it("should return a callback wrapper that sets the activeIndex to the clicked element", () => { + const { result } = renderHookForTest({ }); + + act(() => result.current.onSelectionAction(3)); + + expect(result.current.activeIndex).toBe(3); + }); + + it("should return a callback wrapper that calls onItemClicked with the item and the index", () => { + const onItemClicked = jest.fn(); + const items = ["a", "b", "c", "d"]; + const { result } = renderHookForTest({ onItemClicked, items }); + + act(() => result.current.onSelectionAction(2)); + + expect(onItemClicked).toHaveBeenCalledTimes(1); + expect(onItemClicked).toHaveBeenCalledWith('c', 2); + }); + + it("should update the activeIndex when keyboard-navigating inside the element", () => { + const items = [ + "a", "b", + "c", "d" + ]; + const { result } = renderHookForTest({ items, numberOfItemsInLine: 2 }); + + act(() => result.current.onSelectionAction(0)); // set the activeIndex to 0 + act(() => { fireEvent.keyDown(element, { key: "ArrowRight" }); }); + + expect(result.current.activeIndex).toBe(1); + }); + + it("should not update the activeIndex when performing outbound navigation with the keyboard", () => { + const items = [ + "a", "b", + "c", "d" + ]; + const { result } = renderHookForTest({ items, numberOfItemsInLine: 2 }); + + act(() => result.current.onSelectionAction(0)); // set the activeIndex to 0 + act(() => { fireEvent.keyDown(element, { key: "ArrowUp" }); }); + + expect(result.current.activeIndex).toBe(0); + }); + + function itemsArray(length) { + return range(length); + } + + function renderHookForTest({ items = itemsArray(4), numberOfItemsInLine = 3, onItemClicked = jest.fn(), focusOnMount = false }) { + const itemsCount = items.length; + const getItemByIndex = index => items[index]; + + element = document.createElement("div"); + document.body.appendChild(element); + + return renderHook( + () => + useGridKeyboardNavigation({ + ref: { current: element }, + itemsCount, + getItemByIndex, + onItemClicked, + focusOnMount, + numberOfItemsInLine + }) + ); + } +}); diff --git a/src/hooks/useGridKeyboardNavigation/gridKeyboardNavigationHelper.js b/src/hooks/useGridKeyboardNavigation/gridKeyboardNavigationHelper.js new file mode 100644 index 0000000000..01c1f4b32d --- /dev/null +++ b/src/hooks/useGridKeyboardNavigation/gridKeyboardNavigationHelper.js @@ -0,0 +1,74 @@ +import { NAV_DIRECTIONS } from "../useFullKeyboardListeners"; + +export function getActiveIndexFromInboundNavigation({ direction, numberOfItemsInLine, itemsCount }) { + const getRawIndex = () => { + const firstLineMiddleIndex = Math.floor(numberOfItemsInLine / 2); + if (direction === NAV_DIRECTIONS.UP) { + // last line, middle + const rowCount = Math.ceil(itemsCount / numberOfItemsInLine); + return ((rowCount - 1) * numberOfItemsInLine) + firstLineMiddleIndex; + } + if (direction === NAV_DIRECTIONS.DOWN) { + // first line, middle + return firstLineMiddleIndex; + } + if (direction === NAV_DIRECTIONS.LEFT) { + // middle line, last item + let result = numberOfItemsInLine - 1; + const midIndex = Math.floor((itemsCount - 1) / 2); + while (result < midIndex) { + result += numberOfItemsInLine; + } + return result; + } + if (direction === NAV_DIRECTIONS.RIGHT) { + // middle line, first item + let result = 0; + const midIndex = Math.floor((itemsCount - 1) / 2); + while (result + numberOfItemsInLine < midIndex) { + result += numberOfItemsInLine; + } + return result; + } + }; + + const rawIndex = getRawIndex(); + return Math.max(0, Math.min(rawIndex, itemsCount - 1)); +} + +export function calcActiveIndexAfterArrowNavigation({ activeIndex, itemsCount, numberOfItemsInLine, direction }) { + const getIndexLine = index => Math.ceil((index + 1) / numberOfItemsInLine); + + const horizontalChange = isIndexIncrease => { + const nextIndex = activeIndex + (isIndexIncrease ? 1 : -1); + if (nextIndex < 0 || itemsCount <= nextIndex) { + return { isOutbound: true }; + } + const currentLine = getIndexLine(activeIndex); + const nextIndexLine = getIndexLine(nextIndex); + if (currentLine !== nextIndexLine) { + return { isOutbound: true }; + } + + return { isOutbound: false, nextIndex }; + }; + + const verticalChange = isIndexIncrease => { + const nextIndex = activeIndex + numberOfItemsInLine * (isIndexIncrease ? 1 : -1); + if (nextIndex < 0 || itemsCount <= nextIndex) { + return { isOutbound: true }; + } + return { isOutbound: false, nextIndex }; + }; + + switch (direction) { + case NAV_DIRECTIONS.RIGHT: + return horizontalChange(true); + case NAV_DIRECTIONS.LEFT: + return horizontalChange(false); + case NAV_DIRECTIONS.DOWN: + return verticalChange(true); + case NAV_DIRECTIONS.UP: + return verticalChange(false); + } +} diff --git a/src/hooks/useGridKeyboardNavigation/useGridKeyboardNavigation.js b/src/hooks/useGridKeyboardNavigation/useGridKeyboardNavigation.js new file mode 100644 index 0000000000..c50156c5aa --- /dev/null +++ b/src/hooks/useGridKeyboardNavigation/useGridKeyboardNavigation.js @@ -0,0 +1,104 @@ +import { useCallback, useEffect, useState } from "react"; +import useFullKeyboardListeners from "../useFullKeyboardListeners"; +// import { GridKeyboardNavigationContext } from "../../components/GridKeyboardNavigation/GridKeyboardNavigationContext"; +import { calcActiveIndexAfterArrowNavigation, getActiveIndexFromInboundNavigation } from "./gridKeyboardNavigationHelper"; +import useEventListener from "../useEventListener"; + +const NO_ACTIVE_INDEX = -1; + +/** + * @typedef useGridKeyboardNavigationResult + * @property {number} activeIndex - the currently active index + * @property {function} onSelectionAction - the callback which should be used to select an item. It should be called with the selected item's index. Use this callback for onClick handlers, for example. + */ + +/** + * A hook which is used for accessible keyboard navigation. Useful for components rendering a list of items that can be navigated and selected with a keyboard. + * @param {Object} options + * @param {React.MutableRefObject} options.ref - the reference for the component that listens to keyboard + * @param {number} options.itemsCount - the number of items + * @param {number} options.numberOfItemsInLine - the number of items on each line of the grid + * @param {function} options.onItemClicked - the callback for selecting an item. It will be called when an active item is selected, for example with "Enter". + * @param {function} options.getItemByIndex - a function which gets an index as a param, and returns the item on that index + * @param {boolean=} options.focusOnMount - if true, the referenced element will be focused when mounted + * @returns {useGridKeyboardNavigationResult} + */ +export default function useGridKeyboardNavigation({ + ref, + itemsCount, + numberOfItemsInLine, + onItemClicked, // the callback to call when an item is selected + focusOnMount = false, + getItemByIndex = () => {} +}) { + const [activeIndex, setActiveIndex] = useState(NO_ACTIVE_INDEX); + + // const keyboardContext = useContext(GridKeyboardNavigationContext); + + const onArrowNavigation = direction => { + if (activeIndex === NO_ACTIVE_INDEX) { + setActiveIndex(0); + return; + } + + const { isOutbound, nextIndex } = calcActiveIndexAfterArrowNavigation({ + activeIndex, + itemsCount, + numberOfItemsInLine, + direction + }); + if (isOutbound) { + // keyboardContext?.onOutboundNavigation(ref, direction); + } else { + setActiveIndex(nextIndex); + } + }; + + useEffect(() => { + if (activeIndex > -1) { + ref?.current?.focus(); + } + }, [activeIndex, ref]); + + const blurTargetElement = useCallback(() => ref.current?.blur(), [ref]); + + const onFocus = useCallback(e => { + const direction = e.detail?.keyboardDirection; + if (direction) { + const newIndex = getActiveIndexFromInboundNavigation({ direction, numberOfItemsInLine, itemsCount }); + setActiveIndex(newIndex); + return; + } + if (activeIndex === NO_ACTIVE_INDEX) { + setActiveIndex(0); + } + }, [activeIndex, itemsCount, numberOfItemsInLine]); + + const onBlur = useCallback(() => setActiveIndex(NO_ACTIVE_INDEX), [setActiveIndex]); + + useEventListener({ eventName: "focus", callback: onFocus, ref }); + useEventListener({ eventName: "blur", callback: onBlur, ref }); + + const onSelectionAction = useCallback( + index => { + setActiveIndex(index); + onItemClicked(getItemByIndex(index), index); + }, + [onItemClicked, getItemByIndex] + ); + + const onKeyboardSelection = useCallback(() => onSelectionAction(activeIndex), [onSelectionAction, activeIndex]); + + useFullKeyboardListeners({ + ref, + onArrowNavigation, + onSelectionKey: onKeyboardSelection, + onEscape: blurTargetElement, + focusOnMount + }); + + return { + activeIndex, + onSelectionAction + }; +}