From 77f0d012e06b6a00f1b7ee64ef91d43683a703b6 Mon Sep 17 00:00:00 2001 From: Mikkel Laursen Date: Tue, 25 Jan 2022 19:20:29 -0700 Subject: [PATCH] feat(utils): Implemented new keyboard focus behavior --- packages/utils/src/index.ts | 1 + .../ActiveDescendantMovementProvider.tsx | 91 +++ .../KeyboardMovementProvider.tsx | 105 ++++ .../ActiveDescendantMovementProvider.tsx | 300 +++++++++ .../__tests__/KeyboardMovementProvider.tsx | 567 ++++++++++++++++++ .../__tests__/__snapshots__/utils.ts.snap | 67 +++ .../src/keyboardMovement/__tests__/utils.ts | 458 ++++++++++++++ .../activeDescendantContext.ts | 38 ++ packages/utils/src/keyboardMovement/index.ts | 9 + .../src/keyboardMovement/movementContext.ts | 55 ++ packages/utils/src/keyboardMovement/types.ts | 129 ++++ .../keyboardMovement/useActiveDescendant.ts | 53 ++ .../useActiveDescendantFocus.ts | 71 +++ .../src/keyboardMovement/useKeyboardFocus.ts | 283 +++++++++ .../useKeyboardFocusableElement.ts | 29 + packages/utils/src/keyboardMovement/utils.ts | 162 +++++ packages/utils/src/types.ts | 8 + 17 files changed, 2426 insertions(+) create mode 100644 packages/utils/src/keyboardMovement/ActiveDescendantMovementProvider.tsx create mode 100644 packages/utils/src/keyboardMovement/KeyboardMovementProvider.tsx create mode 100644 packages/utils/src/keyboardMovement/__tests__/ActiveDescendantMovementProvider.tsx create mode 100644 packages/utils/src/keyboardMovement/__tests__/KeyboardMovementProvider.tsx create mode 100644 packages/utils/src/keyboardMovement/__tests__/__snapshots__/utils.ts.snap create mode 100644 packages/utils/src/keyboardMovement/__tests__/utils.ts create mode 100644 packages/utils/src/keyboardMovement/activeDescendantContext.ts create mode 100644 packages/utils/src/keyboardMovement/index.ts create mode 100644 packages/utils/src/keyboardMovement/movementContext.ts create mode 100644 packages/utils/src/keyboardMovement/types.ts create mode 100644 packages/utils/src/keyboardMovement/useActiveDescendant.ts create mode 100644 packages/utils/src/keyboardMovement/useActiveDescendantFocus.ts create mode 100644 packages/utils/src/keyboardMovement/useKeyboardFocus.ts create mode 100644 packages/utils/src/keyboardMovement/useKeyboardFocusableElement.ts create mode 100644 packages/utils/src/keyboardMovement/utils.ts diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 9062560b19..a2e6f978d2 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -10,6 +10,7 @@ export * from "./Dir"; export * from "./events"; export * from "./getPercentage"; export * from "./hover"; +export * from "./keyboardMovement"; export * from "./layout"; export * from "./loop"; export * from "./mode"; diff --git a/packages/utils/src/keyboardMovement/ActiveDescendantMovementProvider.tsx b/packages/utils/src/keyboardMovement/ActiveDescendantMovementProvider.tsx new file mode 100644 index 0000000000..ee25167ed7 --- /dev/null +++ b/packages/utils/src/keyboardMovement/ActiveDescendantMovementProvider.tsx @@ -0,0 +1,91 @@ +import { ReactElement, ReactNode, useMemo } from "react"; +import { + ActiveDescendantContext, + ActiveDescendantContextProvider, +} from "./activeDescendantContext"; + +/** + * @internal + * @remarks \@since 5.0.0 + */ +export interface ActiveDescendantMovementProviderProps + extends ActiveDescendantContext { + children: ReactNode; +} + +/** + * This component should be used with the {@link KeyboardMovementProvider} + * component to implement custom keyboard focusable behavior using + * `aria-activedescendant`. + * + * @example + * Base Example + * ```tsx + * function Descendant({ id, children, ...props }: HTMLAttributes): ReactElement { + * const { ref, active } = useActiveDescendant({ id }); + * return ( + *
+ * {children} + *
+ * ); + * } + * + * function CustomFocus(): ReactElement { + * const { providerProps, focusIndex, ...containerProps } = + * useActiveDescendantFocus() + * + * return ( + * + *
+ * + * Some Option + * + *
+ *
+ * ); + * } + * + * function Example() { + * return ( + * + * + * + * ); + * } + * ``` + * + * @see https://www.w3.org/TR/wai-aria-practices/#kbd_focus_activedescendant + * @internal + * @remarks \@since 5.0.0 + */ +export function ActiveDescendantMovementProvider({ + children, + activeId, + setActiveId, +}: ActiveDescendantMovementProviderProps): ReactElement { + return ( + ({ + activeId, + setActiveId, + }), + [activeId, setActiveId] + )} + > + {children} + + ); +} diff --git a/packages/utils/src/keyboardMovement/KeyboardMovementProvider.tsx b/packages/utils/src/keyboardMovement/KeyboardMovementProvider.tsx new file mode 100644 index 0000000000..383fe8c814 --- /dev/null +++ b/packages/utils/src/keyboardMovement/KeyboardMovementProvider.tsx @@ -0,0 +1,105 @@ +import { ReactElement, ReactNode, useMemo, useRef } from "react"; + +import { + DEFAULT_KEYBOARD_MOVEMENT, + KeyboardMovementContextProvider, +} from "./movementContext"; +import type { + KeyboardFocusContext, + KeyboardFocusElementData, + KeyboardMovementBehavior, + KeyboardMovementConfig, + KeyboardMovementConfiguration, +} from "./types"; +import { getSearchText } from "./utils"; + +/** + * @remarks \@since 5.0.0 + */ +export interface KeyboardMovementProviderProps + extends KeyboardMovementBehavior, + KeyboardMovementConfiguration { + children: ReactNode; +} + +/** + * @example + * Main Usage + * ```tsx + * function Example() { + * return ( + * + * + * + * ); + * } + * + * function CustomKeyboardFocusWidget() { + * const { onKeyDown } = useKeyboardFocus(); + * return ( + *
+ * + * + * + * + *
+ * ); + * } + * + * function FocusableChild() { + * const refCallback = useKeyboardFocusableElement() + * + * return
Content
; + * } + * ``` + * + * @remarks \@since 5.0.0 + */ +export function KeyboardMovementProvider({ + children, + loopable = false, + searchable = false, + includeDisabled = false, + incrementKeys = DEFAULT_KEYBOARD_MOVEMENT.incrementKeys, + decrementKeys = DEFAULT_KEYBOARD_MOVEMENT.decrementKeys, + jumpToFirstKeys = DEFAULT_KEYBOARD_MOVEMENT.jumpToFirstKeys, + jumpToLastKeys = DEFAULT_KEYBOARD_MOVEMENT.jumpToLastKeys, +}: KeyboardMovementProviderProps): ReactElement { + const watching = useRef([]); + const configuration: KeyboardMovementConfig = { + incrementKeys, + decrementKeys, + jumpToFirstKeys, + jumpToLastKeys, + }; + const config = useRef(configuration); + config.current = configuration; + + const value = useMemo( + () => ({ + attach(element) { + watching.current.push({ + element, + content: getSearchText(element, searchable), + }); + }, + detach(element) { + watching.current = watching.current.filter( + (cache) => cache.element !== element + ); + }, + watching, + config, + loopable, + searchable, + includeDisabled: includeDisabled, + }), + [includeDisabled, loopable, searchable] + ); + + return ( + + {children} + + ); +} diff --git a/packages/utils/src/keyboardMovement/__tests__/ActiveDescendantMovementProvider.tsx b/packages/utils/src/keyboardMovement/__tests__/ActiveDescendantMovementProvider.tsx new file mode 100644 index 0000000000..12fc4f040b --- /dev/null +++ b/packages/utils/src/keyboardMovement/__tests__/ActiveDescendantMovementProvider.tsx @@ -0,0 +1,300 @@ +import { fireEvent, render } from "@testing-library/react"; +import { HTMLAttributes, ReactElement, ReactNode, Ref, useEffect } from "react"; + +import { UserInteractionModeListener } from "../../mode/UserInteractionModeListener"; +import { useActiveDescendantContext } from "../activeDescendantContext"; +import { ActiveDescendantMovementProvider } from "../ActiveDescendantMovementProvider"; +import { KeyboardMovementProvider } from "../KeyboardMovementProvider"; +import { + KeyboardMovementBehavior, + KeyboardMovementConfiguration, +} from "../types"; +import { useActiveDescendant } from "../useActiveDescendant"; +import { + ActiveDescendantFocusHookOptions, + useActiveDescendantFocus, +} from "../useActiveDescendantFocus"; + +function Descendant({ + id, + nodeRef, + role = "option", + tabIndex = -1, + children, + ...props +}: HTMLAttributes & { + id: string; + nodeRef?: Ref; +}): ReactElement { + const { ref, active } = useActiveDescendant({ + id: id, + ref: nodeRef, + }); + + return ( +
+ {children} +
+ ); +} + +interface ActiveDescendantContainerProps + extends ActiveDescendantFocusHookOptions { + children?: ReactNode; +} + +function ActiveDescendantContainer({ + children, + ...options +}: ActiveDescendantContainerProps): ReactElement { + const { + providerProps, + focusIndex: _focusIndex, + ...props + } = useActiveDescendantFocus(options); + + return ( +
+ + {children || + Array.from({ length: 5 }, (_, i) => ( + + {`Option ${i + 1}`} + + ))} + +
+ ); +} + +interface TestProps + extends KeyboardMovementBehavior, + KeyboardMovementConfiguration, + ActiveDescendantFocusHookOptions {} + +function Test({ + onFocus, + onSearch, + onKeyDown, + onDecrement, + onIncrement, + onJumpToLast, + onJumpToFirst, + defaultActiveId, + ...props +}: TestProps): ReactElement { + return ( + + + + + + ); +} + +describe("ActiveDescendantMovementProvider", () => { + it("should throw an error if there is not a KeyboardMovementProvider or ActiveDescendantMovementProvider as a parent component", () => { + function NoKeyboardMovementProvider(): ReactElement { + return ( + {}}> + Child 1 + + ); + } + + function NoActiveDescendantMovementProvider(): null { + const { setActiveId } = useActiveDescendantContext(); + useEffect(() => { + setActiveId("boop"); + }, [setActiveId]); + return null; + } + + const error = jest.spyOn(console, "error").mockImplementation(() => { + // don't print error to console + }); + expect(() => { + render(); + }).toThrowError("KeyboardMovementProvider must be a parent component."); + expect(() => { + render(); + }).toThrowError( + "ActiveDescendantMovementProvider must be a parent component." + ); + error.mockRestore(); + }); + + // TODO + it("should correctly focus elements when there is no default active id", () => { + const { getByRole } = render(); + const listbox = getByRole("listbox"); + const option1 = getByRole("option", { name: "Option 1" }); + const option2 = getByRole("option", { name: "Option 2" }); + const option3 = getByRole("option", { name: "Option 3" }); + const option4 = getByRole("option", { name: "Option 4" }); + const option5 = getByRole("option", { name: "Option 5" }); + + expect(document.activeElement).toBe(document.body); + expect(listbox).toHaveAttribute("aria-activedescendant", ""); + expect(option1).not.toHaveClass("active"); + expect(option2).not.toHaveClass("active"); + expect(option3).not.toHaveClass("active"); + expect(option4).not.toHaveClass("active"); + expect(option5).not.toHaveClass("active"); + + fireEvent.keyDown(document.body, { key: "Tab" }); + listbox.focus(); + expect(document.activeElement).toBe(listbox); + expect(listbox).toHaveAttribute("aria-activedescendant", ""); + + expect(document.activeElement).toBe(listbox); + fireEvent.keyDown(listbox, { key: "ArrowUp" }); + expect(listbox).toHaveAttribute("aria-activedescendant", "option-1"); + expect(option1).toHaveClass("active"); + expect(option2).not.toHaveClass("active"); + expect(option3).not.toHaveClass("active"); + expect(option4).not.toHaveClass("active"); + expect(option5).not.toHaveClass("active"); + + fireEvent.keyDown(listbox, { key: "ArrowDown" }); + expect(document.activeElement).toBe(listbox); + expect(listbox).toHaveAttribute("aria-activedescendant", "option-2"); + expect(option1).not.toHaveClass("active"); + expect(option2).toHaveClass("active"); + expect(option3).not.toHaveClass("active"); + expect(option4).not.toHaveClass("active"); + expect(option5).not.toHaveClass("active"); + }); + + it("should allow for a defaultActiveId", () => { + const { getByRole } = render(); + const listbox = getByRole("listbox"); + const option1 = getByRole("option", { name: "Option 1" }); + const option2 = getByRole("option", { name: "Option 2" }); + + expect(document.activeElement).toBe(document.body); + fireEvent.keyDown(document.body, { key: "Tab" }); + expect(listbox).toHaveAttribute("aria-activedescendant", "option-1"); + expect(option1).toHaveClass("active"); + expect(option2).not.toHaveClass("active"); + + listbox.focus(); + expect(document.activeElement).toBe(listbox); + expect(listbox).toHaveAttribute("aria-activedescendant", "option-1"); + expect(option1).toHaveClass("active"); + expect(option2).not.toHaveClass("active"); + + fireEvent.keyDown(listbox, { key: "ArrowDown" }); + expect(document.activeElement).toBe(listbox); + expect(listbox).toHaveAttribute("aria-activedescendant", "option-2"); + expect(option1).not.toHaveClass("active"); + expect(option2).toHaveClass("active"); + }); + + it("should handle looping correctly", () => { + const { getByRole } = render(); + const listbox = getByRole("listbox"); + const option1 = getByRole("option", { name: "Option 1" }); + const option2 = getByRole("option", { name: "Option 2" }); + const option3 = getByRole("option", { name: "Option 3" }); + const option4 = getByRole("option", { name: "Option 4" }); + const option5 = getByRole("option", { name: "Option 5" }); + + expect(document.activeElement).toBe(document.body); + fireEvent.keyDown(document.body, { key: "Tab" }); + expect(listbox).toHaveAttribute("aria-activedescendant", ""); + expect(option1).not.toHaveClass("active"); + expect(option2).not.toHaveClass("active"); + expect(option3).not.toHaveClass("active"); + expect(option4).not.toHaveClass("active"); + expect(option5).not.toHaveClass("active"); + + listbox.focus(); + expect(document.activeElement).toBe(listbox); + expect(listbox).toHaveAttribute("aria-activedescendant", ""); + + fireEvent.keyDown(listbox, { key: "ArrowUp" }); + expect(document.activeElement).toBe(listbox); + expect(listbox).toHaveAttribute("aria-activedescendant", "option-5"); + expect(option1).not.toHaveClass("active"); + expect(option2).not.toHaveClass("active"); + expect(option3).not.toHaveClass("active"); + expect(option4).not.toHaveClass("active"); + expect(option5).toHaveClass("active"); + + fireEvent.keyDown(listbox, { key: "ArrowDown" }); + expect(document.activeElement).toBe(listbox); + expect(listbox).toHaveAttribute("aria-activedescendant", "option-1"); + expect(option1).toHaveClass("active"); + expect(option2).not.toHaveClass("active"); + expect(option3).not.toHaveClass("active"); + expect(option4).not.toHaveClass("active"); + expect(option5).not.toHaveClass("active"); + }); + + it("should handle searching correctly", () => { + const { getByRole } = render(); + const listbox = getByRole("listbox"); + const option1 = getByRole("option", { name: "Option 1" }); + const option2 = getByRole("option", { name: "Option 2" }); + const option3 = getByRole("option", { name: "Option 3" }); + const option4 = getByRole("option", { name: "Option 4" }); + const option5 = getByRole("option", { name: "Option 5" }); + + expect(document.activeElement).toBe(document.body); + fireEvent.keyDown(document.body, { key: "Tab" }); + expect(listbox).toHaveAttribute("aria-activedescendant", ""); + expect(option1).not.toHaveClass("active"); + expect(option2).not.toHaveClass("active"); + expect(option3).not.toHaveClass("active"); + expect(option4).not.toHaveClass("active"); + expect(option5).not.toHaveClass("active"); + + listbox.focus(); + expect(document.activeElement).toBe(listbox); + expect(listbox).toHaveAttribute("aria-activedescendant", ""); + + fireEvent.keyDown(listbox, { key: "A" }); + expect(document.activeElement).toBe(listbox); + expect(listbox).toHaveAttribute("aria-activedescendant", ""); + expect(option1).not.toHaveClass("active"); + expect(option2).not.toHaveClass("active"); + expect(option3).not.toHaveClass("active"); + expect(option4).not.toHaveClass("active"); + expect(option5).not.toHaveClass("active"); + + fireEvent.keyDown(listbox, { key: "O" }); + expect(document.activeElement).toBe(listbox); + expect(listbox).toHaveAttribute("aria-activedescendant", "option-1"); + expect(option1).toHaveClass("active"); + expect(option2).not.toHaveClass("active"); + expect(option3).not.toHaveClass("active"); + expect(option4).not.toHaveClass("active"); + expect(option5).not.toHaveClass("active"); + + fireEvent.keyDown(listbox, { key: "O" }); + expect(document.activeElement).toBe(listbox); + expect(listbox).toHaveAttribute("aria-activedescendant", "option-2"); + expect(option1).not.toHaveClass("active"); + expect(option2).toHaveClass("active"); + expect(option3).not.toHaveClass("active"); + expect(option4).not.toHaveClass("active"); + expect(option5).not.toHaveClass("active"); + }); +}); diff --git a/packages/utils/src/keyboardMovement/__tests__/KeyboardMovementProvider.tsx b/packages/utils/src/keyboardMovement/__tests__/KeyboardMovementProvider.tsx new file mode 100644 index 0000000000..235f1548a6 --- /dev/null +++ b/packages/utils/src/keyboardMovement/__tests__/KeyboardMovementProvider.tsx @@ -0,0 +1,567 @@ +import { fireEvent, render } from "@testing-library/react"; +import { + FocusEvent, + HTMLAttributes, + KeyboardEvent, + ReactElement, + ReactNode, + Ref, +} from "react"; + +import { UserInteractionModeListener } from "../../mode/UserInteractionModeListener"; +import { KeyboardMovementProvider } from "../KeyboardMovementProvider"; +import { + KeyboardMovementBehavior, + KeyboardMovementConfiguration, +} from "../types"; +import { + KeyboardFocusHandler, + KeyboardFocusHookOptions, + useKeyboardFocus, +} from "../useKeyboardFocus"; +import { useKeyboardFocusableElement } from "../useKeyboardFocusableElement"; + +function FocusableChild({ + nodeRef, + role = "menuitem", + tabIndex = -1, + children, + ...props +}: HTMLAttributes & { + nodeRef?: Ref; +}): ReactElement { + const refCallback = useKeyboardFocusableElement(nodeRef); + + return ( +
+ {children} +
+ ); +} + +interface CustomFocusContainerProps + extends KeyboardFocusHookOptions { + children?: ReactNode; + disabledIndexes?: number[]; +} + +function CustomFocusContainer({ + children, + disabledIndexes = [], + ...props +}: CustomFocusContainerProps): ReactElement { + const { onKeyDown, onFocus } = useKeyboardFocus(props); + + return ( +
+ {children || + Array.from({ length: 5 }, (_, i) => ( + + {`Child ${i + 1}`} + + ))} +
+ ); +} + +interface TestProps + extends KeyboardMovementBehavior, + KeyboardMovementConfiguration, + CustomFocusContainerProps {} + +function Test({ + children, + disabledIndexes, + onFocus, + onSearch, + onKeyDown, + onDecrement, + onIncrement, + onJumpToLast, + onFocusChange, + onJumpToFirst, + ...props +}: TestProps): ReactElement { + return ( + + + + {children} + + + + ); +} + +const list = [ + "Frozen yogurt", + "Ice cream sandwhich", + "Eclair", + "Cupcake", + "Gingerbread", + "Jelly bean", + "Lollipop", + "Honeycomb", + "Custard", + "Donut", + "KitKat", + "Chocolate cake", + "Vanilla ice cream", +] as const; + +interface SearchTestProps + extends Omit, + KeyboardMovementConfiguration { + disabledNames?: readonly typeof list[number][]; +} + +function SearchTest({ + disabledNames = [], + ...props +}: SearchTestProps): ReactElement { + return ( + + {list.map((name, i) => ( + + {name} + + ))} + + ); +} + +describe("KeyboardMovementProvider", () => { + it("should throw an error if a child attempts to attach or detach a ref without a parent KeyboardMovementProvider", () => { + const error = jest.spyOn(console, "error").mockImplementation(() => { + // don't print error to console + }); + expect(() => render()).toThrowError( + "KeyboardMovementProvider must be a parent component." + ); + error.mockRestore(); + }); + + it("should default to focusing the first element, not looping, not focusing elements when typing, and the DEFAULT_KEYBOARD_MOVEMENT keys", () => { + const { getByRole } = render(); + const menu = getByRole("menu"); + const child1 = getByRole("menuitem", { name: "Child 1" }); + const child2 = getByRole("menuitem", { name: "Child 2" }); + const child3 = getByRole("menuitem", { name: "Child 3" }); + const child4 = getByRole("menuitem", { name: "Child 4" }); + const child5 = getByRole("menuitem", { name: "Child 5" }); + + expect(document.activeElement).toBe(document.body); + // enable keyboard mode for tests + fireEvent.keyDown(document.body, { key: "Tab" }); + + fireEvent.focus(menu); + expect(document.activeElement).toBe(child1); + + fireEvent.keyDown(menu, { key: "ArrowUp" }); + expect(document.activeElement).toBe(child1); + + fireEvent.keyDown(menu, { key: "ArrowDown" }); + expect(document.activeElement).toBe(child2); + + fireEvent.keyDown(menu, { key: "ArrowDown" }); + expect(document.activeElement).toBe(child3); + + fireEvent.keyDown(menu, { key: "ArrowDown" }); + expect(document.activeElement).toBe(child4); + + fireEvent.keyDown(menu, { key: "ArrowDown" }); + expect(document.activeElement).toBe(child5); + + fireEvent.keyDown(menu, { key: "ArrowDown" }); + expect(document.activeElement).toBe(child5); + + fireEvent.keyDown(menu, { key: "Home" }); + expect(document.activeElement).toBe(child1); + + fireEvent.keyDown(menu, { key: "End" }); + expect(document.activeElement).toBe(child5); + }); + + it("should allow the keyboard focus to be looped", () => { + const { getByRole } = render(); + const menu = getByRole("menu"); + const child1 = getByRole("menuitem", { name: "Child 1" }); + const child2 = getByRole("menuitem", { name: "Child 2" }); + const child3 = getByRole("menuitem", { name: "Child 3" }); + const child4 = getByRole("menuitem", { name: "Child 4" }); + const child5 = getByRole("menuitem", { name: "Child 5" }); + + expect(document.activeElement).toBe(document.body); + // enable keyboard mode for tests + fireEvent.keyDown(document.body, { key: "Tab" }); + + fireEvent.focus(menu); + expect(document.activeElement).toBe(child1); + + fireEvent.keyDown(menu, { key: "ArrowUp" }); + expect(document.activeElement).toBe(child5); + + fireEvent.keyDown(menu, { key: "ArrowDown" }); + expect(document.activeElement).toBe(child1); + + fireEvent.keyDown(menu, { key: "ArrowDown" }); + expect(document.activeElement).toBe(child2); + + fireEvent.keyDown(menu, { key: "ArrowDown" }); + expect(document.activeElement).toBe(child3); + + fireEvent.keyDown(menu, { key: "ArrowDown" }); + expect(document.activeElement).toBe(child4); + + fireEvent.keyDown(menu, { key: "ArrowDown" }); + expect(document.activeElement).toBe(child5); + + fireEvent.keyDown(menu, { key: "End" }); + expect(document.activeElement).toBe(child5); + + fireEvent.keyDown(menu, { key: "Home" }); + expect(document.activeElement).toBe(child1); + }); + + it("should allow the focus to move by typing the first letter of the element", () => { + const { getByRole, rerender } = render(); + const menu = getByRole("menu"); + const frozenYogurt = getByRole("menuitem", { name: "Frozen yogurt" }); + const eclair = getByRole("menuitem", { name: "Eclair" }); + const cupcake = getByRole("menuitem", { name: "Cupcake" }); + const jellyBean = getByRole("menuitem", { name: "Jelly bean" }); + const custard = getByRole("menuitem", { name: "Custard" }); + const chocolateCake = getByRole("menuitem", { name: "Chocolate cake" }); + + // enable keyboard mode for tests + fireEvent.keyDown(document.body, { key: "Tab" }); + + fireEvent.focus(menu); + expect(document.activeElement).toBe(frozenYogurt); + + fireEvent.keyDown(frozenYogurt, { key: "E" }); + expect(document.activeElement).toBe(eclair); + + fireEvent.keyDown(eclair, { key: "J" }); + expect(document.activeElement).toBe(jellyBean); + + fireEvent.keyDown(jellyBean, { key: "C" }); + expect(document.activeElement).toBe(custard); + + fireEvent.keyDown(custard, { key: "C" }); + expect(document.activeElement).toBe(chocolateCake); + + fireEvent.keyDown(custard, { key: "C" }); + expect(document.activeElement).toBe(cupcake); + + fireEvent.keyDown(custard, { key: "C" }); + expect(document.activeElement).toBe(custard); + + fireEvent.keyDown(custard, { key: "E" }); + expect(document.activeElement).toBe(eclair); + + rerender(); + expect(document.activeElement).toBe(eclair); + + fireEvent.keyDown(eclair, { key: "C" }); + expect(document.activeElement).toBe(custard); + + fireEvent.keyDown(custard, { key: "E" }); + expect(document.activeElement).toBe(eclair); + + rerender(); + expect(document.activeElement).toBe(eclair); + + fireEvent.keyDown(eclair, { key: "C" }); + expect(document.activeElement).toBe(cupcake); + }); + + it("should not focus disabled elements by default", () => { + const { getByRole, rerender } = render( + + ); + let menu = getByRole("menu"); + let child1 = getByRole("menuitem", { name: "Child 1" }); + let child2 = getByRole("menuitem", { name: "Child 2" }); + let child3 = getByRole("menuitem", { name: "Child 3" }); + let child4 = getByRole("menuitem", { name: "Child 4" }); + let child5 = getByRole("menuitem", { name: "Child 5" }); + expect(child1).toHaveAttribute("aria-disabled", "true"); + expect(child2).not.toHaveAttribute("aria-disabled", "true"); + expect(child3).toHaveAttribute("aria-disabled", "true"); + expect(child4).not.toHaveAttribute("aria-disabled", "true"); + expect(child5).toHaveAttribute("aria-disabled", "true"); + expect(document.activeElement).toBe(document.body); + + // enable keyboard mode for tests + fireEvent.keyDown(document.body, { key: "Tab" }); + + fireEvent.focus(menu); + expect(document.activeElement).toBe(child2); + + fireEvent.keyDown(menu, { key: "ArrowDown" }); + expect(document.activeElement).toBe(child4); + + fireEvent.keyDown(menu, { key: "ArrowDown" }); + expect(document.activeElement).toBe(child4); + + fireEvent.keyDown(menu, { key: "ArrowUp" }); + expect(document.activeElement).toBe(child2); + + fireEvent.keyDown(menu, { key: "ArrowUp" }); + expect(document.activeElement).toBe(child2); + + fireEvent.keyDown(menu, { key: "End" }); + expect(document.activeElement).toBe(child4); + + fireEvent.keyDown(menu, { key: "Home" }); + expect(document.activeElement).toBe(child2); + + rerender(); + menu = getByRole("menu"); + child1 = getByRole("menuitem", { name: "Child 1" }); + child2 = getByRole("menuitem", { name: "Child 2" }); + child3 = getByRole("menuitem", { name: "Child 3" }); + child4 = getByRole("menuitem", { name: "Child 4" }); + child5 = getByRole("menuitem", { name: "Child 5" }); + expect(child1).toHaveAttribute("aria-disabled", "true"); + expect(child2).not.toHaveAttribute("aria-disabled", "true"); + expect(child3).toHaveAttribute("aria-disabled", "true"); + expect(child4).not.toHaveAttribute("aria-disabled", "true"); + expect(child5).toHaveAttribute("aria-disabled", "true"); + + // enable keyboard mode for tests + fireEvent.keyDown(document.body, { key: "Tab" }); + fireEvent.focus(menu); + expect(document.activeElement).toBe(child2); + + fireEvent.keyDown(menu, { key: "ArrowDown" }); + expect(document.activeElement).toBe(child4); + + fireEvent.keyDown(menu, { key: "ArrowDown" }); + expect(document.activeElement).toBe(child2); + + fireEvent.keyDown(menu, { key: "ArrowUp" }); + expect(document.activeElement).toBe(child4); + + fireEvent.keyDown(menu, { key: "ArrowUp" }); + expect(document.activeElement).toBe(child2); + }); + + it("should maintain focus on the current element if there are no other focusable elements available", () => { + const { getByRole } = render(); + const menu = getByRole("menu"); + const child1 = getByRole("menuitem", { name: "Child 1" }); + const child2 = getByRole("menuitem", { name: "Child 2" }); + const child3 = getByRole("menuitem", { name: "Child 3" }); + const child4 = getByRole("menuitem", { name: "Child 4" }); + const child5 = getByRole("menuitem", { name: "Child 5" }); + expect(child1).not.toHaveAttribute("aria-disabled", "true"); + expect(child2).not.toHaveAttribute("aria-disabled", "true"); + expect(child3).toHaveAttribute("aria-disabled", "true"); + expect(child4).toHaveAttribute("aria-disabled", "true"); + expect(child5).toHaveAttribute("aria-disabled", "true"); + expect(document.activeElement).toBe(document.body); + + // enable keyboard mode for tests + fireEvent.keyDown(document.body, { key: "Tab" }); + fireEvent.focus(menu); + expect(document.activeElement).toBe(child1); + + fireEvent.keyDown(child1, { key: "ArrowDown" }); + expect(document.activeElement).toBe(child2); + + fireEvent.keyDown(child2, { key: "ArrowDown" }); + expect(document.activeElement).toBe(child2); + }); + + it("should allow disabled elements to be focusable if the includeDisabled prop is true", () => { + const { getByRole } = render( + + ); + const menu = getByRole("menu"); + const child1 = getByRole("menuitem", { name: "Child 1" }); + const child2 = getByRole("menuitem", { name: "Child 2" }); + const child3 = getByRole("menuitem", { name: "Child 3" }); + const child4 = getByRole("menuitem", { name: "Child 4" }); + const child5 = getByRole("menuitem", { name: "Child 5" }); + + expect(document.activeElement).toBe(document.body); + // enable keyboard mode for tests + fireEvent.keyDown(document.body, { key: "Tab" }); + + fireEvent.focus(menu); + expect(document.activeElement).toBe(child1); + + fireEvent.keyDown(menu, { key: "ArrowUp" }); + expect(document.activeElement).toBe(child5); + + fireEvent.keyDown(menu, { key: "ArrowDown" }); + expect(document.activeElement).toBe(child1); + + fireEvent.keyDown(menu, { key: "ArrowDown" }); + expect(document.activeElement).toBe(child2); + + fireEvent.keyDown(menu, { key: "ArrowDown" }); + expect(document.activeElement).toBe(child3); + + fireEvent.keyDown(menu, { key: "ArrowDown" }); + expect(document.activeElement).toBe(child4); + + fireEvent.keyDown(menu, { key: "ArrowDown" }); + expect(document.activeElement).toBe(child5); + + fireEvent.keyDown(menu, { key: "End" }); + expect(document.activeElement).toBe(child5); + + fireEvent.keyDown(menu, { key: "Home" }); + expect(document.activeElement).toBe(child1); + }); + + it("should prevent default behavior if any of the KeyboardFocusContext call event.stopPropagation()", () => { + const stopPropagation: KeyboardFocusHandler = ({ + event, + }) => { + event.stopPropagation(); + }; + const onFocus = jest.fn((event: FocusEvent) => + event.stopPropagation() + ); + const onKeyDown = jest.fn((event: KeyboardEvent) => { + event.stopPropagation(); + }); + const onSearch = jest.fn(stopPropagation); + const onIncrement = jest.fn(stopPropagation); + const onDecrement = jest.fn(stopPropagation); + const onJumpToFirst = jest.fn(stopPropagation); + const onJumpToLast = jest.fn(stopPropagation); + const { getByRole, rerender } = render( + + ); + const menu = getByRole("menu"); + expect(document.activeElement).toBe(document.body); + expect(onFocus).not.toBeCalled(); + expect(onKeyDown).not.toBeCalled(); + expect(onSearch).not.toBeCalled(); + expect(onIncrement).not.toBeCalled(); + expect(onDecrement).not.toBeCalled(); + expect(onJumpToFirst).not.toBeCalled(); + expect(onJumpToLast).not.toBeCalled(); + + fireEvent.focus(menu); + expect(onFocus).toBeCalled(); + expect(onKeyDown).not.toBeCalled(); + expect(onSearch).not.toBeCalled(); + expect(onIncrement).not.toBeCalled(); + expect(onDecrement).not.toBeCalled(); + expect(onJumpToFirst).not.toBeCalled(); + expect(onJumpToLast).not.toBeCalled(); + expect(document.activeElement).toBe(document.body); + + fireEvent.keyDown(menu, { key: "ArrowDown" }); + expect(onFocus).toBeCalled(); + expect(onKeyDown).toBeCalled(); + expect(onSearch).not.toBeCalled(); + expect(onIncrement).not.toBeCalled(); + expect(onDecrement).not.toBeCalled(); + expect(onJumpToFirst).not.toBeCalled(); + expect(onJumpToLast).not.toBeCalled(); + expect(document.activeElement).toBe(document.body); + + rerender( + + ); + expect(onFocus).toBeCalled(); + expect(onKeyDown).toBeCalled(); + expect(onSearch).not.toBeCalled(); + expect(onIncrement).not.toBeCalled(); + expect(onDecrement).not.toBeCalled(); + expect(onJumpToFirst).not.toBeCalled(); + expect(onJumpToLast).not.toBeCalled(); + expect(document.activeElement).toBe(document.body); + + fireEvent.keyDown(menu, { key: "C" }); + expect(onFocus).toBeCalled(); + expect(onKeyDown).toBeCalled(); + expect(onSearch).toBeCalled(); + expect(onIncrement).not.toBeCalled(); + expect(onDecrement).not.toBeCalled(); + expect(onJumpToFirst).not.toBeCalled(); + expect(onJumpToLast).not.toBeCalled(); + expect(document.activeElement).toBe(document.body); + + fireEvent.keyDown(menu, { key: "ArrowDown" }); + expect(onFocus).toBeCalled(); + expect(onKeyDown).toBeCalled(); + expect(onSearch).toBeCalled(); + expect(onIncrement).toBeCalled(); + expect(onDecrement).not.toBeCalled(); + expect(onJumpToFirst).not.toBeCalled(); + expect(onJumpToLast).not.toBeCalled(); + expect(document.activeElement).toBe(document.body); + + fireEvent.keyDown(menu, { key: "ArrowUp" }); + expect(onFocus).toBeCalled(); + expect(onKeyDown).toBeCalled(); + expect(onSearch).toBeCalled(); + expect(onIncrement).toBeCalled(); + expect(onDecrement).toBeCalled(); + expect(onJumpToFirst).not.toBeCalled(); + expect(onJumpToLast).not.toBeCalled(); + expect(document.activeElement).toBe(document.body); + + fireEvent.keyDown(menu, { key: "Home" }); + expect(onFocus).toBeCalled(); + expect(onKeyDown).toBeCalled(); + expect(onSearch).toBeCalled(); + expect(onIncrement).toBeCalled(); + expect(onDecrement).toBeCalled(); + expect(onJumpToFirst).toBeCalled(); + expect(onJumpToLast).not.toBeCalled(); + expect(document.activeElement).toBe(document.body); + + fireEvent.keyDown(menu, { key: "End" }); + expect(onFocus).toBeCalled(); + expect(onKeyDown).toBeCalled(); + expect(onSearch).toBeCalled(); + expect(onIncrement).toBeCalled(); + expect(onDecrement).toBeCalled(); + expect(onJumpToFirst).toBeCalled(); + expect(onJumpToLast).toBeCalled(); + expect(document.activeElement).toBe(document.body); + }); +}); diff --git a/packages/utils/src/keyboardMovement/__tests__/__snapshots__/utils.ts.snap b/packages/utils/src/keyboardMovement/__tests__/__snapshots__/utils.ts.snap new file mode 100644 index 0000000000..1f1e5bab6b --- /dev/null +++ b/packages/utils/src/keyboardMovement/__tests__/__snapshots__/utils.ts.snap @@ -0,0 +1,67 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`getSearchText should remove font icons, aria-hidden elements, and hidden elements 1`] = ` +
+ + close + + some div text +
+`; + +exports[`getSearchText should remove font icons, aria-hidden elements, and hidden elements 2`] = ` +
+ some div text + + close + +
+`; + +exports[`getSearchText should remove font icons, aria-hidden elements, and hidden elements 3`] = ` +
+ + some div text +
+`; + +exports[`getSearchText should remove font icons, aria-hidden elements, and hidden elements 4`] = ` +
+ some div text + +
+`; + +exports[`getSearchText should remove font icons, aria-hidden elements, and hidden elements 5`] = ` +
+ + some div text +
+`; + +exports[`getSearchText should remove font icons, aria-hidden elements, and hidden elements 6`] = ` +
+ some div text + +
+`; diff --git a/packages/utils/src/keyboardMovement/__tests__/utils.ts b/packages/utils/src/keyboardMovement/__tests__/utils.ts new file mode 100644 index 0000000000..32d61e03a2 --- /dev/null +++ b/packages/utils/src/keyboardMovement/__tests__/utils.ts @@ -0,0 +1,458 @@ +import { KeyboardFocusElementData } from "../types"; +import { + getFirstFocusableIndex, + getLastFocusableIndex, + getNextFocusableIndex, + getSearchText, + isNotFocusable, +} from "../utils"; + +const createWatching = ( + elements: readonly HTMLElement[] +): readonly KeyboardFocusElementData[] => + elements.map((element) => ({ element, content: "" })); + +const div = document.createElement("div"); +div.textContent = "div"; +const button = document.createElement("button"); +button.textContent = "button"; +const input = document.createElement("input"); +input.type = "text"; +input.value = "input value"; + +const disabledDiv = document.createElement("div"); +disabledDiv.textContent = "div"; +disabledDiv.setAttribute("aria-disabled", "true"); +const disabledButton = document.createElement("button"); +disabledButton.textContent = "button"; +disabledButton.disabled = true; +const disabledInput = document.createElement("input"); +disabledInput.type = "text"; +disabledInput.value = "input value"; +disabledInput.disabled = true; + +describe("isNotFocusable", () => { + it("should always return true if there is no element", () => { + expect(isNotFocusable(undefined, true)).toBe(true); + expect(isNotFocusable(undefined, false)).toBe(true); + }); + + it("should always return false for non-disabled elements", () => { + expect(isNotFocusable(div, false)).toBe(false); + expect(isNotFocusable(div, true)).toBe(false); + expect(isNotFocusable(button, false)).toBe(false); + expect(isNotFocusable(button, true)).toBe(false); + expect(isNotFocusable(input, false)).toBe(false); + expect(isNotFocusable(input, true)).toBe(false); + }); + + it("should return true if disabled elements are not allowed and the element is disabled or aria-disabled", () => { + expect(isNotFocusable(disabledDiv, false)).toBe(true); + expect(isNotFocusable(disabledDiv, true)).toBe(false); + expect(isNotFocusable(disabledButton, false)).toBe(true); + expect(isNotFocusable(disabledButton, true)).toBe(false); + expect(isNotFocusable(disabledInput, false)).toBe(true); + expect(isNotFocusable(disabledInput, true)).toBe(false); + }); +}); + +describe("getFirstFocusableIndex", () => { + it("should return the first focusable element index", () => { + expect(getFirstFocusableIndex(createWatching([div]), false)).toBe(0); + expect(getFirstFocusableIndex(createWatching([div]), true)).toBe(0); + expect(getFirstFocusableIndex(createWatching([button]), false)).toBe(0); + expect(getFirstFocusableIndex(createWatching([button]), true)).toBe(0); + expect(getFirstFocusableIndex(createWatching([input]), false)).toBe(0); + expect(getFirstFocusableIndex(createWatching([input]), true)).toBe(0); + expect( + getFirstFocusableIndex( + createWatching([disabledDiv, disabledButton, disabledInput, div]), + false + ) + ).toBe(3); + expect( + getFirstFocusableIndex( + createWatching([disabledDiv, disabledButton, disabledInput, div]), + true + ) + ).toBe(0); + }); + + it("should return -1 if there are no focusable elements", () => { + expect(getFirstFocusableIndex([], false)).toBe(-1); + expect(getFirstFocusableIndex([], true)).toBe(-1); + expect(getFirstFocusableIndex(createWatching([disabledDiv]), false)).toBe( + -1 + ); + }); +}); + +describe("getLastFocusableIndex", () => { + it("should return the index of the last focusable element", () => { + expect(getLastFocusableIndex(createWatching([div]), false)).toBe(0); + expect(getLastFocusableIndex(createWatching([div]), true)).toBe(0); + expect(getLastFocusableIndex(createWatching([button]), false)).toBe(0); + expect(getLastFocusableIndex(createWatching([button]), true)).toBe(0); + expect(getLastFocusableIndex(createWatching([input]), false)).toBe(0); + expect(getLastFocusableIndex(createWatching([input]), true)).toBe(0); + + expect( + getLastFocusableIndex(createWatching([div, button, input]), false) + ).toBe(2); + expect( + getLastFocusableIndex(createWatching([div, button, input]), true) + ).toBe(2); + expect( + getLastFocusableIndex(createWatching([div, input, button]), false) + ).toBe(2); + expect( + getLastFocusableIndex(createWatching([div, input, button]), true) + ).toBe(2); + + expect( + getLastFocusableIndex( + createWatching([div, disabledDiv, disabledButton, disabledInput]), + false + ) + ).toBe(0); + expect( + getLastFocusableIndex( + createWatching([div, disabledDiv, disabledButton, disabledInput]), + true + ) + ).toBe(3); + expect( + getLastFocusableIndex( + createWatching([disabledDiv, disabledButton, div, disabledInput]), + false + ) + ).toBe(2); + expect( + getLastFocusableIndex( + createWatching([disabledDiv, disabledButton, div, disabledInput]), + true + ) + ).toBe(3); + }); + + it("should return -1 if there are no focusable elements", () => { + expect(getLastFocusableIndex([], false)).toBe(-1); + expect(getLastFocusableIndex([], true)).toBe(-1); + expect(getLastFocusableIndex(createWatching([disabledDiv]), false)).toBe( + -1 + ); + }); +}); + +describe("getNextFocusableIndex", () => { + it("should return the correct index when all the elements are focusable", () => { + const watching = createWatching([div, button, input]); + + expect( + getNextFocusableIndex({ + loopable: false, + watching, + increment: true, + includeDisabled: false, + currentFocusIndex: 0, + }) + ).toBe(1); + expect( + getNextFocusableIndex({ + loopable: false, + watching, + increment: true, + includeDisabled: false, + currentFocusIndex: 1, + }) + ).toBe(2); + expect( + getNextFocusableIndex({ + loopable: false, + watching, + increment: true, + includeDisabled: false, + currentFocusIndex: 2, + }) + ).toBe(2); + + expect( + getNextFocusableIndex({ + loopable: true, + watching, + increment: true, + includeDisabled: false, + currentFocusIndex: 0, + }) + ).toBe(1); + expect( + getNextFocusableIndex({ + loopable: true, + watching, + increment: true, + includeDisabled: false, + currentFocusIndex: 1, + }) + ).toBe(2); + expect( + getNextFocusableIndex({ + loopable: true, + watching, + increment: true, + includeDisabled: false, + currentFocusIndex: 2, + }) + ).toBe(0); + + expect( + getNextFocusableIndex({ + loopable: false, + watching, + increment: false, + includeDisabled: false, + currentFocusIndex: 2, + }) + ).toBe(1); + expect( + getNextFocusableIndex({ + loopable: false, + watching, + increment: false, + includeDisabled: false, + currentFocusIndex: 1, + }) + ).toBe(0); + expect( + getNextFocusableIndex({ + loopable: false, + watching, + increment: false, + includeDisabled: false, + currentFocusIndex: 0, + }) + ).toBe(0); + + expect( + getNextFocusableIndex({ + loopable: true, + watching, + increment: false, + includeDisabled: false, + currentFocusIndex: 2, + }) + ).toBe(1); + expect( + getNextFocusableIndex({ + loopable: true, + watching, + increment: false, + includeDisabled: false, + currentFocusIndex: 1, + }) + ).toBe(0); + expect( + getNextFocusableIndex({ + loopable: true, + watching, + increment: false, + includeDisabled: false, + currentFocusIndex: 0, + }) + ).toBe(2); + }); + + it("should return the correct index when some of the elements are not focusable", () => { + const watching = createWatching([ + disabledDiv, + div, + button, + disabledInput, + input, + disabledButton, + ]); + + expect( + getNextFocusableIndex({ + loopable: false, + watching, + increment: true, + includeDisabled: false, + currentFocusIndex: 1, + }) + ).toBe(2); + expect( + getNextFocusableIndex({ + loopable: false, + watching, + increment: true, + includeDisabled: false, + currentFocusIndex: 2, + }) + ).toBe(4); + expect( + getNextFocusableIndex({ + loopable: false, + watching, + increment: true, + includeDisabled: false, + currentFocusIndex: 4, + }) + ).toBe(4); + + expect( + getNextFocusableIndex({ + loopable: true, + watching, + increment: true, + includeDisabled: false, + currentFocusIndex: 1, + }) + ).toBe(2); + expect( + getNextFocusableIndex({ + loopable: true, + watching, + increment: true, + includeDisabled: false, + currentFocusIndex: 2, + }) + ).toBe(4); + expect( + getNextFocusableIndex({ + loopable: true, + watching, + increment: true, + includeDisabled: false, + currentFocusIndex: 4, + }) + ).toBe(1); + + expect( + getNextFocusableIndex({ + loopable: false, + watching, + increment: false, + includeDisabled: false, + currentFocusIndex: 4, + }) + ).toBe(2); + expect( + getNextFocusableIndex({ + loopable: false, + watching, + increment: false, + includeDisabled: false, + currentFocusIndex: 2, + }) + ).toBe(1); + expect( + getNextFocusableIndex({ + loopable: false, + watching, + increment: false, + includeDisabled: false, + currentFocusIndex: 1, + }) + ).toBe(1); + + expect( + getNextFocusableIndex({ + loopable: true, + watching, + increment: false, + includeDisabled: false, + currentFocusIndex: 4, + }) + ).toBe(2); + expect( + getNextFocusableIndex({ + loopable: true, + watching, + increment: false, + includeDisabled: false, + currentFocusIndex: 2, + }) + ).toBe(1); + expect( + getNextFocusableIndex({ + loopable: true, + watching, + increment: false, + includeDisabled: false, + currentFocusIndex: 1, + }) + ).toBe(4); + }); +}); + +describe("getSearchText", () => { + it("should return an empty string if the search behavior is not allowed", () => { + expect(getSearchText(div, false)).toBe(""); + expect(getSearchText(button, false)).toBe(""); + expect(getSearchText(input, false)).toBe(""); + expect(getSearchText(disabledDiv, false)).toBe(""); + expect(getSearchText(disabledButton, false)).toBe(""); + expect(getSearchText(disabledInput, false)).toBe(""); + }); + + it("should return first letter in the element as an uppercase string", () => { + expect(getSearchText(div, true)).toBe("D"); + expect(getSearchText(button, true)).toBe("B"); + expect(getSearchText(input, true)).toBe(""); + }); + + it("should remove font icons, aria-hidden elements, and hidden elements", () => { + const createFontIcon = (): HTMLElement => { + const fontIcon = document.createElement("i"); + fontIcon.className = "rmd-icon--font"; + fontIcon.textContent = "close"; + return fontIcon; + }; + const createAriaHidden = (): HTMLElement => { + const ariaHidden = document.createElement("div"); + ariaHidden.textContent = "aria hidden"; + ariaHidden.setAttribute("aria-hidden", "true"); + return ariaHidden; + }; + const createHidden = (): HTMLElement => { + const hidden = document.createElement("div"); + hidden.textContent = "hidden"; + hidden.hidden = true; + return hidden; + }; + const createText = (): Text => document.createTextNode("some div text"); + + const leadingIcon = document.createElement("div"); + leadingIcon.appendChild(createFontIcon()); + leadingIcon.appendChild(createText()); + const trailingIcon = document.createElement("div"); + trailingIcon.appendChild(createText()); + trailingIcon.appendChild(createFontIcon()); + const leadingAriaHidden = document.createElement("div"); + leadingAriaHidden.appendChild(createAriaHidden()); + leadingAriaHidden.appendChild(createText()); + const trailingAriaHidden = document.createElement("div"); + trailingAriaHidden.appendChild(createText()); + trailingAriaHidden.appendChild(createAriaHidden()); + const leadingHidden = document.createElement("div"); + leadingHidden.appendChild(createHidden()); + leadingHidden.appendChild(createText()); + const trailingHidden = document.createElement("div"); + trailingHidden.appendChild(createText()); + trailingHidden.appendChild(createHidden()); + + expect(leadingIcon).toMatchSnapshot(); + expect(trailingIcon).toMatchSnapshot(); + expect(leadingAriaHidden).toMatchSnapshot(); + expect(trailingAriaHidden).toMatchSnapshot(); + expect(leadingHidden).toMatchSnapshot(); + expect(trailingHidden).toMatchSnapshot(); + + expect(getSearchText(leadingIcon, true)).toBe("S"); + expect(getSearchText(trailingIcon, true)).toBe("S"); + expect(getSearchText(leadingAriaHidden, true)).toBe("S"); + expect(getSearchText(trailingAriaHidden, true)).toBe("S"); + expect(getSearchText(leadingHidden, true)).toBe("S"); + expect(getSearchText(trailingHidden, true)).toBe("S"); + }); +}); diff --git a/packages/utils/src/keyboardMovement/activeDescendantContext.ts b/packages/utils/src/keyboardMovement/activeDescendantContext.ts new file mode 100644 index 0000000000..306b8eb66d --- /dev/null +++ b/packages/utils/src/keyboardMovement/activeDescendantContext.ts @@ -0,0 +1,38 @@ +import { createContext, Dispatch, SetStateAction, useContext } from "react"; + +/** + * @remarks \@since 5.0.0 + * @internal + */ +export interface ActiveDescendantContext { + activeId: string; + setActiveId: Dispatch>; +} + +/** + * @remarks \@since 5.0.0 + * @internal + */ +const context = createContext({ + activeId: "", + setActiveId() { + throw new Error( + "ActiveDescendantMovementProvider must be a parent component." + ); + }, +}); +context.displayName = "ActiveDescendant"; + +/** + * @remarks \@since 5.0.0 + * @internal + */ +export const { Provider: ActiveDescendantContextProvider } = context; + +/** + * @remarks \@since 5.0.0 + * @internal + */ +export function useActiveDescendantContext(): Readonly { + return useContext(context); +} diff --git a/packages/utils/src/keyboardMovement/index.ts b/packages/utils/src/keyboardMovement/index.ts new file mode 100644 index 0000000000..397c484526 --- /dev/null +++ b/packages/utils/src/keyboardMovement/index.ts @@ -0,0 +1,9 @@ +export * from "./activeDescendantContext"; +export * from "./ActiveDescendantMovementProvider"; +export * from "./KeyboardMovementProvider"; +export * from "./movementContext"; +export * from "./types"; +export * from "./useActiveDescendant"; +export * from "./useActiveDescendantFocus"; +export * from "./useKeyboardFocus"; +export * from "./useKeyboardFocusableElement"; diff --git a/packages/utils/src/keyboardMovement/movementContext.ts b/packages/utils/src/keyboardMovement/movementContext.ts new file mode 100644 index 0000000000..4fa1a7f2ae --- /dev/null +++ b/packages/utils/src/keyboardMovement/movementContext.ts @@ -0,0 +1,55 @@ +import { createContext, useContext } from "react"; + +import type { KeyboardFocusContext, KeyboardMovementConfig } from "./types"; + +/** + * @remarks \@since 5.0.0 + * @internal + */ +const noop = (): void => { + if (process.env.NODE_ENV !== "production") { + throw new Error("KeyboardMovementProvider must be a parent component."); + } +}; + +/** + * Most custom keyboard functionality use these keys. + * + * @remarks \@since 5.0.0 + * @internal + */ +export const DEFAULT_KEYBOARD_MOVEMENT: Readonly = { + incrementKeys: ["ArrowDown"], + decrementKeys: ["ArrowUp"], + jumpToFirstKeys: ["Home"], + jumpToLastKeys: ["End"], +}; + +/** + * @remarks \@since 5.0.0 + * @internal + */ +const context = createContext({ + attach: noop, + detach: noop, + watching: { current: [] }, + loopable: false, + searchable: false, + includeDisabled: false, + config: { current: DEFAULT_KEYBOARD_MOVEMENT }, +}); +context.displayName = "KeyboardMovement"; + +/** + * @remarks \@since 5.0.0 + * @internal + */ +export const { Provider: KeyboardMovementContextProvider } = context; + +/** + * @remarks \@since 5.0.0 + * @internal + */ +export function useKeyboardFocusContext(): Readonly { + return useContext(context); +} diff --git a/packages/utils/src/keyboardMovement/types.ts b/packages/utils/src/keyboardMovement/types.ts new file mode 100644 index 0000000000..6a68c5545b --- /dev/null +++ b/packages/utils/src/keyboardMovement/types.ts @@ -0,0 +1,129 @@ +import type { NonNullRef } from "../types"; + +/** + * @remarks \@since 5.0.0 + */ +export interface KeyboardMovementConfiguration { + /** + * A list of keys that will attempt to increment the focus index by 1. + * + * @defaultValue `["ArrowDown"]` + */ + incrementKeys?: readonly string[]; + + /** + * A list of keys that will attempt to decrement the focus index by 1. + * + * @defaultValue `["ArrowUp"]` + */ + decrementKeys?: readonly string[]; + + /** + * A list of keys that will set the focus index to `0`. + * + * @defaultValue `["Home"]` + */ + jumpToFirstKeys?: readonly string[]; + + /** + * A list of keys that will set the focus index to the last focusable index. + * + * @defaultValue `["End"]` + */ + jumpToLastKeys?: readonly string[]; +} + +/** + * The defined {@link KeyboardMovementConfiguration} that should be used for + * custom keyboard focus behavior. + * + * @remarks \@since 5.0.0 + */ +export type KeyboardMovementConfig = Required; + +/** + * @remarks \@since 5.0.0 + */ +export interface KeyboardMovementBehavior { + /** + * Boolean if pressing a letter will focus the next item in the + * {@link KeyboardMovementProvider} that starts with the same letter. + * + * @defaultValue `false` + */ + searchable?: boolean; + + /** + * Boolean if the {@link KeyboardMovementProvider} should allow the focus behavior + * to loop from the first to last or last to first item instead of preventing + * any new focus behavior. In other words... if the last item is focused and + * the user presses a key that should advance the focus to the next focusable + * element, should the focus stay on the current element or loop back and + * focus the first focusable item. + * + * @defaultValue `false` + */ + loopable?: boolean; + + /** + * Boolean if elements that are `aria-disabled` or `disabled` should still be + * able to gain focus. + * + * @defaultValue `false` + */ + includeDisabled?: boolean; +} + +/** + * @remarks \@since 5.0.0 + * @internal + */ +export interface KeyboardFocusElementData { + /** + * The element that can be keyboard focused. + */ + element: HTMLElement; + + /** + * The text content of the element that is used for searching. This will be + * the empty string if the {@link KeyboardMovementBehavior.searchable} is + * false + */ + content: string; +} + +/** + * This is a ref containing all the {@link KeyboardFocusElementData} that are + * being watched by the {@link KeyboardMovementProvider}. This is generally used + * to focus specific elements by index, attempt to find an element by search + * text, or any additional custom focus behavior. + * + * @remarks \@since 5.0.0 + * @internal + */ +export type KeyboardFocusElementLookup = NonNullRef; + +/** + * @remarks \@since 5.0.0 + * @internal + */ +export interface KeyboardFocusContext + extends Required { + /** {@inheritDoc KeyboardMovementConfig} */ + config: NonNullRef; + + /** + * A function that is used to add an element to the list of focusable + * elements. + */ + attach(element: E): void; + + /** + * A function that is used to remove an element to the list of focusable + * elements. + */ + detach(element: E): void; + + /** {@inheritDoc KeyboardFocusElementLookup} */ + watching: KeyboardFocusElementLookup; +} diff --git a/packages/utils/src/keyboardMovement/useActiveDescendant.ts b/packages/utils/src/keyboardMovement/useActiveDescendant.ts new file mode 100644 index 0000000000..0af0858f58 --- /dev/null +++ b/packages/utils/src/keyboardMovement/useActiveDescendant.ts @@ -0,0 +1,53 @@ +import type { Ref, RefCallback } from "react"; +import { useActiveDescendantContext } from "./activeDescendantContext"; +import { useKeyboardFocusableElement } from "./useKeyboardFocusableElement"; + +/** + * @remarks \@since 5.0.0 + */ +export interface ActiveDescendantHookOptions { + /** + * The DOM id for the element. This is required so that the + * {@link ActiveDescendantContext.activeId} can be updated to the current + * element as needed. + */ + id: string; + + /** + * An optional ref to merge with the ref returned by this hook. + */ + ref?: Ref; +} + +/** + * @remarks \@since 5.0.0 + */ +export interface ActiveDescendantHookReturnValue { + /** + * A ref handler that **must** be provided to the DOM element for the active + * descendant movement to work correctly. + */ + ref: RefCallback; + + /** + * Boolean if this element is the current focus. This is useful for adding a + * focus class name since this element actually does not gain focus while + * active. + */ + active: boolean; +} + +/** + * @remarks \@since 5.0.0 + */ +export function useActiveDescendant({ + id, + ref, +}: ActiveDescendantHookOptions): ActiveDescendantHookReturnValue { + const { activeId } = useActiveDescendantContext(); + const refCallback = useKeyboardFocusableElement(ref); + return { + ref: refCallback, + active: id === activeId, + }; +} diff --git a/packages/utils/src/keyboardMovement/useActiveDescendantFocus.ts b/packages/utils/src/keyboardMovement/useActiveDescendantFocus.ts new file mode 100644 index 0000000000..de98f49adf --- /dev/null +++ b/packages/utils/src/keyboardMovement/useActiveDescendantFocus.ts @@ -0,0 +1,71 @@ +import { useState } from "react"; + +import type { ActiveDescendantContext } from "./activeDescendantContext"; +import { + KeyboardFocusCallbacks, + KeyboardFocusHookReturnValue, + useKeyboardFocus, +} from "./useKeyboardFocus"; + +/** + * @internal + * @remarks \@since 5.0.0 + */ +export interface ActiveDescendantFocusHookOptions + extends KeyboardFocusCallbacks { + /** + * An optional DOM id for one of the children that should be focused by + * default. + */ + defaultActiveId?: string; +} + +/** + * @internal + * @remarks \@since 5.0.0 + */ +export interface ActiveDescendantFocusHookReturnValue + extends KeyboardFocusHookReturnValue { + /** + * The current DOM id of a child that has keyboard focus. + */ + "aria-activedescendant": string; + + /** + * An object of props that should be passed to the + * {@link ActiveDescendantMovementProvider}. + */ + providerProps: Readonly; +} + +/** + * @see {@link ActiveDescendantMovementProvider} for an example + * @internal + * @remarks \@since 5.0.0 + */ +export function useActiveDescendantFocus({ + defaultActiveId = "", + ...options +}: ActiveDescendantFocusHookOptions = {}): ActiveDescendantFocusHookReturnValue { + const [activeId, setActiveId] = useState(defaultActiveId); + return { + ...useKeyboardFocus({ + ...options, + onFocusChange(element) { + setActiveId(element.id); + }, + getDefaultFocusIndex(elements) { + if (!activeId) { + return -1; + } + + return elements.findIndex(({ id }) => id === activeId); + }, + }), + "aria-activedescendant": activeId, + providerProps: { + activeId, + setActiveId, + }, + }; +} diff --git a/packages/utils/src/keyboardMovement/useKeyboardFocus.ts b/packages/utils/src/keyboardMovement/useKeyboardFocus.ts new file mode 100644 index 0000000000..ff3cceab59 --- /dev/null +++ b/packages/utils/src/keyboardMovement/useKeyboardFocus.ts @@ -0,0 +1,283 @@ +import { + FocusEventHandler, + KeyboardEvent, + KeyboardEventHandler, + MutableRefObject, + useRef, +} from "react"; +import { useUserInteractionMode } from "../mode/UserInteractionModeListener"; + +import { findMatchIndex } from "../search/findMatchIndex"; +import { useKeyboardFocusContext } from "./movementContext"; +import { + focusElement, + getFirstFocusableIndex, + getLastFocusableIndex, + getNextFocusableIndex, + isNotFocusable, +} from "./utils"; + +/** + * @remarks \@since 5.0.0 + * @internal + */ +const noop = (): void => { + // do nothing +}; + +/** + * @remarks \@since 5.0.0 + */ +export interface KeyboardFocusArg { + /** + * The keyboard key/letter that was pressed. (`event.key`). + */ + key: string; + + /** + * The keyboard event. + */ + event: KeyboardEvent; +} + +/** + * @remarks \@since 5.0.0 + */ +export type KeyboardFocusHandler = ( + arg: KeyboardFocusArg +) => void; + +/** + * Optional event handlers that can be called for specific custom focus + * behavior. If any of these functions call `event.stopPropagation()`, the + * default focus behavior will not occur. + * + * @remarks \@since 5.0.0 + */ +export interface KeyboardFocusCallbacks { + onFocus?: FocusEventHandler; + onKeyDown?: KeyboardEventHandler; + + /** + * This is called whenever a single letter has been pressed and + * {@link KeyboardMovementBehavior.searchable} is `true`. + */ + onSearch?: KeyboardFocusHandler; + + /** + * This is called whenever one of the + * {@link KeyboardMovementBehavior.incrementKeys} are pressed. + */ + onIncrement?: KeyboardFocusHandler; + + /** + * This is called whenever one of the + * {@link KeyboardMovementBehavior.decrementKeys} are pressed. + */ + onDecrement?: KeyboardFocusHandler; + + /** + * This is called whenever one of the + * {@link KeyboardMovementBehavior.jumpToFirstKeys} are pressed. + */ + onJumpToFirst?: KeyboardFocusHandler; + + /** + * This is called whenever one of the + * {@link KeyboardMovementBehavior.jumpToLastKeys} are pressed. + */ + onJumpToLast?: KeyboardFocusHandler; +} + +/** + * @remarks \@since 5.0.0 + */ +export interface KeyboardFocusHookOptions + extends KeyboardFocusCallbacks { + /** + * A function that can be used to get the default focus index when the + * container element first gains focus. If this returns `-1`, no child element + * will be focused and the container will maintain focus instead. + * + * @param elements - The current list of elements that can be focused within + * the container element + * @param container - The container element that gained focus + */ + getDefaultFocusIndex?(elements: readonly HTMLElement[], container: E): number; + + /** + * An optional function to call when the custom focused element should change. + * The default value is just to call `element.focus()`. + * + * @param element - The element that should gain custom focus + */ + onFocusChange?(element: HTMLElement): void; +} + +/** + * @remarks \@since 5.0.0 + */ +export interface KeyboardFocusHookReturnValue { + onFocus: FocusEventHandler; + onKeyDown: KeyboardEventHandler; + focusIndex: MutableRefObject; +} + +/** + * @remarks \@since 5.0.0 + */ +export function useKeyboardFocus( + options: KeyboardFocusHookOptions = {} +): KeyboardFocusHookReturnValue { + const { + onFocus = noop, + onKeyDown = noop, + onSearch = noop, + onIncrement = noop, + onDecrement = noop, + onJumpToFirst = noop, + onJumpToLast = noop, + onFocusChange = focusElement, + getDefaultFocusIndex, + } = options; + const mode = useUserInteractionMode(); + const focusIndex = useRef(-1); + const { config, loopable, searchable, watching, includeDisabled } = + useKeyboardFocusContext(); + + return { + focusIndex, + onFocus(event) { + onFocus(event); + if (event.isPropagationStopped()) { + return; + } + + if (event.target !== event.currentTarget) { + const i = watching.current.findIndex( + ({ element }) => element === event.target + ); + if (i !== -1) { + focusIndex.current = i; + } + return; + } + + let defaultFocusIndex: number; + if (getDefaultFocusIndex) { + defaultFocusIndex = getDefaultFocusIndex( + watching.current.map(({ element }) => element), + event.currentTarget + ); + } else { + defaultFocusIndex = getFirstFocusableIndex( + watching.current, + includeDisabled + ); + } + + // this makes it so that if you click the container element without + // clicking any child, it doesn't focus the first element again + if (defaultFocusIndex === -1 || mode !== "keyboard") { + return; + } + + focusIndex.current = defaultFocusIndex; + const element = watching.current[focusIndex.current]?.element; + element && onFocusChange(element); + }, + onKeyDown(event) { + onKeyDown(event); + if (event.isPropagationStopped()) { + return; + } + + const { key, altKey, ctrlKey, metaKey, shiftKey } = event; + const { incrementKeys, decrementKeys, jumpToFirstKeys, jumpToLastKeys } = + config.current; + + const update = (index: number): void => { + event.preventDefault(); + event.stopPropagation(); + if (focusIndex.current === index) { + return; + } + + focusIndex.current = index; + + const element = watching.current[index]?.element; + element && onFocusChange(element); + }; + + if ( + searchable && + key.length === 1 && + // can't search with space since it is generally a click event + key !== " " && + !altKey && + !ctrlKey && + !metaKey && + !shiftKey + ) { + onSearch({ key, event }); + if (event.isPropagationStopped()) { + return; + } + + const values = watching.current.map(({ content, element }) => { + if (isNotFocusable(element, includeDisabled)) { + return ""; + } + + return content; + }); + + update(findMatchIndex(key, values, focusIndex.current)); + } else if (jumpToFirstKeys.includes(key)) { + onJumpToFirst({ key, event }); + if (event.isPropagationStopped()) { + return; + } + + update(getFirstFocusableIndex(watching.current, includeDisabled)); + } else if (jumpToLastKeys.includes(key)) { + onJumpToLast({ key, event }); + if (event.isPropagationStopped()) { + return; + } + + update(getLastFocusableIndex(watching.current, includeDisabled)); + } else if (incrementKeys.includes(key)) { + onIncrement({ key, event }); + if (event.isPropagationStopped()) { + return; + } + + update( + getNextFocusableIndex({ + loopable, + watching: watching.current, + increment: true, + includeDisabled, + currentFocusIndex: focusIndex.current, + }) + ); + } else if (decrementKeys.includes(key)) { + onDecrement({ key, event }); + if (event.isPropagationStopped()) { + return; + } + + update( + getNextFocusableIndex({ + loopable, + watching: watching.current, + increment: false, + includeDisabled, + currentFocusIndex: focusIndex.current, + }) + ); + } + }, + }; +} diff --git a/packages/utils/src/keyboardMovement/useKeyboardFocusableElement.ts b/packages/utils/src/keyboardMovement/useKeyboardFocusableElement.ts new file mode 100644 index 0000000000..18174c8c50 --- /dev/null +++ b/packages/utils/src/keyboardMovement/useKeyboardFocusableElement.ts @@ -0,0 +1,29 @@ +import { Ref, RefCallback, useCallback, useRef } from "react"; + +import { applyRef } from "../applyRef"; +import { useKeyboardFocusContext } from "./movementContext"; + +/** + * @internal + * @remarks \@since 5.0.0 + */ +export function useKeyboardFocusableElement( + ref?: Ref +): RefCallback { + const { attach, detach } = useKeyboardFocusContext(); + const nodeRef = useRef(null); + + return useCallback( + (instance: E | null) => { + applyRef(instance, ref); + if (instance) { + attach(instance); + } else if (nodeRef.current) { + detach(nodeRef.current); + } + + nodeRef.current = instance; + }, + [attach, detach, ref] + ); +} diff --git a/packages/utils/src/keyboardMovement/utils.ts b/packages/utils/src/keyboardMovement/utils.ts new file mode 100644 index 0000000000..e5c297c57a --- /dev/null +++ b/packages/utils/src/keyboardMovement/utils.ts @@ -0,0 +1,162 @@ +import type { KeyboardFocusElementData } from "./types"; +import { loop } from "../loop"; + +/** + * @remarks \@since 5.0.0 + * @internal + */ +export const focusElement = (element: HTMLElement): void => { + element.focus(); +}; + +/** + * @remarks \@since 5.0.0 + * @internal + */ +export const isNotFocusable = ( + element: HTMLElement | undefined, + includeDisabled: boolean +): boolean => { + if (!element) { + return true; + } + + if (includeDisabled) { + return false; + } + + return ( + element.getAttribute("disabled") !== null || + element.getAttribute("aria-disabled") === "true" + ); +}; + +/** + * @remarks \@since 5.0.0 + * @internal + */ +export const getFirstFocusableIndex = ( + watching: readonly KeyboardFocusElementData[], + includeDisabled: boolean +): number => { + if (!watching.length) { + return -1; + } + + let firstIndex = 0; + while ( + firstIndex < watching.length - 1 && + isNotFocusable(watching[firstIndex].element, includeDisabled) + ) { + firstIndex += 1; + } + + if (isNotFocusable(watching[firstIndex].element, includeDisabled)) { + return -1; + } + + return firstIndex; +}; + +/** + * @remarks \@since 5.0.0 + * @internal + */ +export const getLastFocusableIndex = ( + watching: readonly KeyboardFocusElementData[], + includeDisabled: boolean +): number => { + if (!watching.length) { + return -1; + } + + let lastIndex = watching.length - 1; + while ( + lastIndex > 0 && + isNotFocusable(watching[lastIndex].element, includeDisabled) + ) { + lastIndex -= 1; + } + + if (isNotFocusable(watching[lastIndex].element, includeDisabled)) { + return -1; + } + + return lastIndex; +}; + +/** + * @remarks \@since 5.0.0 + * @internal + */ +interface NextFocusableIndexOptions { + loopable: boolean; + watching: readonly KeyboardFocusElementData[]; + increment: boolean; + includeDisabled: boolean; + currentFocusIndex: number; +} + +/** + * @remarks \@since 5.0.0 + * @internal + */ +export const getNextFocusableIndex = ({ + loopable, + watching, + increment, + includeDisabled, + currentFocusIndex, +}: NextFocusableIndexOptions): number => { + const min = getFirstFocusableIndex(watching, includeDisabled); + const max = getLastFocusableIndex(watching, includeDisabled); + let nextIndex = loop({ + min, + max, + value: currentFocusIndex, + minmax: !loopable, + increment, + }); + while ( + isNotFocusable(watching[nextIndex].element, includeDisabled) && + (loopable || nextIndex !== (increment ? max : min)) + ) { + nextIndex = loop({ + min, + max, + value: nextIndex, + minmax: !loopable, + increment, + }); + } + + // Since the `min` and `max` values are "safely" set, I don't need to verify + // the nextIndex is still focusable + return nextIndex; +}; + +/** + * @remarks \@since 5.0.0 + * @internal + */ +export function getSearchText( + element: HTMLElement, + searchable: boolean +): string { + if (!searchable) { + return ""; + } + + const cloned = element.cloneNode(true) as HTMLElement; + cloned + .querySelectorAll(".rmd-icon--font,[aria-hidden=true],[hidden]") + .forEach((element) => { + element.parentNode?.removeChild(element); + }); + + // Note: It would be good to use `cloned.innerText` (maybe?) at some point, + // but it returns `undefined` in jsdom. It also does cause a reflow, so maybe + // this is fine? + // https://developer.mozilla.org/en-US/docs/Web/API/Node/textContent#differences_from_innertext + return (cloned.textContent || "").substring(0, 1).toUpperCase(); +} diff --git a/packages/utils/src/types.ts b/packages/utils/src/types.ts index 58912ce239..20e1f40f9b 100644 --- a/packages/utils/src/types.ts +++ b/packages/utils/src/types.ts @@ -49,3 +49,11 @@ export type LabelRequiredForA11y = RequireAtLeastOne< Props, keyof LabelA11y >; + +/** + * @remarks \@since 5.0.0 + * @internal + */ +export interface NonNullRef { + readonly current: T; +}