diff --git a/src/components/GridKeyboardNavigationContext/__stories__/useGridKeyboardNavigationContext.stories.js b/src/components/GridKeyboardNavigationContext/__stories__/useGridKeyboardNavigationContext.stories.js index 2bbaf9a5ee..3711f469b5 100644 --- a/src/components/GridKeyboardNavigationContext/__stories__/useGridKeyboardNavigationContext.stories.js +++ b/src/components/GridKeyboardNavigationContext/__stories__/useGridKeyboardNavigationContext.stories.js @@ -12,40 +12,43 @@ const PADDING_PX = 24; const ON_CLICK = action("Selected"); -export const DummyNavigableGrid = forwardRef(({ itemsCount, numberOfItemsInLine, itemPrefix, disabled }, ref) => { - const width = useMemo(() => numberOfItemsInLine * ELEMENT_WIDTH_PX + 2 * PADDING_PX, [numberOfItemsInLine]); - const items = useMemo(() => range(itemsCount).map(num => `${itemPrefix} ${num}`), [itemPrefix, 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) => ( - - ))} -
- ); -}); +export const DummyNavigableGrid = forwardRef( + ({ itemsCount, numberOfItemsInLine, itemPrefix = "", disabled, disabledIndexes, withoutBorder = false }, ref) => { + const width = useMemo(() => numberOfItemsInLine * ELEMENT_WIDTH_PX + 2 * PADDING_PX, [numberOfItemsInLine]); + const items = useMemo(() => range(itemsCount).map(num => `${itemPrefix} ${num}`), [itemPrefix, itemsCount]); + const getItemByIndex = useCallback(index => items[index], [items]); + const { activeIndex, onSelectionAction } = useGridKeyboardNavigation({ + ref, + numberOfItemsInLine, + itemsCount, + getItemByIndex, + onItemClicked: ON_CLICK, + disabledIndexes + }); + const onClickByIndex = useCallback(index => () => onSelectionAction(index), [onSelectionAction]); + return ( +
+ {items.map((item, index) => ( + + ))} +
+ ); + } +); export const LayoutWithInnerKeyboardNavigation = forwardRef((_ignored, ref) => { const leftElRef = useRef(null); diff --git a/src/components/GridKeyboardNavigationContext/__stories__/useGridKeyboardNavigationContext.stories.scss b/src/components/GridKeyboardNavigationContext/__stories__/useGridKeyboardNavigationContext.stories.scss index 93f8a8cee7..265a6a1854 100644 --- a/src/components/GridKeyboardNavigationContext/__stories__/useGridKeyboardNavigationContext.stories.scss +++ b/src/components/GridKeyboardNavigationContext/__stories__/useGridKeyboardNavigationContext.stories.scss @@ -9,6 +9,10 @@ outline: none; text-align: center; box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1), 0 0 0 1px var(--primary-hover-color) inset; + + &.without-border { + box-shadow: none; + } } .use-grid-keyboard-dummy-grid-item { diff --git a/src/components/Menu/Menu/Menu.jsx b/src/components/Menu/Menu/Menu.jsx index 9bc3d05139..5018488fd3 100644 --- a/src/components/Menu/Menu/Menu.jsx +++ b/src/components/Menu/Menu/Menu.jsx @@ -12,6 +12,7 @@ import useCloseMenuOnKeyEvent from "./hooks/useCloseMenuOnKeyEvent"; import useMenuKeyboardNavigation from "./hooks/useMenuKeyboardNavigation"; import useMouseLeave from "./hooks/useMouseLeave"; import "./Menu.scss"; +import { useAdjacentSelectableMenuIndex } from "./hooks/useAdjacentSelectableMenuIndex"; const Menu = forwardRef( ( @@ -76,9 +77,12 @@ const Menu = forwardRef( useClickOutside({ ref, callback: onCloseMenu }); useCloseMenuOnKeyEvent(hasOpenSubMenu, onCloseMenu, ref, onClose, isSubMenu, useDocumentEventListeners); + + const { getNextSelectableIndex, getPreviousSelectableIndex } = useAdjacentSelectableMenuIndex({ children }); useMenuKeyboardNavigation( hasOpenSubMenu, - children, + getNextSelectableIndex, + getPreviousSelectableIndex, activeItemIndex, onSetActiveItemIndexCallback, isVisible, @@ -91,12 +95,10 @@ const Menu = forwardRef( setIsInitialSelectedState(true); }, [setIsInitialSelectedState]); - useLayoutEffect(() => { + useEffect(() => { if (hasOpenSubMenu || useDocumentEventListeners) return; if (activeItemIndex > -1) { - requestAnimationFrame(() => { - ref && ref.current && ref.current.focus(); - }); + ref?.current?.focus(); } }, [activeItemIndex, hasOpenSubMenu, useDocumentEventListeners]); @@ -147,7 +149,10 @@ const Menu = forwardRef( menuId: id, useDocumentEventListeners, isInitialSelectedState, - shouldScrollMenu + shouldScrollMenu, + getNextSelectableIndex, + getPreviousSelectableIndex, + isUnderSubMenu: isSubMenu }) : null; })} diff --git a/src/components/Menu/Menu/__stories__/Menu.stories.mdx b/src/components/Menu/Menu/__stories__/Menu.stories.mdx index 32204e4335..5fd4157d35 100644 --- a/src/components/Menu/Menu/__stories__/Menu.stories.mdx +++ b/src/components/Menu/Menu/__stories__/Menu.stories.mdx @@ -7,13 +7,17 @@ import { menuWithIconsTemplate, ComponentRuleSimpleActions, ComponentRuleWithSearch, - ComponentRuleDefaultWidth, ComponentRuleLargeWidth, menuSizesTemplate + ComponentRuleDefaultWidth, + ComponentRuleLargeWidth, + menuSizesTemplate, + menuWithGridItems } from "./menu.stories"; import { COMBOBOX, DROPDOWN, SPLIT_BUTTON } from "../../../../storybook/components/related-components/component-description-map"; +import classes from "./Menu.stories.module.scss"; +### Menu with grid items and sub menu +Grid menu items are navigable with a keyboard as well + + + + {menuWithGridItems.bind({})} + + + ## Related components \ No newline at end of file diff --git a/src/components/Menu/Menu/__stories__/Menu.stories.module.scss b/src/components/Menu/Menu/__stories__/Menu.stories.module.scss index 187e94393f..8e519da37c 100644 --- a/src/components/Menu/Menu/__stories__/Menu.stories.module.scss +++ b/src/components/Menu/Menu/__stories__/Menu.stories.module.scss @@ -9,4 +9,8 @@ &-large-menu { width: 328px; } -} \ No newline at end of file +} + +.menu-long-story-wrapper { + height: 500px; +} diff --git a/src/components/Menu/Menu/__stories__/menu.stories.js b/src/components/Menu/Menu/__stories__/menu.stories.js index 793c6b89f3..579ad60efc 100644 --- a/src/components/Menu/Menu/__stories__/menu.stories.js +++ b/src/components/Menu/Menu/__stories__/menu.stories.js @@ -22,6 +22,8 @@ import Search from "components/Search/Search"; import MenuTitle from "components/Menu/MenuTitle/MenuTitle"; import MenuDivider from "components/Menu/MenuDivider/MenuDivider"; import classes from "./Menu.stories.module.scss"; +import { DummyNavigableGrid } from "../../../GridKeyboardNavigationContext/__stories__/useGridKeyboardNavigationContext.stories"; +import MenuGridItem from "components/Menu/MenuGridItem/MenuGridItem"; export const menuTemplate = args => ( @@ -109,6 +111,37 @@ export const menuWith2DepthSubMenuTemplate = args => ( ); +export const menuWithGridItems = args => ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+); + export const ComponentRuleSimpleActions = () => ( diff --git a/src/components/Menu/Menu/hooks/__tests__/useAdjacentSelectableMenuIndex.jest.js b/src/components/Menu/Menu/hooks/__tests__/useAdjacentSelectableMenuIndex.jest.js new file mode 100644 index 0000000000..21b65543f4 --- /dev/null +++ b/src/components/Menu/Menu/hooks/__tests__/useAdjacentSelectableMenuIndex.jest.js @@ -0,0 +1,126 @@ +import { renderHook } from "@testing-library/react-hooks"; +import { useAdjacentSelectableMenuIndex } from "../useAdjacentSelectableMenuIndex"; + +describe("useAdjacentSelectableMenuIndex", () => { + const ENABLED_SELECTABLE_CHILD = { type: { isSelectable: true }, props: {} }; + const DISABLED_SELECTABLE_CHILD = { type: { isSelectable: true }, props: { disabled: true } }; + const ENABLED_NON_SELECTABLE_CHILD = { type: {}, props: {} }; + + describe("getNextSelectableIndex", () => { + it("should return the same index if there is only one child", () => { + const children = [ENABLED_SELECTABLE_CHILD]; + const currentIndex = 0; + const expectedResult = 0; + + const { result: hookResult } = renderHookForTest(children); + const result = hookResult.current.getNextSelectableIndex(currentIndex); + + expect(result).toEqual(expectedResult); + }); + + it("should return the third index if there are three items and the second index is selected", () => { + const children = [ENABLED_SELECTABLE_CHILD, ENABLED_SELECTABLE_CHILD, ENABLED_SELECTABLE_CHILD]; + const currentIndex = 1; + const expectedResult = 2; + + const { result: hookResult } = renderHookForTest(children); + const result = hookResult.current.getNextSelectableIndex(currentIndex); + + expect(result).toEqual(expectedResult); + }); + + it("should return the first index if there are three children and the third index is selected", () => { + const children = [ENABLED_SELECTABLE_CHILD, ENABLED_SELECTABLE_CHILD, ENABLED_SELECTABLE_CHILD]; + const currentIndex = 2; + const expectedResult = 0; + + const { result: hookResult } = renderHookForTest(children); + const result = hookResult.current.getNextSelectableIndex(currentIndex); + + expect(result).toEqual(expectedResult); + }); + + it("should return the third index if there are three children, the first one is active and the second one is disabled", () => { + const children = [ENABLED_SELECTABLE_CHILD, DISABLED_SELECTABLE_CHILD, ENABLED_SELECTABLE_CHILD]; + const currentIndex = 0; + const expectedResult = 2; + + const { result: hookResult } = renderHookForTest(children); + const result = hookResult.current.getNextSelectableIndex(currentIndex); + + expect(result).toEqual(expectedResult); + }); + + it("should return the first index if there are three children, the second is active and the third one is no selectable", () => { + const children = [ENABLED_SELECTABLE_CHILD, ENABLED_SELECTABLE_CHILD, ENABLED_NON_SELECTABLE_CHILD]; + const currentIndex = 1; + const expectedResult = 0; + + const { result: hookResult } = renderHookForTest(children); + const result = hookResult.current.getNextSelectableIndex(currentIndex); + + expect(result).toEqual(expectedResult); + }); + }); + + describe("getPreviousSelectableIndex", () => { + it("should return the null if there is only one child", () => { + const children = [ENABLED_SELECTABLE_CHILD]; + const currentIndex = 0; + const expectedResult = null; + + const { result: hookResult } = renderHookForTest(children); + const result = hookResult.current.getPreviousSelectableIndex(currentIndex); + + expect(result).toEqual(expectedResult); + }); + + it("should return the second index if there are three items and the third index is selected", () => { + const children = [ENABLED_SELECTABLE_CHILD, ENABLED_SELECTABLE_CHILD, ENABLED_SELECTABLE_CHILD]; + const currentIndex = 2; + const expectedResult = 1; + + const { result: hookResult } = renderHookForTest(children); + const result = hookResult.current.getPreviousSelectableIndex(currentIndex); + + expect(result).toEqual(expectedResult); + }); + + it("should return the third index if there are three children and the first index is selected", () => { + const children = [ENABLED_SELECTABLE_CHILD, ENABLED_SELECTABLE_CHILD, ENABLED_SELECTABLE_CHILD]; + const currentIndex = 0; + const expectedResult = 2; + + const { result: hookResult } = renderHookForTest(children); + const result = hookResult.current.getPreviousSelectableIndex(currentIndex); + + expect(result).toEqual(expectedResult); + }); + + it("should return the first index if there are three children, the third one is active and the second one is disabled", () => { + const children = [ENABLED_SELECTABLE_CHILD, DISABLED_SELECTABLE_CHILD, ENABLED_SELECTABLE_CHILD]; + const currentIndex = 2; + const expectedResult = 0; + + const { result: hookResult } = renderHookForTest(children); + const result = hookResult.current.getPreviousSelectableIndex(currentIndex); + + expect(result).toEqual(expectedResult); + }); + + it("should return the third index if there are three children, the second is active and the first one is no selectable", () => { + const children = [ENABLED_NON_SELECTABLE_CHILD, ENABLED_SELECTABLE_CHILD, ENABLED_SELECTABLE_CHILD]; + const currentIndex = 1; + const expectedResult = 2; + + const { result: hookResult } = renderHookForTest(children); + const result = hookResult.current.getPreviousSelectableIndex(currentIndex); + + expect(result).toEqual(expectedResult); + }); + }); + + function renderHookForTest(children) { + return renderHook(() => useAdjacentSelectableMenuIndex({ children })); + } +}); diff --git a/src/components/Menu/Menu/hooks/__tests__/useLastNavigationDirection.jest.js b/src/components/Menu/Menu/hooks/__tests__/useLastNavigationDirection.jest.js index 364f731f94..0545859349 100644 --- a/src/components/Menu/Menu/hooks/__tests__/useLastNavigationDirection.jest.js +++ b/src/components/Menu/Menu/hooks/__tests__/useLastNavigationDirection.jest.js @@ -7,7 +7,7 @@ describe("useLastNavigationDirection", () => { it("should return undefined when no direction key was pressed yet", () => { const { result } = renderHookForTest(); - expect(result.current.lastNavigationDirection).toBeUndefined(); + expect(result.current.lastNavigationDirectionRef.current).toBeUndefined(); }); it("should return undefined when only non-direction keys were pressed", () => { @@ -17,7 +17,7 @@ describe("useLastNavigationDirection", () => { userEvent.keyboard("f"); }); - expect(result.current.lastNavigationDirection).toBeUndefined(); + expect(result.current.lastNavigationDirectionRef.current).toBeUndefined(); }); [ @@ -33,7 +33,7 @@ describe("useLastNavigationDirection", () => { userEvent.keyboard(`{${key}}`); }); - expect(result.current.lastNavigationDirection).toBe(direction); + expect(result.current.lastNavigationDirectionRef.current).toBe(direction); }); it(`should return direction "${direction}" when pressing the key "${key}" after another direction key`, () => { @@ -44,7 +44,7 @@ describe("useLastNavigationDirection", () => { userEvent.keyboard(`{${otherDirectionKey}}{${key}}`); }); - expect(result.current.lastNavigationDirection).toBe(direction); + expect(result.current.lastNavigationDirectionRef.current).toBe(direction); }); it(`should return direction "${direction}" when pressing the key "${key}" after another non-direction keys`, () => { @@ -54,7 +54,7 @@ describe("useLastNavigationDirection", () => { userEvent.keyboard(`something{${key}}`); }); - expect(result.current.lastNavigationDirection).toBe(direction); + expect(result.current.lastNavigationDirectionRef.current).toBe(direction); }); it(`should return direction "${direction}" when pressing the key "${key}" and THEN non-direction keys`, () => { @@ -64,7 +64,7 @@ describe("useLastNavigationDirection", () => { userEvent.keyboard(`{${key}}something`); }); - expect(result.current.lastNavigationDirection).toBe(direction); + expect(result.current.lastNavigationDirectionRef.current).toBe(direction); }); }); diff --git a/src/components/Menu/Menu/hooks/useAdjacentSelectableMenuIndex.jsx b/src/components/Menu/Menu/hooks/useAdjacentSelectableMenuIndex.jsx new file mode 100644 index 0000000000..2d2607dde4 --- /dev/null +++ b/src/components/Menu/Menu/hooks/useAdjacentSelectableMenuIndex.jsx @@ -0,0 +1,41 @@ +import { useCallback } from "react"; + +export const useAdjacentSelectableMenuIndex = ({ children }) => { + const isChildSelectable = useCallback( + newIndex => { + const child = children[newIndex]; + return child.type.isSelectable && !child.props.disabled; + }, + [children] + ); + + const getNextSelectableIndex = useCallback( + currentActiveItemIndex => { + let newIndex; + for (let offset = 1; offset <= children.length; offset++) { + newIndex = (currentActiveItemIndex + offset) % children.length; + if (isChildSelectable(newIndex)) { + return newIndex; + } + } + return null; + }, + [children, isChildSelectable] + ); + + const getPreviousSelectableIndex = useCallback( + currentActiveItemIndex => { + let newIndex; + for (let offset = children.length - 1; offset > 0; offset--) { + newIndex = (currentActiveItemIndex + offset) % children.length; + if (isChildSelectable(newIndex)) { + return newIndex; + } + } + return null; + }, + [children, isChildSelectable] + ); + + return { getNextSelectableIndex, getPreviousSelectableIndex }; +}; diff --git a/src/components/Menu/Menu/hooks/useLastNavigationDirection.jsx b/src/components/Menu/Menu/hooks/useLastNavigationDirection.jsx index 648694a30c..e01be75d6c 100644 --- a/src/components/Menu/Menu/hooks/useLastNavigationDirection.jsx +++ b/src/components/Menu/Menu/hooks/useLastNavigationDirection.jsx @@ -1,4 +1,4 @@ -import { useRef, useState, useCallback } from "react"; +import { useRef, useCallback } from "react"; import { ARROW_DOWN_KEYS, ARROW_LEFT_KEYS, @@ -13,21 +13,28 @@ const NAVIGATION_KEYS = [...ARROW_UP_KEYS, ...ARROW_RIGHT_KEYS, ...ARROW_DOWN_KE export const useLastNavigationDirection = () => { const documentRef = useRef(document); - const [lastNavigationDirection, setLastNavigationDirection] = useState(undefined); + const lastNavigationDirectionRef = useRef(); - const onKeyEvent = useCallback(({ key }) => { - if (ARROW_UP_KEYS.includes(key)) { - setLastNavigationDirection(NAV_DIRECTIONS.UP); - } else if (ARROW_RIGHT_KEYS.includes(key)) { - setLastNavigationDirection(NAV_DIRECTIONS.RIGHT); - } else if (ARROW_DOWN_KEYS.includes(key)) { - setLastNavigationDirection(NAV_DIRECTIONS.DOWN); - } else if (ARROW_LEFT_KEYS.includes(key)) { - setLastNavigationDirection(NAV_DIRECTIONS.LEFT); - } + const setLastNavigationDirection = useCallback(dir => { + lastNavigationDirectionRef.current = dir; }, []); + const onKeyEvent = useCallback( + ({ key }) => { + if (ARROW_UP_KEYS.includes(key)) { + setLastNavigationDirection(NAV_DIRECTIONS.UP); + } else if (ARROW_RIGHT_KEYS.includes(key)) { + setLastNavigationDirection(NAV_DIRECTIONS.RIGHT); + } else if (ARROW_DOWN_KEYS.includes(key)) { + setLastNavigationDirection(NAV_DIRECTIONS.DOWN); + } else if (ARROW_LEFT_KEYS.includes(key)) { + setLastNavigationDirection(NAV_DIRECTIONS.LEFT); + } + }, + [setLastNavigationDirection] + ); + useKeyEvent({ ref: documentRef, capture: true, keys: NAVIGATION_KEYS, callback: onKeyEvent }); - return { lastNavigationDirection }; + return { lastNavigationDirectionRef }; }; diff --git a/src/components/Menu/Menu/hooks/useMenuKeyboardNavigation.jsx b/src/components/Menu/Menu/hooks/useMenuKeyboardNavigation.jsx index 711a5b991a..4316addd62 100644 --- a/src/components/Menu/Menu/hooks/useMenuKeyboardNavigation.jsx +++ b/src/components/Menu/Menu/hooks/useMenuKeyboardNavigation.jsx @@ -1,4 +1,5 @@ import { useCallback, useMemo } from "react"; +import { ARROW_DOWN_KEYS, ARROW_UP_KEYS } from "../../../../hooks/useFullKeyboardListeners"; import useKeyEvent from "../../../../hooks/useKeyEvent"; const ARROW_DIRECTIONS = { @@ -6,18 +7,12 @@ const ARROW_DIRECTIONS = { DOWN: "down" }; -const ARROW_DOWN_KEYS = ["ArrowDown"]; -const ARROW_UP_KEYS = ["ArrowUp"]; const ENTER_KEYS = ["Enter"]; -const isChildSelectable = (newIndex, children) => { - const child = children[newIndex]; - return child.type.isSelectable && !child.props.disabled; -}; - export default function useMenuKeyboardNavigation( hasOpenSubMenu, - children, + getNextSelectableIndex, + getPreviousSelectableIndex, activeItemIndex, setActiveItemIndex, isVisible, @@ -31,24 +26,14 @@ export default function useMenuKeyboardNavigation( if (hasOpenSubMenu) return false; if (direction === ARROW_DIRECTIONS.DOWN) { - for (let offset = 1; offset <= children.length; offset++) { - newIndex = (activeItemIndex + offset) % children.length; - if (isChildSelectable(newIndex, children)) { - break; - } - } + newIndex = getNextSelectableIndex(activeItemIndex); } else if (direction === ARROW_DIRECTIONS.UP) { - for (let offset = children.length - 1; offset > 0; offset--) { - newIndex = (activeItemIndex + offset) % children.length; - if (isChildSelectable(newIndex, children)) { - break; - } - } + newIndex = getPreviousSelectableIndex(activeItemIndex); } if (newIndex || newIndex === 0) setActiveItemIndex(newIndex); }, - [activeItemIndex, children, hasOpenSubMenu, setActiveItemIndex] + [activeItemIndex, getNextSelectableIndex, getPreviousSelectableIndex, hasOpenSubMenu, setActiveItemIndex] ); const onArrowUp = useCallback(() => { onArrowKeyEvent(ARROW_DIRECTIONS.UP); diff --git a/src/components/Menu/MenuGridItem/MenuGridItem.jsx b/src/components/Menu/MenuGridItem/MenuGridItem.jsx new file mode 100644 index 0000000000..f675f6ec94 --- /dev/null +++ b/src/components/Menu/MenuGridItem/MenuGridItem.jsx @@ -0,0 +1,118 @@ +import React, { useRef, forwardRef, useCallback } from "react"; +import PropTypes from "prop-types"; +import cx from "classnames"; +import { useFocusWithin } from "@react-aria/interactions"; +import useMergeRefs from "../../../hooks/useMergeRefs"; +import "./MenuGridItem.scss"; +import { GridKeyboardNavigationContext } from "../../GridKeyboardNavigationContext/GridKeyboardNavigationContext"; +import { useMenuGridItemNavContext } from "./useMenuGridItemNavContext"; +import { useFocusGridItemByActiveStatus } from "./useFocusGridItemByActiveStatus"; + +const MenuGridItem = forwardRef( + ( + { + className, + id, + children, + index, + activeItemIndex, + closeMenu, + getNextSelectableIndex, + getPreviousSelectableIndex, + setActiveItemIndex, + setSubMenuIsOpenByIndex, + isUnderSubMenu, + disabled + }, + ref + ) => { + const componentRef = useRef(null); + const mergedRef = useMergeRefs({ refs: [ref, componentRef] }); + const childRef = useRef(); + + const child = children && React.Children.only(children); + if (!child) { + console.error( + "MenuGridItem can accept only a single element as first level child, this element is not valid: ", + child + ); + } + + const onFocusWithinChange = useCallback( + isFocusWithin => { + setSubMenuIsOpenByIndex(index, isFocusWithin); + if (isFocusWithin) { + setActiveItemIndex(index); + } + }, + [index, setActiveItemIndex, setSubMenuIsOpenByIndex] + ); + const { focusWithinProps } = useFocusWithin({ onFocusWithinChange }); + + useFocusGridItemByActiveStatus({ wrapperRef: componentRef, childRef, activeItemIndex, index }); + + const keyboardContext = useMenuGridItemNavContext({ + wrapperRef: mergedRef, + setActiveItemIndex, + getPreviousSelectableIndex, + getNextSelectableIndex, + activeItemIndex, + isUnderSubMenu, + closeMenu + }); + + return ( +
+ + {React.cloneElement(child, { + ...child?.props, + disabled, + ref: childRef + })} + +
+ ); + } +); + +MenuGridItem.isMenuChild = true; +MenuGridItem.isSelectable = true; + +MenuGridItem.propTypes = { + children: PropTypes.element, + className: PropTypes.string, + disabled: PropTypes.bool, // if true, keyboard navigation will skip on this item. Also, this prop will be passed on to the child + closeMenu: PropTypes.func, // a callback to close the wrapping menu + id: PropTypes.string, + activeItemIndex: PropTypes.number, // the currently active index of the wrapping menu + setActiveItemIndex: PropTypes.func, + getNextSelectableIndex: PropTypes.func, + getPreviousSelectableIndex: PropTypes.func, + index: PropTypes.number, // the index of this item + isUnderSubMenu: PropTypes.bool, // true if this item is under a submenu, and not a top-level menu + setSubMenuIsOpenByIndex: PropTypes.func +}; + +MenuGridItem.defaultProps = { + children: undefined, + className: undefined, + disabled: false, + id: undefined, + isUnderSubMenu: false, + closeMenu: undefined, + activeItemIndex: -1, + setActiveItemIndex: undefined, + index: undefined, + setSubMenuIsOpenByIndex: undefined, + getNextSelectableIndex: undefined, + getPreviousSelectableIndex: undefined +}; + +export default MenuGridItem; diff --git a/src/components/Menu/MenuGridItem/MenuGridItem.scss b/src/components/Menu/MenuGridItem/MenuGridItem.scss new file mode 100644 index 0000000000..992e0b8130 --- /dev/null +++ b/src/components/Menu/MenuGridItem/MenuGridItem.scss @@ -0,0 +1 @@ +@import "../../../styles/themes.scss"; diff --git a/src/components/Menu/MenuGridItem/__stories__/MenuGridItem.stories.js b/src/components/Menu/MenuGridItem/__stories__/MenuGridItem.stories.js new file mode 100644 index 0000000000..180206c5f0 --- /dev/null +++ b/src/components/Menu/MenuGridItem/__stories__/MenuGridItem.stories.js @@ -0,0 +1,62 @@ +import { DialogContentContainer, Menu, MenuItem, MenuTitle } from "../../.."; +import { DummyNavigableGrid } from "../../../GridKeyboardNavigationContext/__stories__/useGridKeyboardNavigationContext.stories"; +import { Activity, Code, Favorite, Feedback, Invite, Settings } from "../../../Icon/Icons"; +import MenuGridItem from "../MenuGridItem"; +import "./MenuGridItem.stories.scss"; + +export const menuGridItemTemplate = args => ( + + + + + + + +); + +export const menuGridItemWithDisabled = args => ( + + + + + + + + + + + + + +); + +export const menuGridItemInSubMenus = args => ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+); diff --git a/src/components/Menu/MenuGridItem/__stories__/MenuGridItem.stories.mdx b/src/components/Menu/MenuGridItem/__stories__/MenuGridItem.stories.mdx new file mode 100644 index 0000000000..ae7951e4ac --- /dev/null +++ b/src/components/Menu/MenuGridItem/__stories__/MenuGridItem.stories.mdx @@ -0,0 +1,91 @@ +import MenuGridItem from "../MenuGridItem"; +import { ArgsTable, Story, Canvas, Meta } from "@storybook/addon-docs"; +import { + createComponentTemplate, + createStoryMetaSettings +} from "../../../../storybook/functions/create-component-story"; +import { MENU_BUTTON, MENU } from "../../../../storybook/components/related-components/component-description-map"; +import { menuGridItemTemplate, menuGridItemWithDisabled, menuGridItemInSubMenus } from "./MenuGridItem.stories"; +import "./MenuGridItem.stories.scss"; + +export const metaSettings = createStoryMetaSettings({ + component: MenuGridItem, + enumPropNamesArray: [], // List enum props here + iconPropNamesArray: [], // List props that are typed as icons here + actionPropsArray: [] // List the component's actions here +}); + + + + + +# MenuGridItem + +- [Overview](#overview) +- [Props](#props) +- [Usage](#usage) +- [Use cases and examples](#use-cases-and-examples) +- [Related components](#related-components) +- [Feedback](#feedback) + +## Overview + +MenuGridItem can be used to place a grid-like, keyboard navigable container, inside a Menu. +The user will be able to interact and navigate into and from the grid in a natural way. + + + + {menuGridItemTemplate.bind({})} + + + +## Props + +Since MenuGridItem should be used only inside a Menu, almost all of the props below will be supplied automatically by the wrapping Menu. + + + +## Usage + +Also, the referenced element should have a tabIndex value (probably -1)., + <>MenuGridItem will pass the disabled prop to the child. The child should handle this prop and disable interactions., + <>To support a "disabled" mode, the child must have a prop named disabled (it will be automatically detected)., +]} /> + +Check the MenuItem or MenuItemButton components + +## Use cases and examples + +### With disabled states + +Disabled items will be "skipped" when using keyboard navigation. Try it for yourself! + + + + {menuGridItemWithDisabled.bind({})} + + + +### Inside sub-menus + +Keyboard navigation is also supported in sub-menus + + + + {menuGridItemInSubMenus.bind({})} + + + +## Related components + + diff --git a/src/components/Menu/MenuGridItem/__stories__/MenuGridItem.stories.scss b/src/components/Menu/MenuGridItem/__stories__/MenuGridItem.stories.scss new file mode 100644 index 0000000000..26d14f3c65 --- /dev/null +++ b/src/components/Menu/MenuGridItem/__stories__/MenuGridItem.stories.scss @@ -0,0 +1,5 @@ +@import "../../../../styles/themes.scss"; + +.storybook-menu-grid-item-long-story { + height: 500px; +} diff --git a/src/components/Menu/MenuGridItem/__tests__/__snapshots__/menuGridItem-snapshot-tests.jest.js.snap b/src/components/Menu/MenuGridItem/__tests__/__snapshots__/menuGridItem-snapshot-tests.jest.js.snap new file mode 100644 index 0000000000..ba960e452f --- /dev/null +++ b/src/components/Menu/MenuGridItem/__tests__/__snapshots__/menuGridItem-snapshot-tests.jest.js.snap @@ -0,0 +1,33 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MenuGridItem renders correctly with a child 1`] = ` +
+
+ Hello! +
+
+`; + +exports[`MenuGridItem renders correctly with a disabled child 1`] = ` +
+
+ this should be disabled +
+
+`; diff --git a/src/components/Menu/MenuGridItem/__tests__/menuGridItem-snapshot-tests.jest.js b/src/components/Menu/MenuGridItem/__tests__/menuGridItem-snapshot-tests.jest.js new file mode 100644 index 0000000000..50e27b3d19 --- /dev/null +++ b/src/components/Menu/MenuGridItem/__tests__/menuGridItem-snapshot-tests.jest.js @@ -0,0 +1,40 @@ +import React from "react"; +import renderer from "react-test-renderer"; +import MenuGridItem from "../MenuGridItem"; + +const NO_OP = () => {}; + +describe("MenuGridItem renders correctly", () => { + const FAKE_REQUIRED_PROPS = { + closeMenu: NO_OP, + setActiveItemIndex: NO_OP, + getNextSelectableIndex: NO_OP, + getPreviousSelectableIndex: NO_OP, + setSubMenuIsOpenByIndex: NO_OP + }; + + it("with a child", () => { + const tree = renderer + .create( + +
Hello!
+
+ ) + .toJSON(); + + expect(tree).toMatchSnapshot(); + }); + + it("with a disabled child", () => { + // MenuGridItem should pass the disabled prop into DivWrapper + const DivWrapper = ({ disabled }) =>
this should be disabled
; + const tree = renderer + .create( + + + + ) + .toJSON(); + expect(tree).toMatchSnapshot(); + }); +}); diff --git a/src/components/Menu/MenuGridItem/__tests__/menuGridItem-tests.jest.js b/src/components/Menu/MenuGridItem/__tests__/menuGridItem-tests.jest.js new file mode 100644 index 0000000000..a87b15ff87 --- /dev/null +++ b/src/components/Menu/MenuGridItem/__tests__/menuGridItem-tests.jest.js @@ -0,0 +1,32 @@ +import { cleanup, render } from "@testing-library/react"; +import React from "react"; +import MenuGridItem from "../MenuGridItem"; + +const NO_OP = () => {}; + +describe("MenuGridItem", () => { + const FAKE_REQUIRED_PROPS = { + closeMenu: NO_OP, + setActiveItemIndex: NO_OP, + getNextSelectableIndex: NO_OP, + getPreviousSelectableIndex: NO_OP, + setSubMenuIsOpenByIndex: NO_OP + }; + + afterEach(() => { + cleanup(); + }); + + it("should pass the disabled prop to the child", () => { + const { getByTestId } = render( + + + + ); + const child = getByTestId("my-div"); + + expect(child).toBeDisabled; + }); +}); diff --git a/src/components/Menu/MenuGridItem/__tests__/useFocusGridItemByActiveStatus.jest.js b/src/components/Menu/MenuGridItem/__tests__/useFocusGridItemByActiveStatus.jest.js new file mode 100644 index 0000000000..6fec7feac1 --- /dev/null +++ b/src/components/Menu/MenuGridItem/__tests__/useFocusGridItemByActiveStatus.jest.js @@ -0,0 +1,81 @@ +import { cleanup, renderHook } from "@testing-library/react-hooks"; +import { useFocusGridItemByActiveStatus } from "../useFocusGridItemByActiveStatus"; + +import * as GridKeyboardNavigationContextHelperModule from "../../../GridKeyboardNavigationContext/helper"; +import * as useLastNavigationDirectionModule from "../../Menu/hooks/useLastNavigationDirection"; + +describe("useFocusGridItemByActiveStatus", () => { + let element, childElement, wrapperRef, childRef; + + beforeEach(() => { + element = document.createElement("div"); + document.body.appendChild(element); + jest.spyOn(element, "blur"); + + childElement = document.createElement("input"); + element.appendChild(childElement); + + wrapperRef = { current: element }; + childRef = { current: childElement }; + + jest.spyOn(GridKeyboardNavigationContextHelperModule, "focusElementWithDirection"); + jest.spyOn(useLastNavigationDirectionModule, "useLastNavigationDirection"); + }); + + afterEach(() => { + element.remove(); + childElement.remove(); + cleanup(); + jest.restoreAllMocks(); + }); + + it("it should blur the wrapper element if index != activeItemIndex", () => { + renderHook(() => useFocusGridItemByActiveStatus({ index: 0, activeItemIndex: 1, wrapperRef, childRef })); + + expect(element.blur).toHaveBeenCalledTimes(1); + }); + + it("it should blur the wrapper element if activeItemIndex changes from the given index to a different one", () => { + const props = { index: 0, activeItemIndex: 0, wrapperRef, childRef }; + const { rerender } = renderHook(() => useFocusGridItemByActiveStatus(props)); + expect(element.blur).not.toBeCalled(); + + props.activeItemIndex = props.index + 1; + rerender(); + + expect(element.blur).toHaveBeenCalledTimes(1); + }); + + it("should focus the child element, with current direction, when mounting and index === activeItemIndex", () => { + mockLastNavigationDirection("some direction"); + renderHook(() => useFocusGridItemByActiveStatus({ index: 1, activeItemIndex: 1, wrapperRef, childRef })); + + expect(GridKeyboardNavigationContextHelperModule.focusElementWithDirection).toHaveBeenCalledTimes(1); + expect(GridKeyboardNavigationContextHelperModule.focusElementWithDirection).toHaveBeenLastCalledWith( + childRef, + "some direction" + ); + }); + + it("should focus the child element, with current direction, when activeItemIndex changes to given index", () => { + mockLastNavigationDirection("some direction"); + const props = { index: 1, activeItemIndex: 0, wrapperRef, childRef }; + const { rerender } = renderHook(() => useFocusGridItemByActiveStatus(props)); + expect(GridKeyboardNavigationContextHelperModule.focusElementWithDirection).not.toBeCalled(); + + props.activeItemIndex = props.index; + rerender(); + + expect(GridKeyboardNavigationContextHelperModule.focusElementWithDirection).toHaveBeenCalledTimes(1); + expect(GridKeyboardNavigationContextHelperModule.focusElementWithDirection).toHaveBeenLastCalledWith( + childRef, + "some direction" + ); + }); + + function mockLastNavigationDirection(currentDirectionValue) { + useLastNavigationDirectionModule.useLastNavigationDirection.mockReturnValue({ + lastNavigationDirectionRef: { current: currentDirectionValue } + }); + } +}); diff --git a/src/components/Menu/MenuGridItem/__tests__/useMenuGridItemNavContext.jest.js b/src/components/Menu/MenuGridItem/__tests__/useMenuGridItemNavContext.jest.js new file mode 100644 index 0000000000..429f6fb257 --- /dev/null +++ b/src/components/Menu/MenuGridItem/__tests__/useMenuGridItemNavContext.jest.js @@ -0,0 +1,107 @@ +import { cleanup, renderHook } from "@testing-library/react-hooks"; +import { NAV_DIRECTIONS } from "../../../../hooks/useFullKeyboardListeners"; +import * as GridKeyboardNavigationContextModule from "../../../GridKeyboardNavigationContext/GridKeyboardNavigationContext"; +import { useMenuGridItemNavContext } from "../useMenuGridItemNavContext"; + +describe("useMenuGridItemNavContext", () => { + let element; + let elementRef; + + beforeEach(() => { + element = document.createElement("div"); + document.body.appendChild(element); + + elementRef = { current: element }; + }); + + afterEach(() => { + element.remove(); + cleanup(); + jest.resetAllMocks(); + }); + + describe("onOutboundNavigation", () => { + const outBoundingElement = document.createElement("span"); + + it("should call the parent GridKeyboardNavigationContext", () => { + const mockedInnerUseContext = { onOutboundNavigation: jest.fn() }; + const { result } = renderHookForTest({ mockedInnerUseContext }); + + result.current.onOutboundNavigation(outBoundingElement, NAV_DIRECTIONS.UP); + + expect(mockedInnerUseContext.onOutboundNavigation).toHaveBeenCalledWith(outBoundingElement, NAV_DIRECTIONS.UP); + }); + + it("should set the previous item as active when navigating up", () => { + const getPreviousSelectableIndex = jest.fn().mockReturnValue(10); + const setActiveItemIndex = jest.fn(); + const { result } = renderHookForTest({ activeItemIndex: 15, getPreviousSelectableIndex, setActiveItemIndex }); + + result.current.onOutboundNavigation(outBoundingElement, NAV_DIRECTIONS.UP); + + expect(getPreviousSelectableIndex).toHaveBeenCalledTimes(1); + expect(getPreviousSelectableIndex).toHaveBeenCalledWith(15); + expect(setActiveItemIndex).toHaveBeenCalledTimes(1); + expect(setActiveItemIndex).toHaveBeenCalledWith(10); + }); + + it("should set the next item as active when navigating down", () => { + const getNextSelectableIndex = jest.fn().mockReturnValue(20); + const setActiveItemIndex = jest.fn(); + const { result } = renderHookForTest({ activeItemIndex: 15, getNextSelectableIndex, setActiveItemIndex }); + + result.current.onOutboundNavigation(outBoundingElement, NAV_DIRECTIONS.DOWN); + + expect(getNextSelectableIndex).toHaveBeenCalledTimes(1); + expect(getNextSelectableIndex).toHaveBeenCalledWith(15); + expect(setActiveItemIndex).toHaveBeenCalledTimes(1); + expect(setActiveItemIndex).toHaveBeenCalledWith(20); + }); + + it("should do nothing when not under a sub menu and pressing left", () => { + const setActiveItemIndex = jest.fn(); + const closeMenu = jest.fn(); + const { result } = renderHookForTest({ setActiveItemIndex, closeMenu }); + + result.current.onOutboundNavigation(outBoundingElement, NAV_DIRECTIONS.LEFT); + + expect(setActiveItemIndex).not.toHaveBeenCalled(); + expect(closeMenu).not.toHaveBeenCalled(); + }); + + it("should close the sub menu and pressing left", () => { + const closeMenu = jest.fn(); + const { result } = renderHookForTest({ closeMenu, isUnderSubMenu: true }); + + result.current.onOutboundNavigation(outBoundingElement, NAV_DIRECTIONS.LEFT); + + expect(closeMenu).toHaveBeenCalledTimes(1); + }); + + function renderHookForTest({ + setActiveItemIndex = jest.fn(), + getNextSelectableIndex = jest.fn(), + getPreviousSelectableIndex = jest.fn(), + activeItemIndex = 0, + isUnderSubMenu = false, + closeMenu = jest.fn(), + mockedInnerUseContext = { onOutboundNavigation: jest.fn() } + }) { + jest + .spyOn(GridKeyboardNavigationContextModule, "useGridKeyboardNavigationContext") + .mockReturnValue(mockedInnerUseContext); + + return renderHook(() => + useMenuGridItemNavContext({ + wrapperRef: elementRef, + setActiveItemIndex, + getNextSelectableIndex, + getPreviousSelectableIndex, + activeItemIndex, + isUnderSubMenu, + closeMenu + }) + ); + } + }); +}); diff --git a/src/components/Menu/MenuGridItem/useFocusGridItemByActiveStatus.jsx b/src/components/Menu/MenuGridItem/useFocusGridItemByActiveStatus.jsx new file mode 100644 index 0000000000..6bdc7b8b24 --- /dev/null +++ b/src/components/Menu/MenuGridItem/useFocusGridItemByActiveStatus.jsx @@ -0,0 +1,16 @@ +import { useMemo, useEffect } from "react"; +import { focusElementWithDirection } from "../../GridKeyboardNavigationContext/helper"; +import { useLastNavigationDirection } from "../Menu/hooks/useLastNavigationDirection"; + +export const useFocusGridItemByActiveStatus = ({ wrapperRef, childRef, index, activeItemIndex }) => { + const { lastNavigationDirectionRef } = useLastNavigationDirection(); + const isActive = useMemo(() => index === activeItemIndex, [activeItemIndex, index]); + + useEffect(() => { + if (isActive) { + focusElementWithDirection(childRef, lastNavigationDirectionRef.current); + } else { + wrapperRef?.current?.blur?.(); + } + }, [childRef, isActive, lastNavigationDirectionRef, wrapperRef]); +}; diff --git a/src/components/Menu/MenuGridItem/useMenuGridItemNavContext.jsx b/src/components/Menu/MenuGridItem/useMenuGridItemNavContext.jsx new file mode 100644 index 0000000000..ab5dfd07e1 --- /dev/null +++ b/src/components/Menu/MenuGridItem/useMenuGridItemNavContext.jsx @@ -0,0 +1,49 @@ +import { useMemo } from "react"; +import { NAV_DIRECTIONS } from "../../../hooks/useFullKeyboardListeners"; +import { useGridKeyboardNavigationContext } from "../../GridKeyboardNavigationContext/GridKeyboardNavigationContext"; + +export const useMenuGridItemNavContext = ({ + wrapperRef, + setActiveItemIndex, + getPreviousSelectableIndex, + getNextSelectableIndex, + activeItemIndex, + isUnderSubMenu, + closeMenu +}) => { + /* + * This is an "adapter" between the Grid Keyboard Navigation mechanism and the Menu navigation mechanism. + * Currently, the two mechanisms work a bit differently. + * In the future, when the Menu component will use a GridKeyboardNavigationContext, this adapter shouldn't be needed anymore. + */ + const innerKeyboardContext = useGridKeyboardNavigationContext([], wrapperRef); + const keyboardContext = useMemo( + () => ({ + onOutboundNavigation: (elementRef, direction) => { + innerKeyboardContext.onOutboundNavigation(elementRef, direction); + + switch (direction) { + case NAV_DIRECTIONS.UP: + return setActiveItemIndex(getPreviousSelectableIndex(activeItemIndex)); + case NAV_DIRECTIONS.DOWN: + return setActiveItemIndex(getNextSelectableIndex(activeItemIndex)); + case NAV_DIRECTIONS.LEFT: + if (isUnderSubMenu) { + closeMenu(); + } + } + } + }), + [ + innerKeyboardContext, + setActiveItemIndex, + getPreviousSelectableIndex, + activeItemIndex, + getNextSelectableIndex, + isUnderSubMenu, + closeMenu + ] + ); + + return keyboardContext; +}; diff --git a/src/components/Menu/MenuItem/hooks/useMenuItemMouseEvents.js b/src/components/Menu/MenuItem/hooks/useMenuItemMouseEvents.js index cca5aa10a9..903185f825 100644 --- a/src/components/Menu/MenuItem/hooks/useMenuItemMouseEvents.js +++ b/src/components/Menu/MenuItem/hooks/useMenuItemMouseEvents.js @@ -34,7 +34,7 @@ export default function useMenuItemMouseEvents( } } - if (isActive) { + if (isActive && hasChildren) { setSubMenuIsOpenByIndex(index, !!isMouseEnter); } }, [ diff --git a/src/components/Menu/MenuItemButton/__stories__/MenuItemButton.stories.mdx b/src/components/Menu/MenuItemButton/__stories__/MenuItemButton.stories.mdx index 440eccf954..48cf0245c0 100644 --- a/src/components/Menu/MenuItemButton/__stories__/MenuItemButton.stories.mdx +++ b/src/components/Menu/MenuItemButton/__stories__/MenuItemButton.stories.mdx @@ -46,6 +46,8 @@ export const menuItemButtonTemplate = createComponentTemplate(MenuItemButton); ## Props +Need to place multiple buttons in a grid-like layout inside a Menu? Consider using the MenuGridItem component + ## Variants ### States diff --git a/src/components/index.js b/src/components/index.js index 3b6a00a568..44077eeae0 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -75,3 +75,4 @@ export { default as ColorUtils } from "../utils/colors-utils"; export { default as Slider } from "./Slider/Slider"; export { default as IconButton } from "./IconButton/IconButton"; export { default as Flex } from "./Flex/Flex"; +export { default as MenuGridItem } from "./Menu/MenuGridItem/MenuGridItem"; diff --git a/webpack/published-components.js b/webpack/published-components.js index f8952a4330..e0016a57a2 100644 --- a/webpack/published-components.js +++ b/webpack/published-components.js @@ -47,6 +47,7 @@ const publishedComponents = { MenuItemButton: "components/Menu/MenuItemButton/MenuItemButton.jsx", MenuDivider: "components/Menu/MenuDivider/MenuDivider.jsx", Menu: "components/Menu/Menu/Menu.jsx", + MenuGridItem: "components/Menu/MenuGridItem/MenuGridItem.jsx", Dialog: "components/Dialog/Dialog.jsx", DialogContentContainer: "components/DialogContentContainer/DialogContentContainer.jsx", AttentionBox: "components/AttentionBox/AttentionBox.jsx", @@ -94,7 +95,8 @@ const publishedComponents = { useTimeout: "hooks/useTimeout/index.js", usePrevious: "hooks/usePrevious.js", useMergeRefs: "hooks/useMergeRefs.js", - useIsMouseOver: "hooks/useIsMouseOver.js" + useIsMouseOver: "hooks/useIsMouseOver.js", + useGridKeyboardNavigation: "hooks/useGridKeyboardNavigation/useGridKeyboardNavigation.js" }; function getPublishedComponents() {