diff --git a/jest-setup.js b/jest-setup.js new file mode 100644 index 0000000..8186f7e --- /dev/null +++ b/jest-setup.js @@ -0,0 +1,22 @@ +import '@testing-library/jest-dom'; +import * as warning from './src/utils/warning'; + +beforeEach(() => { + if (console.error.mockRestore) { + console.error.mockRestore(); + } + + if (console.warn.mockRestore) { + console.warn.mockRestore(); + } + + if (warning.warning.mockRestore) { + warning.warning.mockRestore(); + } + + jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, 'warn').mockImplementation(() => {}); + jest.spyOn(warning, 'warning').mockImplementation(() => {}); + + warning.resetCodeCache(); +}); diff --git a/jest.config.js b/jest.config.js index 24c6d8a..16b615c 100644 --- a/jest.config.js +++ b/jest.config.js @@ -3,4 +3,6 @@ module.exports = { collectCoverageFrom: ['src/**/*.{ts,tsx}', '!**/node_modules/**'], coverageDirectory: 'coverage', testURL: 'http://localhost/', + testEnvironment: 'jsdom', + setupFilesAfterEnv: ['./jest-setup.js'], }; diff --git a/package.json b/package.json index d713ffa..b7aee5e 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "types": "types/index.d.ts", "scripts": { "test": "NODE_ENV=test jest", - "test:watch": "jest --watch", + "test:watch": "NODE_ENV=test jest --watchAll", "clean": "rimraf ./dist ./es ./tmp ./lib ./types", "prepublishOnly": "npm run build", "postpublish": "npm run clean", @@ -53,23 +53,27 @@ }, "devDependencies": { "@babel/cli": "^7.10.5", - "@babel/core": "^7.11.1", + "@babel/core": "^7.14.6", "@babel/plugin-external-helpers": "^7.10.4", "@babel/plugin-proposal-nullish-coalescing-operator": "^7.10.4", "@babel/preset-env": "^7.11.0", "@babel/preset-react": "^7.10.4", "@babel/preset-typescript": "^7.10.4", - "@types/jest": "26.0.9", - "@types/node": "^14.0.27", + "@testing-library/react": "11.2.7", + "@testing-library/jest-dom": "5.14.1", + "@types/jest": "^26.0.23", + "@types/node": "^15.12.2", "@types/react": "^17.0.11", "babel-eslint": "^10.1.0", - "babel-jest": "26.3.0", + "babel-jest": "27.0.2", "babel-loader": "^8.0.6", "coveralls": "3.1.0", "cross-env": "^7.0.2", "eslint": "^7.6.0", - "jest": "26.4.0", + "jest": "27.0.4", "prettier": "^2.0.5", + "react": "17.0.2", + "react-dom": "17.0.2", "rimraf": "^3.0.2", "typescript": "^4.3.2" } diff --git a/src/focus-context.tsx b/src/focus-context.tsx index 22abd26..068747c 100644 --- a/src/focus-context.tsx +++ b/src/focus-context.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from 'react'; import createFocusStore from './focus-store'; import lrudInput from './lrud-input/focus-lrud'; import { ProviderValue, RootFocusNode, Orientation } from './types'; -import warning from './utils/warning'; +import { warning } from './utils/warning'; const FocusContext = React.createContext(null); diff --git a/src/focus-node.test.js b/src/focus-node.test.js new file mode 100644 index 0000000..8ca1f58 --- /dev/null +++ b/src/focus-node.test.js @@ -0,0 +1,209 @@ +import React, { useRef } from 'react'; +import '@testing-library/jest-dom'; +import { render, act, fireEvent, screen } from '@testing-library/react'; +import { + FocusRoot, + FocusNode, + useSetFocus, + useFocusStoreDangerously, +} from './index'; +import { warning } from './utils/warning'; + +describe('FocusNode', () => { + it('warns when there is no FocusRoot', () => { + function TestComponent() { + return ( + <> + + + ); + } + + expect(() => { + render(); + }).toThrow(); + + expect(warning).toHaveBeenCalledTimes(2); + expect(warning.mock.calls[0][1]).toEqual('NO_FOCUS_PROVIDER_DETECTED'); + // Note: I'm not entirely sure why the warning is called twice in this test...but that's OK + expect(warning.mock.calls[1][1]).toEqual('NO_FOCUS_PROVIDER_DETECTED'); + }); + + describe('focusId', () => { + it('uses the ID that you provide', () => { + let focusStore; + + function TestComponent() { + focusStore = useFocusStoreDangerously(); + + return ( + <> + + + + ); + } + + render( + + + + ); + + expect(focusStore.getState().focusedNodeId).toBe('nodeA'); + }); + + it('generates its own ID when one is not provided', () => { + let focusStore; + + function TestComponent() { + focusStore = useFocusStoreDangerously(); + + return ( + <> + + + + ); + } + + render( + + + + ); + + expect(typeof focusStore.getState().focusedNodeId === 'string').toBe( + true + ); + }); + + it('warns if an invalid ID is passed, but still generates a valid one', () => { + let focusStore; + + function TestComponent() { + focusStore = useFocusStoreDangerously(); + + return ( + <> + + + + ); + } + + render( + + + + ); + + expect(typeof focusStore.getState().focusedNodeId === 'string').toBe( + true + ); + + expect(warning).toHaveBeenCalledTimes(1); + expect(warning.mock.calls[0][1]).toEqual('INVALID_FOCUS_ID_PASSED'); + }); + + it('warns if the ID "root" is passed in, but still generates a valid one', () => { + let focusStore; + + function TestComponent() { + focusStore = useFocusStoreDangerously(); + + return ( + <> + + + + ); + } + + render( + + + + ); + + expect(typeof focusStore.getState().focusedNodeId === 'string').toBe( + true + ); + + expect(focusStore.getState().focusedNodeId).not.toBe('root'); + + expect(warning).toHaveBeenCalledTimes(1); + expect(warning.mock.calls[0][1]).toEqual('ROOT_ID_WAS_PASSED'); + }); + }); + + describe('propsFromNode', () => { + it('allows you to pass props based on the node', () => { + let focusStore; + let setFocus; + + function TestComponent() { + focusStore = useFocusStoreDangerously(); + setFocus = useSetFocus(); + + return ( + <> + { + return { + className: node.isFocused ? 'sandwiches' : 'spaghetti', + }; + }} + /> + + + ); + } + + render( + + + + ); + + expect(focusStore.getState().focusedNodeId).toBe('nodeA'); + let nodeA = screen.getByTestId('nodeA'); + expect(nodeA).toHaveClass('sandwiches'); + + act(() => setFocus('nodeB')); + + expect(nodeA).toHaveClass('spaghetti'); + }); + }); + + describe('ref', () => { + it('allows you to pass a ref', () => { + let focusStore; + let elRef; + + function TestComponent() { + focusStore = useFocusStoreDangerously(); + elRef = useRef(); + + return ( + <> + + + + ); + } + + render( + + + + ); + + expect(focusStore.getState().focusedNodeId).toBe('nodeA'); + let nodeA = screen.getByTestId('nodeA'); + expect(elRef.current).toBe(nodeA); + }); + }); +}); diff --git a/src/focus-node.tsx b/src/focus-node.tsx index 574b8ff..b13fb81 100644 --- a/src/focus-node.tsx +++ b/src/focus-node.tsx @@ -10,7 +10,7 @@ import React, { } from 'react'; import FocusContext from './focus-context'; import nodeFromDefinition from './utils/node-from-definition'; -import warning from './utils/warning'; +import { warning } from './utils/warning'; import { FocusStore, Id, @@ -177,17 +177,30 @@ export function FocusNode( ); const [nodeId] = useState(() => { - const isInvalidId = focusId === 'root'; - - if (isInvalidId) { - warning( - 'A focus node with an invalid focus ID was created: "root". This is a reserved ID, so it has been ' + - 'ignored. Please choose another ID if you wish to specify an ID.', - 'ROOT_ID_WAS_PASSED' - ); + const nonStringFocusId = + typeof focusId !== 'string' && focusId !== undefined; + const reservedFocusId = focusId === 'root'; + const invalidNodeId = nonStringFocusId || reservedFocusId; + + if (process.env.NODE_ENV !== 'production') { + if (reservedFocusId) { + warning( + 'A focus node with an invalid focus ID was created: "root". This is a reserved ID, so it has been ' + + 'ignored. Please choose another ID if you wish to specify an ID.', + 'ROOT_ID_WAS_PASSED' + ); + } + + if (nonStringFocusId) { + warning( + 'A focus node with an invalid focus ID was created: "root". This is a reserved ID, so it has been ' + + 'ignored. Please choose another ID if you wish to specify an ID.', + 'INVALID_FOCUS_ID_PASSED' + ); + } } - if (focusId && !isInvalidId) { + if (focusId && !invalidNodeId) { return focusId; } else { const id = `node-${uniqueId}`; @@ -459,15 +472,16 @@ export function FocusNode( const isLeaf = nodeRef.current && nodeRef.current.children.length === 0; const isDisabled = nodeRef.current && nodeRef.current.disabled; - if (!isLeaf || isDisabled) { return; } const focusState = staticDefinitions.providerValue.store.getState(); + if ( !focusState._hasPointerEventsEnabled || - !nodeExistsInTree.current + !nodeExistsInTree.current || + focusState.interactionMode !== 'pointer' ) { return; } diff --git a/src/focus-root.test.js b/src/focus-root.test.js new file mode 100644 index 0000000..3301904 --- /dev/null +++ b/src/focus-root.test.js @@ -0,0 +1,244 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { render, fireEvent, screen } from '@testing-library/react'; +import { FocusRoot, FocusNode, useFocusStoreDangerously } from './index'; +import { warning } from './utils/warning'; + +describe('', () => { + describe('wrapping', () => { + it('does not wrap by default', () => { + let focusStore; + + function TestComponent() { + focusStore = useFocusStoreDangerously(); + + return ( + <> + + A + + + B + + + ); + } + + render( + + + + ); + + const nodeAEl = screen.getByTestId('nodeA'); + expect(nodeAEl).toHaveClass('isFocused'); + expect(nodeAEl).toHaveClass('isFocusedLeaf'); + + const nodeBEl = screen.getByTestId('nodeB'); + expect(nodeBEl).not.toHaveClass('isFocused'); + expect(nodeBEl).not.toHaveClass('isFocusedLeaf'); + + const focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('nodeA'); + expect(focusState.focusHierarchy).toEqual(['root', 'nodeA']); + expect(focusState.activeNodeId).toEqual(null); + expect(Object.values(focusState.nodes)).toHaveLength(3); + + fireEvent.keyDown(window, { + code: 'ArrowRight', + key: 'ArrowRight', + }); + + expect(focusStore.getState().focusedNodeId).toEqual('nodeB'); + + fireEvent.keyDown(window, { + code: 'ArrowRight', + key: 'ArrowRight', + }); + + expect(focusStore.getState().focusedNodeId).toEqual('nodeB'); + }); + + it('supports wrapping', () => { + let focusStore; + + function TestComponent() { + focusStore = useFocusStoreDangerously(); + + return ( + <> + + A + + + B + + + ); + } + + render( + + + + ); + + const nodeAEl = screen.getByTestId('nodeA'); + expect(nodeAEl).toHaveClass('isFocused'); + expect(nodeAEl).toHaveClass('isFocusedLeaf'); + + const nodeBEl = screen.getByTestId('nodeB'); + expect(nodeBEl).not.toHaveClass('isFocused'); + expect(nodeBEl).not.toHaveClass('isFocusedLeaf'); + + const focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('nodeA'); + expect(focusState.focusHierarchy).toEqual(['root', 'nodeA']); + expect(focusState.activeNodeId).toEqual(null); + expect(Object.values(focusState.nodes)).toHaveLength(3); + + fireEvent.keyDown(window, { + code: 'ArrowRight', + key: 'ArrowRight', + }); + + expect(focusStore.getState().focusedNodeId).toEqual('nodeB'); + + fireEvent.keyDown(window, { + code: 'ArrowRight', + key: 'ArrowRight', + }); + + expect(focusStore.getState().focusedNodeId).toEqual('nodeA'); + }); + }); + + describe('orientation', () => { + it('can be configured to be vertical', () => { + let focusStore; + + function TestComponent() { + focusStore = useFocusStoreDangerously(); + + return ( + <> + + A + + + B + + + ); + } + + render( + + + + ); + + const nodeAEl = screen.getByTestId('nodeA'); + expect(nodeAEl).toHaveClass('isFocused'); + expect(nodeAEl).toHaveClass('isFocusedLeaf'); + + const nodeBEl = screen.getByTestId('nodeB'); + expect(nodeBEl).not.toHaveClass('isFocused'); + expect(nodeBEl).not.toHaveClass('isFocusedLeaf'); + + const focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('nodeA'); + expect(focusState.focusHierarchy).toEqual(['root', 'nodeA']); + expect(focusState.activeNodeId).toEqual(null); + expect(Object.values(focusState.nodes)).toHaveLength(3); + + fireEvent.keyDown(window, { + code: 'ArrowDown', + key: 'ArrowDown', + }); + + expect(focusStore.getState().focusedNodeId).toEqual('nodeB'); + + fireEvent.keyDown(window, { + code: 'ArrowDown', + key: 'ArrowDown', + }); + + expect(focusStore.getState().focusedNodeId).toEqual('nodeB'); + }); + + it('warns on invalid orientation values', () => { + function TestComponent() { + return ( + <> + + + ); + } + + render( + + + + ); + + expect(warning).toHaveBeenCalledTimes(1); + expect(warning.mock.calls[0][1]).toEqual('INVALID_ROOT_ORIENTATION'); + }); + }); + + describe('orientation + wrapping', () => { + it('functions together', () => { + let focusStore; + + function TestComponent() { + focusStore = useFocusStoreDangerously(); + + return ( + <> + + A + + + B + + + ); + } + + render( + + + + ); + + const nodeAEl = screen.getByTestId('nodeA'); + expect(nodeAEl).toHaveClass('isFocused'); + expect(nodeAEl).toHaveClass('isFocusedLeaf'); + + const nodeBEl = screen.getByTestId('nodeB'); + expect(nodeBEl).not.toHaveClass('isFocused'); + expect(nodeBEl).not.toHaveClass('isFocusedLeaf'); + + const focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('nodeA'); + expect(focusState.focusHierarchy).toEqual(['root', 'nodeA']); + expect(focusState.activeNodeId).toEqual(null); + expect(Object.values(focusState.nodes)).toHaveLength(3); + + fireEvent.keyDown(window, { + code: 'ArrowDown', + key: 'ArrowDown', + }); + + expect(focusStore.getState().focusedNodeId).toEqual('nodeB'); + + fireEvent.keyDown(window, { + code: 'ArrowDown', + key: 'ArrowDown', + }); + + expect(focusStore.getState().focusedNodeId).toEqual('nodeA'); + }); + }); +}); diff --git a/src/focus-store.test.js b/src/focus-store.test.js new file mode 100644 index 0000000..da20f75 --- /dev/null +++ b/src/focus-store.test.js @@ -0,0 +1,20 @@ +import createFocusStore from './focus-store'; + +describe('createFocusStore', () => { + it('returns an object with the right shape', () => { + const focusStore = createFocusStore(); + + expect(focusStore).toBeTruthy(); + + expect(typeof focusStore.subscribe).toEqual('function'); + expect(typeof focusStore.getState).toEqual('function'); + expect(typeof focusStore.createNodes).toEqual('function'); + expect(typeof focusStore.deleteNode).toEqual('function'); + expect(typeof focusStore.setFocus).toEqual('function'); + expect(typeof focusStore.updateNode).toEqual('function'); + expect(typeof focusStore.handleArrow).toEqual('function'); + expect(typeof focusStore.handleSelect).toEqual('function'); + expect(typeof focusStore.configurePointerEvents).toEqual('function'); + expect(typeof focusStore.destroy).toEqual('function'); + }); +}); diff --git a/src/focus-store.ts b/src/focus-store.ts index eca2837..5a794a2 100644 --- a/src/focus-store.ts +++ b/src/focus-store.ts @@ -4,7 +4,7 @@ import updateFocus from './update-focus/update-focus'; import handleArrowUtil from './handle-arrow/handle-arrow'; import enforceStateStructure from './utils/enforce-state-structure'; import recursivelyUpdateChildren from './utils/recursively-update-node'; -import warning from './utils/warning'; +import { warning } from './utils/warning'; import { FocusState, Orientation, @@ -204,6 +204,8 @@ export default function createFocusStore({ } } + return; + } else if (currentNode.disabled) { return; } else if (currentNode.isExiting) { if (process.env.NODE_ENV !== 'production') { @@ -276,22 +278,24 @@ export default function createFocusStore({ } if (nodeId === 'root') { - if (update.disabled) { - warning( - 'You attempted to disable the root node. ' + - 'The root node of a focus tree cannot be disabled. ' + - 'This has no effect, but it may represent an error in your code.', - 'DISABLE_ROOT_NODE' - ); - } + if (process.env.NODE_ENV !== 'production') { + if (update.disabled) { + warning( + 'You attempted to disable the root node. ' + + 'The root node of a focus tree cannot be disabled. ' + + 'This has no effect, but it may represent an error in your code.', + 'DISABLE_ROOT_NODE' + ); + } - if (update.isExiting) { - warning( - 'You attempted to exit the root node. ' + - 'The root node of a focus tree cannot be exited. ' + - 'This has no effect, but it may represent an error in your code.', - 'EXIT_ROOT_NODE' - ); + if (update.isExiting) { + warning( + 'You attempted to exit the root node. ' + + 'The root node of a focus tree cannot be exited. ' + + 'This has no effect, but it may represent an error in your code.', + 'EXIT_ROOT_NODE' + ); + } } return; } diff --git a/src/hooks/internal/use-on-change.ts b/src/hooks/internal/use-on-change.ts index 3c6b080..8c2f1cc 100644 --- a/src/hooks/internal/use-on-change.ts +++ b/src/hooks/internal/use-on-change.ts @@ -1,6 +1,5 @@ import { useEffect } from 'react'; import usePrevious from './use-previous'; -import warning from '../../utils/warning'; type ComparatorFn = (a: Value, b: Value | undefined) => boolean; @@ -14,22 +13,6 @@ export default function useChange( const previous = usePrevious(val); useEffect(() => { - if (process.env.NODE_ENV !== 'production') { - if (typeof callback !== 'function') { - warning( - `A non-function callback was passed to the useChange hook. callback must be a function.`, - 'useOnChange_invalidCallback' - ); - } - - if (typeof comparator !== 'function') { - warning( - `A non-function comparator was passed to the useChange hook. comparator must be a function.`, - 'useOnChange_invalidComparator' - ); - } - } - if (typeof callback === 'function' && typeof comparator === 'function') { if (!comparator(val, previous)) { callback(val, previous); diff --git a/src/hooks/use-active-node.test.js b/src/hooks/use-active-node.test.js new file mode 100644 index 0000000..b361d14 --- /dev/null +++ b/src/hooks/use-active-node.test.js @@ -0,0 +1,84 @@ +import React, { useState } from 'react'; +import '@testing-library/jest-dom'; +import { + render, + fireEvent, + createEvent, + screen, + act, +} from '@testing-library/react'; +import { + FocusRoot, + FocusNode, + useFocusStoreDangerously, + useActiveNode, +} from '../index'; + +describe('useActiveNode', () => { + it('returns the expected node', (done) => { + let focusStore; + let activeNode; + + function TestComponent() { + focusStore = useFocusStoreDangerously(); + activeNode = useActiveNode(); + + return ( + <> + + + + ); + } + + render( + + + + ); + + let focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('nodeA'); + expect(focusState.focusHierarchy).toEqual(['root', 'nodeA']); + expect(activeNode).toEqual(null); + + fireEvent.keyDown(window, { + code: 'ArrowRight', + key: 'ArrowRight', + }); + + focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('nodeB'); + expect(focusState.focusHierarchy).toEqual(['root', 'nodeB']); + + fireEvent.mouseMove(window); + + requestAnimationFrame(() => { + const nodeA = screen.getByTestId('nodeA'); + const nodeB = screen.getByTestId('nodeB'); + + fireEvent.mouseOver(nodeB); + + const clickEventB = createEvent.click(nodeB, { button: 0 }); + fireEvent(nodeB, clickEventB); + + focusState = focusStore.getState(); + expect(activeNode).toBe(focusState.nodes.nodeB); + + fireEvent.mouseOver(nodeA); + + focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('nodeA'); + expect(focusState.focusHierarchy).toEqual(['root', 'nodeA']); + expect(activeNode).toBe(focusState.nodes.nodeB); + + const clickEventA = createEvent.click(nodeA, { button: 0 }); + fireEvent(nodeA, clickEventA); + + focusState = focusStore.getState(); + expect(activeNode).toBe(focusState.nodes.nodeA); + + done(); + }); + }); +}); diff --git a/src/hooks/use-active-node.ts b/src/hooks/use-active-node.ts index 99f5613..7218634 100644 --- a/src/hooks/use-active-node.ts +++ b/src/hooks/use-active-node.ts @@ -1,6 +1,6 @@ import { useContext, useState, useEffect, useRef } from 'react'; import FocusContext from '../focus-context'; -import warning from '../utils/warning'; +import { warning } from '../utils/warning'; import { Node } from '../types'; export default function useActiveNode(): Node | null { diff --git a/src/hooks/use-focus-events.test.js b/src/hooks/use-focus-events.test.js new file mode 100644 index 0000000..547fd6b --- /dev/null +++ b/src/hooks/use-focus-events.test.js @@ -0,0 +1,325 @@ +import React, { useState } from 'react'; +import '@testing-library/jest-dom'; +import { + render, + fireEvent, + createEvent, + screen, + act, +} from '@testing-library/react'; +import { + FocusRoot, + FocusNode, + useFocusEvents, + useFocusStoreDangerously, +} from '../index'; + +describe('useFocusEvents', () => { + describe('focus/blur', () => { + it('calls them when appropriate', () => { + const nodeAOnFocused = jest.fn(); + const nodeAOnBlurred = jest.fn(); + const nodeBOnFocused = jest.fn(); + const nodeBOnBlurred = jest.fn(); + let focusStore; + + function TestComponent() { + focusStore = useFocusStoreDangerously(); + + useFocusEvents('nodeA', { + focus: nodeAOnFocused, + blur: nodeAOnBlurred, + }); + + useFocusEvents('nodeB', { + focus: nodeBOnFocused, + blur: nodeBOnBlurred, + }); + + return ( + <> + + + + ); + } + + render( + + + + ); + + let focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('nodeA'); + expect(focusState.focusHierarchy).toEqual(['root', 'nodeA']); + + expect(nodeAOnFocused.mock.calls.length).toBe(1); + expect(nodeAOnBlurred.mock.calls.length).toBe(0); + expect(nodeBOnFocused.mock.calls.length).toBe(0); + expect(nodeBOnBlurred.mock.calls.length).toBe(0); + + fireEvent.keyDown(window, { + code: 'ArrowRight', + key: 'ArrowRight', + }); + + focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('nodeB'); + expect(focusState.focusHierarchy).toEqual(['root', 'nodeB']); + + expect(nodeAOnFocused.mock.calls.length).toBe(1); + expect(nodeAOnBlurred.mock.calls.length).toBe(1); + expect(nodeBOnFocused.mock.calls.length).toBe(1); + expect(nodeBOnBlurred.mock.calls.length).toBe(0); + }); + }); + + describe('disabled/enabled', () => { + it('works when enabled on mount', () => { + const nodeAOnDisabled = jest.fn(); + const nodeAOnEnabled = jest.fn(); + const nodeBOnDisabled = jest.fn(); + const nodeBOnEnabled = jest.fn(); + let focusStore; + let updateDisableA; + + function TestComponent() { + const [disableA, setDisableA] = useState(false); + focusStore = useFocusStoreDangerously(); + + updateDisableA = setDisableA; + + useFocusEvents('nodeA', { + disabled: nodeAOnDisabled, + enabled: nodeAOnEnabled, + }); + + useFocusEvents('nodeB', { + disabled: nodeBOnDisabled, + enabled: nodeBOnEnabled, + }); + + return ( + <> + + + + ); + } + + render( + + + + ); + + let focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('nodeA'); + expect(focusState.focusHierarchy).toEqual(['root', 'nodeA']); + + expect(nodeAOnDisabled.mock.calls.length).toBe(0); + expect(nodeAOnEnabled.mock.calls.length).toBe(0); + expect(nodeBOnDisabled.mock.calls.length).toBe(0); + expect(nodeBOnEnabled.mock.calls.length).toBe(0); + + act(() => updateDisableA(true)); + + focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('nodeB'); + expect(focusState.focusHierarchy).toEqual(['root', 'nodeB']); + + expect(nodeAOnDisabled.mock.calls.length).toBe(1); + expect(nodeAOnEnabled.mock.calls.length).toBe(0); + expect(nodeBOnDisabled.mock.calls.length).toBe(0); + expect(nodeBOnEnabled.mock.calls.length).toBe(0); + + act(() => updateDisableA(false)); + + focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('nodeB'); + expect(focusState.focusHierarchy).toEqual(['root', 'nodeB']); + + expect(nodeAOnDisabled.mock.calls.length).toBe(1); + expect(nodeAOnEnabled.mock.calls.length).toBe(1); + expect(nodeBOnDisabled.mock.calls.length).toBe(0); + expect(nodeBOnEnabled.mock.calls.length).toBe(0); + }); + + it('works when disabled on mount', () => { + const nodeAOnDisabled = jest.fn(); + const nodeAOnEnabled = jest.fn(); + const nodeBOnDisabled = jest.fn(); + const nodeBOnEnabled = jest.fn(); + let focusStore; + let updateDisableA; + + function TestComponent() { + const [disableA, setDisableA] = useState(true); + focusStore = useFocusStoreDangerously(); + + updateDisableA = setDisableA; + + useFocusEvents('nodeCCC', { + disabled: nodeAOnDisabled, + enabled: nodeAOnEnabled, + }); + + useFocusEvents('nodeB', { + disabled: nodeBOnDisabled, + enabled: nodeBOnEnabled, + }); + + return ( + <> + + + + ); + } + + render( + + + + ); + + let focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('nodeB'); + expect(focusState.focusHierarchy).toEqual(['root', 'nodeB']); + + expect(nodeAOnDisabled.mock.calls.length).toBe(0); + expect(nodeAOnEnabled.mock.calls.length).toBe(0); + expect(nodeBOnDisabled.mock.calls.length).toBe(0); + expect(nodeBOnEnabled.mock.calls.length).toBe(0); + + act(() => updateDisableA(false)); + + focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('nodeB'); + expect(focusState.focusHierarchy).toEqual(['root', 'nodeB']); + + expect(nodeAOnDisabled.mock.calls.length).toBe(0); + expect(nodeAOnEnabled.mock.calls.length).toBe(1); + expect(nodeBOnDisabled.mock.calls.length).toBe(0); + expect(nodeBOnEnabled.mock.calls.length).toBe(0); + + act(() => updateDisableA(true)); + + focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('nodeB'); + expect(focusState.focusHierarchy).toEqual(['root', 'nodeB']); + + expect(nodeAOnDisabled.mock.calls.length).toBe(1); + expect(nodeAOnEnabled.mock.calls.length).toBe(1); + expect(nodeBOnDisabled.mock.calls.length).toBe(0); + expect(nodeBOnEnabled.mock.calls.length).toBe(0); + }); + }); + + describe('active/inactive', () => { + it('calls them when appropriate', (done) => { + const nodeAOnActive = jest.fn(); + const nodeAOnInactive = jest.fn(); + const nodeBOnActive = jest.fn(); + const nodeBOnInactive = jest.fn(); + let focusStore; + + function TestComponent() { + focusStore = useFocusStoreDangerously(); + + useFocusEvents('nodeA', { + active: nodeAOnActive, + inactive: nodeAOnInactive, + }); + + useFocusEvents('nodeB', { + active: nodeBOnActive, + inactive: nodeBOnInactive, + }); + + return ( + <> + + + + ); + } + + render( + + + + ); + + let focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('nodeA'); + expect(focusState.focusHierarchy).toEqual(['root', 'nodeA']); + + expect(nodeAOnActive.mock.calls.length).toBe(0); + expect(nodeAOnInactive.mock.calls.length).toBe(0); + expect(nodeBOnActive.mock.calls.length).toBe(0); + expect(nodeBOnInactive.mock.calls.length).toBe(0); + + fireEvent.keyDown(window, { + code: 'ArrowRight', + key: 'ArrowRight', + }); + + focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('nodeB'); + expect(focusState.focusHierarchy).toEqual(['root', 'nodeB']); + + expect(nodeAOnActive.mock.calls.length).toBe(0); + expect(nodeAOnInactive.mock.calls.length).toBe(0); + expect(nodeBOnActive.mock.calls.length).toBe(0); + expect(nodeBOnInactive.mock.calls.length).toBe(0); + + fireEvent.mouseMove(window); + + requestAnimationFrame(() => { + const nodeA = screen.getByTestId('nodeA'); + const nodeB = screen.getByTestId('nodeB'); + + fireEvent.mouseOver(nodeB); + + const clickEventB = createEvent.click(nodeB, { button: 0 }); + fireEvent(nodeB, clickEventB); + + expect(nodeAOnActive.mock.calls.length).toBe(0); + expect(nodeAOnInactive.mock.calls.length).toBe(0); + expect(nodeBOnActive.mock.calls.length).toBe(1); + expect(nodeBOnInactive.mock.calls.length).toBe(0); + + fireEvent.mouseOver(nodeA); + + focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('nodeA'); + expect(focusState.focusHierarchy).toEqual(['root', 'nodeA']); + + expect(nodeAOnActive.mock.calls.length).toBe(0); + expect(nodeAOnInactive.mock.calls.length).toBe(0); + expect(nodeBOnActive.mock.calls.length).toBe(1); + expect(nodeBOnInactive.mock.calls.length).toBe(0); + + const clickEventA = createEvent.click(nodeA, { button: 0 }); + fireEvent(nodeA, clickEventA); + + expect(nodeAOnActive.mock.calls.length).toBe(1); + expect(nodeAOnInactive.mock.calls.length).toBe(0); + expect(nodeBOnActive.mock.calls.length).toBe(1); + expect(nodeBOnInactive.mock.calls.length).toBe(1); + + done(); + }); + }); + }); +}); diff --git a/src/hooks/use-focus-events.ts b/src/hooks/use-focus-events.ts index acc2562..8b96685 100644 --- a/src/hooks/use-focus-events.ts +++ b/src/hooks/use-focus-events.ts @@ -1,6 +1,7 @@ -import { useRef } from 'react'; +import { useRef, useState } from 'react'; import useOnChange from './internal/use-on-change'; import useFocusNode from './use-focus-node'; +import { warning } from '../utils/warning'; import { Id, Node } from '../types'; type EventCallback = (node: Node) => void; @@ -17,18 +18,49 @@ interface Events { } export default function useFocusEvents(nodeId: Id, events: Events = {}): void { - const node = useFocusNode(nodeId); + const [constantNodeId] = useState(nodeId); + + const node = useFocusNode(constantNodeId); const nodeRef = useRef(node); nodeRef.current = node; const eventsRef = useRef(events); eventsRef.current = events; + // This pattern allows the `focus` hook to be called even on mount. + // When the node doesn't exist, we set this to false. Then, when the initial + // mounting is done, `node.isFocused` is true, and the callbacks fire. const isFocused = Boolean(node && node.isFocused); - const isDisabled = Boolean(node && node.disabled); + + // This ensures that the enabled/disabled hooks are *not* called on mount. + // This way, `disabled/enabled` are only called when that state actually changes + let isDisabled; + if (!node) { + isDisabled = null; + } else { + isDisabled = node.disabled; + } + + // For active, we also wouldn't want it to be called on mount, but it's not possible + // for a node to be mounted as active, so this simpler logic gives us the desired behavior. const isActive = Boolean(node && node.active); - useOnChange(isFocused, currentIsFocused => { + useOnChange(nodeId, (currentId, prevId) => { + if (typeof prevId !== 'string') { + return; + } + + if (process.env.NODE_ENV !== 'production') { + if (currentId !== prevId) { + warning( + `The nodeId passed into useFocusEvents changed. This change has been ignored: the nodeId cannot be changed.`, + 'FOCUS_EVENTS_NODE_ID_CHANGED' + ); + } + } + }); + + useOnChange(isFocused, (currentIsFocused) => { if (nodeRef.current) { if (currentIsFocused && typeof eventsRef.current.focus === 'function') { eventsRef.current.focus(nodeRef.current); @@ -41,7 +73,11 @@ export default function useFocusEvents(nodeId: Id, events: Events = {}): void { } }); - useOnChange(isDisabled, currentIsDisabled => { + useOnChange(isDisabled, (currentIsDisabled, prevIsDisabled) => { + if (prevIsDisabled === undefined || prevIsDisabled == null) { + return; + } + if (nodeRef.current) { if ( currentIsDisabled && @@ -57,7 +93,7 @@ export default function useFocusEvents(nodeId: Id, events: Events = {}): void { } }); - useOnChange(isActive, currentIsActive => { + useOnChange(isActive, (currentIsActive) => { if (nodeRef.current) { if (currentIsActive && typeof eventsRef.current.active === 'function') { eventsRef.current.active(nodeRef.current); diff --git a/src/hooks/use-focus-hierarchy.test.js b/src/hooks/use-focus-hierarchy.test.js new file mode 100644 index 0000000..24c8136 --- /dev/null +++ b/src/hooks/use-focus-hierarchy.test.js @@ -0,0 +1,59 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { render, act } from '@testing-library/react'; +import { + FocusRoot, + FocusNode, + useFocusHierarchy, + useSetFocus, + useFocusStoreDangerously, +} from '../index'; + +describe('useFocusHierarchy', () => { + it('returns the expected hierarchy', () => { + let focusStore; + let setFocus; + let focusHierarchy; + + function TestComponent() { + focusStore = useFocusStoreDangerously(); + setFocus = useSetFocus(); + focusHierarchy = useFocusHierarchy(); + + return ( + <> + + + + ); + } + + render( + + + + ); + + expect(focusHierarchy).toHaveLength(2); + expect(focusHierarchy.map((node) => node.focusId)).toEqual([ + 'root', + 'nodeA', + ]); + + expect(focusHierarchy.map((node) => node.focusId)).toEqual( + focusStore.getState().focusHierarchy + ); + + act(() => setFocus('nodeB')); + + expect(focusHierarchy).toHaveLength(2); + expect(focusHierarchy.map((node) => node.focusId)).toEqual([ + 'root', + 'nodeB', + ]); + + expect(focusHierarchy.map((node) => node.focusId)).toEqual( + focusStore.getState().focusHierarchy + ); + }); +}); diff --git a/src/hooks/use-focus-hierarchy.ts b/src/hooks/use-focus-hierarchy.ts index 7d683dd..b67d2b1 100644 --- a/src/hooks/use-focus-hierarchy.ts +++ b/src/hooks/use-focus-hierarchy.ts @@ -1,6 +1,6 @@ import { useContext, useEffect, useState, useRef } from 'react'; import FocusContext from '../focus-context'; -import warning from '../utils/warning'; +import { warning } from '../utils/warning'; import { Node, NodeHierarchy } from '../types'; function hierarchiesAreEqual( diff --git a/src/hooks/use-focus-node.test.js b/src/hooks/use-focus-node.test.js new file mode 100644 index 0000000..643dce7 --- /dev/null +++ b/src/hooks/use-focus-node.test.js @@ -0,0 +1,82 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { render, act } from '@testing-library/react'; +import { + FocusRoot, + FocusNode, + useFocusNode, + useSetFocus, + useFocusStoreDangerously, +} from '../index'; + +describe('useFocusNode', () => { + it('returns the expected node', () => { + let setFocus; + let focusNode; + let focusStore; + + function TestComponent() { + setFocus = useSetFocus(); + focusNode = useFocusNode('nodeA'); + focusStore = useFocusStoreDangerously(); + + return ( + <> + + + + ); + } + + render( + + + + ); + + expect(focusNode).toEqual( + expect.objectContaining({ + focusId: 'nodeA', + isFocused: true, + isFocusedLeaf: true, + }) + ); + + expect(focusNode).toBe(focusStore.getState().nodes.nodeA); + + act(() => setFocus('nodeB')); + + expect(focusNode).toEqual( + expect.objectContaining({ + focusId: 'nodeA', + isFocused: false, + isFocusedLeaf: false, + }) + ); + + expect(focusNode).toBe(focusStore.getState().nodes.nodeA); + }); + + it('returns null if the node does not exist', () => { + let focusNode; + + function TestComponent() { + focusNode = useFocusNode('nodeABC'); + + return ( + <> + + + + ); + } + + render( + + + + ); + + expect(focusNode).toEqual(null); + }); +}); diff --git a/src/hooks/use-focus-node.ts b/src/hooks/use-focus-node.ts index ff6b3a9..38a0320 100644 --- a/src/hooks/use-focus-node.ts +++ b/src/hooks/use-focus-node.ts @@ -1,6 +1,6 @@ import { useContext, useState, useEffect, useRef } from 'react'; import FocusContext from '../focus-context'; -import warning from '../utils/warning'; +import { warning } from '../utils/warning'; import { Id, Node } from '../types'; export default function useFocusNode(focusId: Id): Node | null { diff --git a/src/hooks/use-focus-store-dangerously.ts b/src/hooks/use-focus-store-dangerously.ts index 766280d..27818c2 100644 --- a/src/hooks/use-focus-store-dangerously.ts +++ b/src/hooks/use-focus-store-dangerously.ts @@ -1,6 +1,6 @@ import { useContext } from 'react'; import FocusContext from '../focus-context'; -import warning from '../utils/warning'; +import { warning } from '../utils/warning'; import { FocusStore } from '../types'; export default function useFocusStoreDangerously(): FocusStore { diff --git a/src/hooks/use-set-focus.test.js b/src/hooks/use-set-focus.test.js new file mode 100644 index 0000000..8816113 --- /dev/null +++ b/src/hooks/use-set-focus.test.js @@ -0,0 +1,343 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { render, act, fireEvent, screen } from '@testing-library/react'; +import { + FocusRoot, + FocusNode, + useSetFocus, + useFocusStoreDangerously, +} from '../index'; +import { warning } from '../utils/warning'; + +describe('useSetFocus', () => { + it('is a noop when the node is already focused', () => { + let focusStore; + let setFocus; + + function TestComponent() { + focusStore = useFocusStoreDangerously(); + setFocus = useSetFocus(); + + return ( + <> + + + + ); + } + + render( + + + + ); + + let nodeA = screen.getByTestId('nodeA'); + expect(nodeA).toHaveClass('isFocused'); + expect(nodeA).toHaveClass('isFocusedLeaf'); + + let nodeB = screen.getByTestId('nodeB'); + expect(nodeB).not.toHaveClass('isFocused'); + expect(nodeB).not.toHaveClass('isFocusedLeaf'); + + const focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('nodeA'); + expect(focusState.focusHierarchy).toEqual(['root', 'nodeA']); + expect(focusState.activeNodeId).toEqual(null); + expect(Object.values(focusState.nodes)).toHaveLength(3); + + act(() => setFocus('nodeA')); + + nodeA = screen.getByTestId('nodeA'); + expect(nodeA).toHaveClass('isFocused'); + expect(nodeA).toHaveClass('isFocusedLeaf'); + + nodeB = screen.getByTestId('nodeB'); + expect(nodeB).not.toHaveClass('isFocused'); + expect(nodeB).not.toHaveClass('isFocusedLeaf'); + }); + + it('can be used to assign focus', () => { + let focusStore; + let setFocus; + + function TestComponent() { + focusStore = useFocusStoreDangerously(); + setFocus = useSetFocus(); + + return ( + <> + + + + ); + } + + render( + + + + ); + + let nodeA = screen.getByTestId('nodeA'); + expect(nodeA).toHaveClass('isFocused'); + expect(nodeA).toHaveClass('isFocusedLeaf'); + + let nodeB = screen.getByTestId('nodeB'); + expect(nodeB).not.toHaveClass('isFocused'); + expect(nodeB).not.toHaveClass('isFocusedLeaf'); + + let focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('nodeA'); + expect(focusState.focusHierarchy).toEqual(['root', 'nodeA']); + expect(focusState.activeNodeId).toEqual(null); + expect(Object.values(focusState.nodes)).toHaveLength(3); + + act(() => setFocus('nodeB')); + + nodeA = screen.getByTestId('nodeA'); + expect(nodeA).not.toHaveClass('isFocused'); + expect(nodeA).not.toHaveClass('isFocusedLeaf'); + + nodeB = screen.getByTestId('nodeB'); + expect(nodeB).toHaveClass('isFocused'); + expect(nodeB).toHaveClass('isFocusedLeaf'); + + focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('nodeB'); + expect(focusState.focusHierarchy).toEqual(['root', 'nodeB']); + expect(focusState.activeNodeId).toEqual(null); + expect(Object.values(focusState.nodes)).toHaveLength(3); + }); + + it('can be used to assign focus deeply by focusing a parent', () => { + let focusStore; + let setFocus; + + function TestComponent() { + focusStore = useFocusStoreDangerously(); + setFocus = useSetFocus(); + + return ( + <> + + + + A + + + + + + + + + ); + } + + render( + + + + ); + + let nodeAAA = screen.getByTestId('nodeA-A-A'); + expect(nodeAAA).toHaveClass('isFocused'); + expect(nodeAAA).toHaveClass('isFocusedLeaf'); + + let focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('nodeA-A-A'); + expect(focusState.focusHierarchy).toEqual([ + 'root', + 'nodeA', + 'nodeA-A', + 'nodeA-A-A', + ]); + expect(focusState.activeNodeId).toEqual(null); + expect(Object.values(focusState.nodes)).toHaveLength(7); + + act(() => setFocus('nodeB')); + + nodeAAA = screen.getByTestId('nodeA-A-A'); + expect(nodeAAA).not.toHaveClass('isFocused'); + expect(nodeAAA).not.toHaveClass('isFocusedLeaf'); + + let nodeB = screen.getByTestId('nodeB'); + expect(nodeB).toHaveClass('isFocused'); + expect(nodeB).not.toHaveClass('isFocusedLeaf'); + + let nodeBA = screen.getByTestId('nodeB-A'); + expect(nodeBA).toHaveClass('isFocused'); + expect(nodeBA).toHaveClass('isFocusedLeaf'); + + focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('nodeB-A'); + expect(focusState.focusHierarchy).toEqual(['root', 'nodeB', 'nodeB-A']); + expect(focusState.activeNodeId).toEqual(null); + }); + + it('ignores disabled nodes', () => { + let focusStore; + let setFocus; + + function TestComponent() { + focusStore = useFocusStoreDangerously(); + setFocus = useSetFocus(); + + return ( + <> + + + + ); + } + + render( + + + + ); + + let nodeA = screen.getByTestId('nodeA'); + expect(nodeA).toHaveClass('isFocused'); + expect(nodeA).toHaveClass('isFocusedLeaf'); + + let nodeB = screen.getByTestId('nodeB'); + expect(nodeB).not.toHaveClass('isFocused'); + expect(nodeB).not.toHaveClass('isFocusedLeaf'); + + let focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('nodeA'); + expect(focusState.focusHierarchy).toEqual(['root', 'nodeA']); + expect(focusState.activeNodeId).toEqual(null); + expect(Object.values(focusState.nodes)).toHaveLength(3); + + act(() => setFocus('nodeB')); + + nodeA = screen.getByTestId('nodeA'); + expect(nodeA).toHaveClass('isFocused'); + expect(nodeA).toHaveClass('isFocusedLeaf'); + + nodeB = screen.getByTestId('nodeB'); + expect(nodeB).not.toHaveClass('isFocused'); + expect(nodeB).not.toHaveClass('isFocusedLeaf'); + + focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('nodeA'); + expect(focusState.focusHierarchy).toEqual(['root', 'nodeA']); + expect(focusState.activeNodeId).toEqual(null); + expect(Object.values(focusState.nodes)).toHaveLength(3); + }); + + it('it is a noop if the node does not exist', () => { + let focusStore; + let setFocus; + + function TestComponent() { + focusStore = useFocusStoreDangerously(); + setFocus = useSetFocus(); + + return ( + <> + + + + ); + } + + render( + + + + ); + + let nodeA = screen.getByTestId('nodeA'); + expect(nodeA).toHaveClass('isFocused'); + expect(nodeA).toHaveClass('isFocusedLeaf'); + + let nodeB = screen.getByTestId('nodeB'); + expect(nodeB).not.toHaveClass('isFocused'); + expect(nodeB).not.toHaveClass('isFocusedLeaf'); + + let focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('nodeA'); + expect(focusState.focusHierarchy).toEqual(['root', 'nodeA']); + expect(focusState.activeNodeId).toEqual(null); + expect(Object.values(focusState.nodes)).toHaveLength(3); + + act(() => setFocus('nodeC')); + + nodeA = screen.getByTestId('nodeA'); + expect(nodeA).toHaveClass('isFocused'); + expect(nodeA).toHaveClass('isFocusedLeaf'); + + nodeB = screen.getByTestId('nodeB'); + expect(nodeB).not.toHaveClass('isFocused'); + expect(nodeB).not.toHaveClass('isFocusedLeaf'); + + focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('nodeA'); + expect(focusState.focusHierarchy).toEqual(['root', 'nodeA']); + expect(focusState.activeNodeId).toEqual(null); + expect(Object.values(focusState.nodes)).toHaveLength(3); + + expect(warning).toHaveBeenCalledTimes(1); + expect(warning.mock.calls[0][1]).toEqual('NODE_DOES_NOT_EXIST'); + }); + + it('it is a noop with a nonsense argument', () => { + let focusStore; + let setFocus; + + function TestComponent() { + focusStore = useFocusStoreDangerously(); + setFocus = useSetFocus(); + + return ( + <> + + + + ); + } + + render( + + + + ); + + let nodeA = screen.getByTestId('nodeA'); + expect(nodeA).toHaveClass('isFocused'); + expect(nodeA).toHaveClass('isFocusedLeaf'); + + let nodeB = screen.getByTestId('nodeB'); + expect(nodeB).not.toHaveClass('isFocused'); + expect(nodeB).not.toHaveClass('isFocusedLeaf'); + + let focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('nodeA'); + expect(focusState.focusHierarchy).toEqual(['root', 'nodeA']); + expect(focusState.activeNodeId).toEqual(null); + expect(Object.values(focusState.nodes)).toHaveLength(3); + + act(() => setFocus({})); + + nodeA = screen.getByTestId('nodeA'); + expect(nodeA).toHaveClass('isFocused'); + expect(nodeA).toHaveClass('isFocusedLeaf'); + + nodeB = screen.getByTestId('nodeB'); + expect(nodeB).not.toHaveClass('isFocused'); + expect(nodeB).not.toHaveClass('isFocusedLeaf'); + + focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('nodeA'); + expect(focusState.focusHierarchy).toEqual(['root', 'nodeA']); + expect(focusState.activeNodeId).toEqual(null); + expect(Object.values(focusState.nodes)).toHaveLength(3); + + expect(warning).toHaveBeenCalledTimes(1); + expect(warning.mock.calls[0][1]).toEqual('NODE_ID_NOT_STRING_TO_SET_FOCUS'); + }); +}); diff --git a/src/hooks/use-set-focus.ts b/src/hooks/use-set-focus.ts index 36b3129..8eee6bc 100644 --- a/src/hooks/use-set-focus.ts +++ b/src/hooks/use-set-focus.ts @@ -1,6 +1,6 @@ import { useContext } from 'react'; import FocusContext from '../focus-context'; -import warning from '../utils/warning'; +import { warning } from '../utils/warning'; import { Id } from '../types'; export default function useSetFocus(): (focusId: Id) => void { diff --git a/src/tests/disabled-nodes.test.js b/src/tests/disabled-nodes.test.js new file mode 100644 index 0000000..035aa0a --- /dev/null +++ b/src/tests/disabled-nodes.test.js @@ -0,0 +1,68 @@ +import React, { useState } from 'react'; +import '@testing-library/jest-dom'; +import { render, act, fireEvent, screen } from '@testing-library/react'; +import { + FocusRoot, + FocusNode, + useSetFocus, + useFocusStoreDangerously, +} from '../index'; +import { warning } from '../utils/warning'; + +describe('disabled FocusNodes', () => { + it('moves focus out of the entire subtree when a parent is disabled', () => { + let updateDisable; + let focusStore; + + function TestComponent() { + focusStore = useFocusStoreDangerously(); + const [disable, setDisable] = useState(false); + updateDisable = setDisable; + + return ( + + + + + A + + + + + + + + + + ); + } + + render( + + + + ); + + let focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('nodeA-A-A'); + expect(focusState.focusHierarchy).toEqual([ + 'root', + 'testRoot', + 'nodeA', + 'nodeA-A', + 'nodeA-A-A', + ]); + + act(() => updateDisable(true)); + focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('nodeB-A'); + expect(focusState.focusHierarchy).toEqual([ + 'root', + 'testRoot', + 'nodeB', + 'nodeB-A', + ]); + + expect(warning).toHaveBeenCalledTimes(0); + }); +}); diff --git a/src/tests/focus-node-events.test.js b/src/tests/focus-node-events.test.js new file mode 100644 index 0000000..12f25cb --- /dev/null +++ b/src/tests/focus-node-events.test.js @@ -0,0 +1,155 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { render, fireEvent, screen } from '@testing-library/react'; +import { FocusRoot, FocusNode, useFocusStoreDangerously } from '../index'; + +describe('FocusNode Events', () => { + describe('onBlur/onFocus', () => { + it('calls them when appropriate', () => { + const nodeAOnFocused = jest.fn(); + const nodeAOnBlurred = jest.fn(); + const nodeBOnFocused = jest.fn(); + const nodeBOnBlurred = jest.fn(); + let focusStore; + + function TestComponent() { + focusStore = useFocusStoreDangerously(); + + return ( + <> + + + + ); + } + + render( + + + + ); + + let focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('nodeA'); + expect(focusState.focusHierarchy).toEqual(['root', 'nodeA']); + + expect(nodeAOnFocused.mock.calls.length).toBe(1); + expect(nodeAOnBlurred.mock.calls.length).toBe(0); + expect(nodeBOnFocused.mock.calls.length).toBe(0); + expect(nodeBOnBlurred.mock.calls.length).toBe(0); + + fireEvent.keyDown(window, { + code: 'ArrowRight', + key: 'ArrowRight', + }); + + focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('nodeB'); + expect(focusState.focusHierarchy).toEqual(['root', 'nodeB']); + + expect(nodeAOnFocused.mock.calls.length).toBe(1); + expect(nodeAOnBlurred.mock.calls.length).toBe(1); + expect(nodeBOnFocused.mock.calls.length).toBe(1); + expect(nodeBOnBlurred.mock.calls.length).toBe(0); + }); + }); + + describe('onArrow', () => { + it('preventDefault', () => { + let focusStore; + + function TestComponent() { + focusStore = useFocusStoreDangerously(); + + return ( + <> + e.preventDefault()} + data-testid="nodeA" + /> + + + ); + } + + render( + + + + ); + + let nodeEl = screen.getByTestId('nodeA'); + expect(nodeEl).toHaveClass('isFocused'); + expect(nodeEl).toHaveClass('isFocusedLeaf'); + + let focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('nodeA'); + expect(focusState.focusHierarchy).toEqual(['root', 'nodeA']); + + fireEvent.keyDown(window, { + code: 'ArrowRight', + key: 'ArrowRight', + }); + + nodeEl = screen.getByTestId('nodeA'); + expect(nodeEl).toHaveClass('isFocused'); + expect(nodeEl).toHaveClass('isFocusedLeaf'); + + focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('nodeA'); + expect(focusState.focusHierarchy).toEqual(['root', 'nodeA']); + }); + + it('stopPropagation', () => { + let focusStore; + + function TestComponent() { + focusStore = useFocusStoreDangerously(); + + return ( + <> + e.preventDefault()}> + e.stopPropagation()}> + + + + ); + } + + render( + + + + ); + + let focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('nodeA-A'); + expect(focusState.focusHierarchy).toEqual(['root', 'nodeA', 'nodeA-A']); + + fireEvent.keyDown(window, { + code: 'ArrowRight', + key: 'ArrowRight', + }); + + focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('nodeB'); + expect(focusState.focusHierarchy).toEqual(['root', 'nodeB']); + }); + }); +}); diff --git a/src/tests/focus-trap.test.js b/src/tests/focus-trap.test.js new file mode 100644 index 0000000..b664118 --- /dev/null +++ b/src/tests/focus-trap.test.js @@ -0,0 +1,28 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { render, fireEvent, screen } from '@testing-library/react'; +import { FocusRoot, FocusNode, useFocusStoreDangerously } from '../index'; +import { warning } from '../utils/warning'; + +describe('Focus Traps', () => { + it('warns when restoreTrapFocusHierarchy is passed to a non-trap', () => { + function TestComponent() { + return ( + + + + + + ); + } + + render( + + + + ); + + expect(warning).toHaveBeenCalledTimes(1); + expect(warning.mock.calls[0][1]).toEqual('RESTORE_TRAP_FOCUS_WITHOUT_TRAP'); + }); +}); diff --git a/src/tests/grids.test.js b/src/tests/grids.test.js new file mode 100644 index 0000000..9e3003e --- /dev/null +++ b/src/tests/grids.test.js @@ -0,0 +1,488 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { render, fireEvent, screen } from '@testing-library/react'; +import { FocusRoot, FocusNode, useFocusStoreDangerously } from '../index'; +import { warning } from '../utils/warning'; + +describe('Grids', () => { + it('warns when orientation is passed', () => { + function TestComponent() { + return ( + + + + + + ); + } + + render( + + + + ); + + expect(warning).toHaveBeenCalledTimes(1); + expect(warning.mock.calls[0][1]).toEqual('ORIENTATION_ON_GRID'); + }); + + it('warns when onGridMove is passed to a non-grid', () => { + function TestComponent() { + return ( + {}}> + + + + + ); + } + + render( + + + + ); + + expect(warning).toHaveBeenCalledTimes(1); + expect(warning.mock.calls[0][1]).toEqual('GRID_MOVE_NOT_ON_GRID'); + }); + + it('warns when onMove is passed', () => { + function TestComponent() { + return ( + {}}> + + + + + ); + } + + render( + + + + ); + + expect(warning).toHaveBeenCalledTimes(1); + expect(warning.mock.calls[0][1]).toEqual('ON_MOVE_ON_GRID'); + }); + + describe('1x1', () => { + it('mounts correctly', () => { + let focusStore; + + function TestComponent() { + focusStore = useFocusStoreDangerously(); + + return ( + + + + + + ); + } + + render( + + + + ); + + const gridItem = screen.getByTestId('gridItem'); + expect(gridItem).toHaveClass('isFocused'); + expect(gridItem).toHaveClass('isFocusedLeaf'); + + const focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('gridItem'); + expect(focusState.focusHierarchy).toEqual([ + 'root', + 'gridRoot', + 'gridRow', + 'gridItem', + ]); + expect(focusState.activeNodeId).toEqual(null); + expect(Object.values(focusState.nodes)).toHaveLength(4); + + expect(warning).toHaveBeenCalledTimes(0); + }); + }); + + describe('2x2', () => { + it('mounts correctly', () => { + let focusStore; + + function TestComponent() { + focusStore = useFocusStoreDangerously(); + + return ( + + + + + + + + + + + ); + } + + render( + + + + ); + + const gridItem11 = screen.getByTestId('gridItem1-1'); + expect(gridItem11).toHaveClass('isFocused'); + expect(gridItem11).toHaveClass('isFocusedLeaf'); + + const focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('gridItem1-1'); + expect(focusState.focusHierarchy).toEqual([ + 'root', + 'gridRoot', + 'gridRow1', + 'gridItem1-1', + ]); + expect(focusState.activeNodeId).toEqual(null); + expect(Object.values(focusState.nodes)).toHaveLength(8); + expect(warning).toHaveBeenCalledTimes(0); + }); + + it('mounts correctly, respecting defaultColumnIndex/defaultRowIndex', () => { + let focusStore; + + function TestComponent() { + focusStore = useFocusStoreDangerously(); + + return ( + + + + + + + + + + + ); + } + + render( + + + + ); + + const gridItem11 = screen.getByTestId('gridItem2-2'); + expect(gridItem11).toHaveClass('isFocused'); + expect(gridItem11).toHaveClass('isFocusedLeaf'); + + const focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('gridItem2-2'); + expect(focusState.focusHierarchy).toEqual([ + 'root', + 'gridRoot', + 'gridRow2', + 'gridItem2-2', + ]); + expect(focusState.activeNodeId).toEqual(null); + expect(Object.values(focusState.nodes)).toHaveLength(8); + expect(warning).toHaveBeenCalledTimes(0); + }); + + it('navigates correctly (no wrapping)', () => { + let focusStore; + + function TestComponent() { + focusStore = useFocusStoreDangerously(); + + return ( + + + + + + + + + + + ); + } + + render( + + + + ); + + let gridItem11 = screen.getByTestId('gridItem1-1'); + expect(gridItem11).toHaveClass('isFocused'); + expect(gridItem11).toHaveClass('isFocusedLeaf'); + + let focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('gridItem1-1'); + expect(focusState.focusHierarchy).toEqual([ + 'root', + 'gridRoot', + 'gridRow1', + 'gridItem1-1', + ]); + expect(focusState.activeNodeId).toEqual(null); + expect(Object.values(focusState.nodes)).toHaveLength(8); + + fireEvent.keyDown(window, { + code: 'ArrowDown', + key: 'ArrowDown', + }); + + focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('gridItem2-1'); + expect(focusState.focusHierarchy).toEqual([ + 'root', + 'gridRoot', + 'gridRow2', + 'gridItem2-1', + ]); + expect(focusState.activeNodeId).toEqual(null); + + fireEvent.keyDown(window, { + code: 'ArrowRight', + key: 'ArrowRight', + }); + + focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('gridItem2-2'); + expect(focusState.focusHierarchy).toEqual([ + 'root', + 'gridRoot', + 'gridRow2', + 'gridItem2-2', + ]); + expect(focusState.activeNodeId).toEqual(null); + + // This tests that wrapping is off by default + fireEvent.keyDown(window, { + code: 'ArrowRight', + key: 'ArrowRight', + }); + + focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('gridItem2-2'); + expect(focusState.focusHierarchy).toEqual([ + 'root', + 'gridRoot', + 'gridRow2', + 'gridItem2-2', + ]); + expect(focusState.activeNodeId).toEqual(null); + + fireEvent.keyDown(window, { + code: 'ArrowUp', + key: 'ArrowUp', + }); + + focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('gridItem1-2'); + expect(focusState.focusHierarchy).toEqual([ + 'root', + 'gridRoot', + 'gridRow1', + 'gridItem1-2', + ]); + expect(focusState.activeNodeId).toEqual(null); + + // Tests to ensure that up/down boundaries are respected + fireEvent.keyDown(window, { + code: 'ArrowUp', + key: 'ArrowUp', + }); + + focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('gridItem1-2'); + expect(focusState.focusHierarchy).toEqual([ + 'root', + 'gridRoot', + 'gridRow1', + 'gridItem1-2', + ]); + expect(focusState.activeNodeId).toEqual(null); + expect(warning).toHaveBeenCalledTimes(0); + }); + + it('navigates correctly (wrapping horizontally)', () => { + let focusStore; + + function TestComponent() { + focusStore = useFocusStoreDangerously(); + + return ( + + + + + + + + + + + ); + } + + render( + + + + ); + + let gridItem11 = screen.getByTestId('gridItem1-1'); + expect(gridItem11).toHaveClass('isFocused'); + expect(gridItem11).toHaveClass('isFocusedLeaf'); + + let focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('gridItem1-1'); + expect(focusState.focusHierarchy).toEqual([ + 'root', + 'gridRoot', + 'gridRow1', + 'gridItem1-1', + ]); + expect(focusState.activeNodeId).toEqual(null); + expect(Object.values(focusState.nodes)).toHaveLength(8); + + fireEvent.keyDown(window, { + code: 'ArrowLeft', + key: 'ArrowLeft', + }); + + focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('gridItem1-2'); + expect(focusState.focusHierarchy).toEqual([ + 'root', + 'gridRoot', + 'gridRow1', + 'gridItem1-2', + ]); + expect(focusState.activeNodeId).toEqual(null); + + // This tests that wrapping doesn't work vertically when only `wrapGridColumns` + // is specified + fireEvent.keyDown(window, { + code: 'ArrowUp', + key: 'ArrowUp', + }); + + focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('gridItem1-2'); + expect(focusState.focusHierarchy).toEqual([ + 'root', + 'gridRoot', + 'gridRow1', + 'gridItem1-2', + ]); + expect(focusState.activeNodeId).toEqual(null); + expect(warning).toHaveBeenCalledTimes(0); + }); + + it('navigates correctly (wrapping vertically)', () => { + let focusStore; + + function TestComponent() { + focusStore = useFocusStoreDangerously(); + + return ( + + + + + + + + + + + ); + } + + render( + + + + ); + + let gridItem11 = screen.getByTestId('gridItem1-1'); + expect(gridItem11).toHaveClass('isFocused'); + expect(gridItem11).toHaveClass('isFocusedLeaf'); + + let focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('gridItem1-1'); + expect(focusState.focusHierarchy).toEqual([ + 'root', + 'gridRoot', + 'gridRow1', + 'gridItem1-1', + ]); + expect(focusState.activeNodeId).toEqual(null); + expect(Object.values(focusState.nodes)).toHaveLength(8); + + fireEvent.keyDown(window, { + code: 'ArrowLeft', + key: 'ArrowLeft', + }); + + focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('gridItem1-1'); + expect(focusState.focusHierarchy).toEqual([ + 'root', + 'gridRoot', + 'gridRow1', + 'gridItem1-1', + ]); + expect(focusState.activeNodeId).toEqual(null); + + fireEvent.keyDown(window, { + code: 'ArrowUp', + key: 'ArrowUp', + }); + + focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('gridItem2-1'); + expect(focusState.focusHierarchy).toEqual([ + 'root', + 'gridRoot', + 'gridRow2', + 'gridItem2-1', + ]); + expect(focusState.activeNodeId).toEqual(null); + expect(warning).toHaveBeenCalledTimes(0); + }); + }); +}); diff --git a/src/tests/mounting.test.js b/src/tests/mounting.test.js new file mode 100644 index 0000000..8afd9f8 --- /dev/null +++ b/src/tests/mounting.test.js @@ -0,0 +1,403 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { render, fireEvent, screen } from '@testing-library/react'; +import { FocusRoot, FocusNode, useFocusStoreDangerously } from '../index'; + +// +// These tests verify that the focus tree is accurate during the initial mount. +// No state changes should be made in these tests...they should only test a static environment. +// + +describe('Mounting', () => { + describe('one node', () => { + it('automatically assigns focus to that node', () => { + let focusStore; + + function TestComponent() { + focusStore = useFocusStoreDangerously(); + + return ( + + A + + ); + } + + render( + + + + ); + + const nodeEl = screen.getByTestId('nodeA'); + expect(nodeEl).toHaveClass('isFocused'); + expect(nodeEl).toHaveClass('isFocusedLeaf'); + + const focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('nodeA'); + expect(focusState.focusHierarchy).toEqual(['root', 'nodeA']); + expect(focusState.activeNodeId).toEqual(null); + expect(Object.values(focusState.nodes)).toHaveLength(2); + }); + }); + + describe('two nodes', () => { + it('handles all children being disabled', () => { + let focusStore; + + function TestComponent() { + focusStore = useFocusStoreDangerously(); + + return ( + <> + + + + ); + } + + render( + + + + ); + + const nodeAEl = screen.getByTestId('nodeA'); + expect(nodeAEl).not.toHaveClass('isFocused'); + expect(nodeAEl).not.toHaveClass('isFocusedLeaf'); + + const nodeBEl = screen.getByTestId('nodeB'); + expect(nodeBEl).not.toHaveClass('isFocused'); + expect(nodeBEl).not.toHaveClass('isFocusedLeaf'); + + const focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('root'); + expect(focusState.focusHierarchy).toEqual(['root']); + expect(focusState.activeNodeId).toEqual(null); + expect(Object.values(focusState.nodes)).toHaveLength(3); + }); + + it('does not leap over disabled parent nodes', () => { + let focusStore; + + function TestComponent() { + focusStore = useFocusStoreDangerously(); + + return ( + <> + + + + + + ); + } + + render( + + + + ); + + const nodeAEl = screen.getByTestId('nodeA'); + expect(nodeAEl).not.toHaveClass('isFocused'); + expect(nodeAEl).not.toHaveClass('isFocusedLeaf'); + + const nodeBEl = screen.getByTestId('nodeB'); + expect(nodeBEl).not.toHaveClass('isFocused'); + expect(nodeBEl).not.toHaveClass('isFocusedLeaf'); + + const nodeBA = screen.getByTestId('nodeB-A'); + expect(nodeBA).not.toHaveClass('isFocused'); + expect(nodeBA).not.toHaveClass('isFocusedLeaf'); + + const focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('root'); + expect(focusState.focusHierarchy).toEqual(['root']); + expect(focusState.activeNodeId).toEqual(null); + expect(Object.values(focusState.nodes)).toHaveLength(4); + }); + + it('automatically assigns focus to the first node', () => { + let focusStore; + + function TestComponent() { + focusStore = useFocusStoreDangerously(); + + return ( + <> + + A + + + B + + + ); + } + + render( + + + + ); + + const nodeAEl = screen.getByTestId('nodeA'); + expect(nodeAEl).toHaveClass('isFocused'); + expect(nodeAEl).toHaveClass('isFocusedLeaf'); + + const nodeBEl = screen.getByTestId('nodeB'); + expect(nodeBEl).not.toHaveClass('isFocused'); + expect(nodeBEl).not.toHaveClass('isFocusedLeaf'); + + const focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('nodeA'); + expect(focusState.focusHierarchy).toEqual(['root', 'nodeA']); + expect(focusState.activeNodeId).toEqual(null); + expect(Object.values(focusState.nodes)).toHaveLength(3); + }); + + it('does not move focus when arrows other than right are pressed', () => { + let focusStore; + + function TestComponent() { + focusStore = useFocusStoreDangerously(); + + return ( + <> + + A + + + B + + + ); + } + + render( + + + + ); + + const nodeAEl = screen.getByTestId('nodeA'); + expect(nodeAEl).toHaveClass('isFocused'); + expect(nodeAEl).toHaveClass('isFocusedLeaf'); + + const nodeBEl = screen.getByTestId('nodeB'); + expect(nodeBEl).not.toHaveClass('isFocused'); + expect(nodeBEl).not.toHaveClass('isFocusedLeaf'); + + const focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('nodeA'); + expect(focusState.focusHierarchy).toEqual(['root', 'nodeA']); + expect(focusState.activeNodeId).toEqual(null); + expect(Object.values(focusState.nodes)).toHaveLength(3); + + fireEvent.keyDown(window, { + code: 'ArrowLeft', + key: 'ArrowLeft', + }); + + expect(focusStore.getState().focusedNodeId).toEqual('nodeA'); + + fireEvent.keyDown(window, { + code: 'ArrowDown', + key: 'ArrowDown', + }); + + expect(focusStore.getState().focusedNodeId).toEqual('nodeA'); + + fireEvent.keyDown(window, { + code: 'ArrowUp', + key: 'ArrowUp', + }); + + expect(focusStore.getState().focusedNodeId).toEqual('nodeA'); + }); + + it('moves focus when the right arrow is pressed', () => { + let focusStore; + + function TestComponent() { + focusStore = useFocusStoreDangerously(); + + return ( + <> + + A + + + B + + + ); + } + + render( + + + + ); + + const nodeAEl = screen.getByTestId('nodeA'); + expect(nodeAEl).toHaveClass('isFocused'); + expect(nodeAEl).toHaveClass('isFocusedLeaf'); + + const nodeBEl = screen.getByTestId('nodeB'); + expect(nodeBEl).not.toHaveClass('isFocused'); + expect(nodeBEl).not.toHaveClass('isFocusedLeaf'); + + const focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('nodeA'); + expect(focusState.focusHierarchy).toEqual(['root', 'nodeA']); + expect(focusState.activeNodeId).toEqual(null); + expect(Object.values(focusState.nodes)).toHaveLength(3); + + fireEvent.keyDown(window, { + code: 'ArrowRight', + key: 'ArrowRight', + }); + + expect(focusStore.getState().focusedNodeId).toEqual('nodeB'); + }); + }); + + describe('a deep tree', () => { + it('resolves the initial focus state as expected', () => { + let focusStore; + + function TestComponent() { + focusStore = useFocusStoreDangerously(); + + return ( + <> + + + + A + + + + + + + + + ); + } + + render( + + + + ); + + const nodeAEl = screen.getByTestId('nodeA'); + expect(nodeAEl).toHaveClass('isFocused'); + expect(nodeAEl).not.toHaveClass('isFocusedLeaf'); + + const nodeAAEl = screen.getByTestId('nodeA-A'); + expect(nodeAAEl).toHaveClass('isFocused'); + expect(nodeAAEl).not.toHaveClass('isFocusedLeaf'); + + const nodeAAAEl = screen.getByTestId('nodeA-A-A'); + expect(nodeAAAEl).toHaveClass('isFocused'); + expect(nodeAAAEl).toHaveClass('isFocusedLeaf'); + + const nodeABEl = screen.getByTestId('nodeA-B'); + expect(nodeABEl).not.toHaveClass('isFocused'); + expect(nodeABEl).not.toHaveClass('isFocusedLeaf'); + + const nodeBEl = screen.getByTestId('nodeB'); + expect(nodeBEl).not.toHaveClass('isFocused'); + expect(nodeBEl).not.toHaveClass('isFocusedLeaf'); + + const nodeBAEl = screen.getByTestId('nodeB-A'); + expect(nodeBAEl).not.toHaveClass('isFocused'); + expect(nodeBAEl).not.toHaveClass('isFocusedLeaf'); + + const focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('nodeA-A-A'); + expect(focusState.focusHierarchy).toEqual([ + 'root', + 'nodeA', + 'nodeA-A', + 'nodeA-A-A', + ]); + expect(focusState.activeNodeId).toEqual(null); + expect(Object.values(focusState.nodes)).toHaveLength(7); + }); + + it('handles `onMountAssignFocusTo`', () => { + let focusStore; + + function TestComponent() { + focusStore = useFocusStoreDangerously(); + + return ( + + + + + A + + + + + + + + + + ); + } + + render( + + + + ); + + const nodeAEl = screen.getByTestId('nodeA'); + expect(nodeAEl).not.toHaveClass('isFocused'); + expect(nodeAEl).not.toHaveClass('isFocusedLeaf'); + + const nodeAAEl = screen.getByTestId('nodeA-A'); + expect(nodeAAEl).not.toHaveClass('isFocused'); + expect(nodeAAEl).not.toHaveClass('isFocusedLeaf'); + + const nodeAAAEl = screen.getByTestId('nodeA-A-A'); + expect(nodeAAAEl).not.toHaveClass('isFocused'); + expect(nodeAAAEl).not.toHaveClass('isFocusedLeaf'); + + const nodeABEl = screen.getByTestId('nodeA-B'); + expect(nodeABEl).not.toHaveClass('isFocused'); + expect(nodeABEl).not.toHaveClass('isFocusedLeaf'); + + const nodeBEl = screen.getByTestId('nodeB'); + expect(nodeBEl).toHaveClass('isFocused'); + expect(nodeBEl).not.toHaveClass('isFocusedLeaf'); + + const nodeBAEl = screen.getByTestId('nodeB-A'); + expect(nodeBAEl).toHaveClass('isFocused'); + expect(nodeBAEl).toHaveClass('isFocusedLeaf'); + + const nodeBB = screen.getByTestId('nodeB-B'); + expect(nodeBB).not.toHaveClass('isFocused'); + expect(nodeBB).not.toHaveClass('isFocusedLeaf'); + + const focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('nodeB-A'); + expect(focusState.focusHierarchy).toEqual([ + 'root', + 'testRoot', + 'nodeB', + 'nodeB-A', + ]); + expect(focusState.activeNodeId).toEqual(null); + expect(Object.values(focusState.nodes)).toHaveLength(9); + }); + }); +}); diff --git a/src/tests/pointer-events.test.js b/src/tests/pointer-events.test.js new file mode 100644 index 0000000..7020118 --- /dev/null +++ b/src/tests/pointer-events.test.js @@ -0,0 +1,190 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { render, fireEvent, createEvent, screen } from '@testing-library/react'; +import { FocusRoot, FocusNode, useFocusStoreDangerously } from '../index'; + +describe('Pointer Events', () => { + it('has them disabled by default', () => { + let focusStore; + + function TestComponent() { + focusStore = useFocusStoreDangerously(); + + return ( + <> + + + + ); + } + + render( + + + + ); + + let focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('nodeA'); + expect(focusState.focusHierarchy).toEqual(['root', 'nodeA']); + + const nodeB = screen.getByTestId('nodeB'); + fireEvent.mouseOver(nodeB); + + focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('nodeA'); + expect(focusState.focusHierarchy).toEqual(['root', 'nodeA']); + }); + + it('can be enabled, and can move focus on mouse move when the interaction mode is pointer', (done) => { + const nodeBOnClick = jest.fn(); + let focusStore; + + function TestComponent() { + focusStore = useFocusStoreDangerously(); + + return ( + <> + + + + ); + } + + render( + + + + ); + + let focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('nodeA'); + expect(focusState.focusHierarchy).toEqual(['root', 'nodeA']); + + // Fire an event on the window to set the interaction mode to pointer + fireEvent.mouseMove(window); + + // The handler within the focus store is rAF'd + requestAnimationFrame(() => { + const nodeB = screen.getByTestId('nodeB'); + fireEvent.mouseOver(nodeB); + + focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('nodeB'); + expect(focusState.focusHierarchy).toEqual(['root', 'nodeB']); + + const clickEvent = createEvent.click(nodeB, { button: 0 }); + fireEvent(nodeB, clickEvent); + + expect(nodeBOnClick.mock.calls.length).toBe(1); + + done(); + }); + }); + + // This one does not move focus because the window does not receive a mouse move event! + it('can be enabled, but does not function when the interaction mode is not pointer', (done) => { + const nodeBOnClick = jest.fn(); + let focusStore; + + function TestComponent() { + focusStore = useFocusStoreDangerously(); + + return ( + <> + + + + ); + } + + render( + + + + ); + + let focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('nodeA'); + expect(focusState.focusHierarchy).toEqual(['root', 'nodeA']); + + // The handler within the focus store is rAF'd + requestAnimationFrame(() => { + const nodeB = screen.getByTestId('nodeB'); + fireEvent.mouseOver(nodeB); + + focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('nodeA'); + expect(focusState.focusHierarchy).toEqual(['root', 'nodeA']); + + const clickEvent = createEvent.click(nodeB, { button: 0 }); + fireEvent(nodeB, clickEvent); + + expect(nodeBOnClick.mock.calls.length).toBe(0); + + done(); + }); + }); + + it('does not effect onMouseOver/onClick events, even when the interaction mode is not pointer', (done) => { + const nodeOnSelected = jest.fn(); + const nodeOnClick = jest.fn(); + const nodeOnMouseOver = jest.fn(); + let focusStore; + + function TestComponent() { + focusStore = useFocusStoreDangerously(); + + return ( + <> + + + + ); + } + + render( + + + + ); + + let focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('nodeA'); + expect(focusState.focusHierarchy).toEqual(['root', 'nodeA']); + + // The handler within the focus store is rAF'd + requestAnimationFrame(() => { + const nodeB = screen.getByTestId('nodeB'); + fireEvent.mouseOver(nodeB); + + expect(nodeOnMouseOver.mock.calls.length).toBe(1); + + focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('nodeA'); + expect(focusState.focusHierarchy).toEqual(['root', 'nodeA']); + + const clickEvent = createEvent.click(nodeB, { button: 0 }); + fireEvent(nodeB, clickEvent); + + expect(nodeOnSelected.mock.calls.length).toBe(0); + expect(nodeOnClick.mock.calls.length).toBe(1); + + done(); + }); + }); +}); diff --git a/src/tests/tree-updates.test.js b/src/tests/tree-updates.test.js new file mode 100644 index 0000000..7199549 --- /dev/null +++ b/src/tests/tree-updates.test.js @@ -0,0 +1,123 @@ +import React, { useState } from 'react'; +import '@testing-library/jest-dom'; +import { render, act, screen } from '@testing-library/react'; +import { FocusRoot, FocusNode, useFocusStoreDangerously } from '../index'; + +// +// React Apps frequently mount and unmount components as a user interacts +// with the app. These tests ensure that changes to the React component tree +// work +// + +describe('Tree updates', () => { + it('moves focus to a new child node that is mounted (one level)', () => { + let focusStore; + let updateMountState; + + function TestComponent() { + focusStore = useFocusStoreDangerously(); + const [mountChild, setMountChild] = useState(false); + updateMountState = setMountChild; + + return ( + + {mountChild && } + + ); + } + + render( + + + + ); + + let nodeA = screen.getByTestId('nodeA'); + expect(nodeA).toHaveClass('isFocused'); + expect(nodeA).toHaveClass('isFocusedLeaf'); + + let focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('nodeA'); + expect(focusState.focusHierarchy).toEqual(['root', 'nodeA']); + expect(focusState.activeNodeId).toEqual(null); + expect(Object.values(focusState.nodes)).toHaveLength(2); + + act(() => updateMountState(true)); + + nodeA = screen.getByTestId('nodeA'); + expect(nodeA).toHaveClass('isFocused'); + expect(nodeA).not.toHaveClass('isFocusedLeaf'); + + let nodeAA = screen.getByTestId('nodeA-A'); + expect(nodeAA).toHaveClass('isFocused'); + expect(nodeAA).toHaveClass('isFocusedLeaf'); + + focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('nodeA-A'); + expect(focusState.focusHierarchy).toEqual(['root', 'nodeA', 'nodeA-A']); + expect(focusState.activeNodeId).toEqual(null); + expect(Object.values(focusState.nodes)).toHaveLength(3); + }); + + it('moves focus to a new child node that is mounted (two levels)', () => { + let focusStore; + let updateMountState; + + function TestComponent() { + focusStore = useFocusStoreDangerously(); + const [mountChild, setMountChild] = useState(false); + updateMountState = setMountChild; + + return ( + + {mountChild && ( + + + + )} + + ); + } + + render( + + + + ); + + let nodeA = screen.getByTestId('nodeA'); + expect(nodeA).toHaveClass('isFocused'); + expect(nodeA).toHaveClass('isFocusedLeaf'); + + let focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('nodeA'); + expect(focusState.focusHierarchy).toEqual(['root', 'nodeA']); + expect(focusState.activeNodeId).toEqual(null); + expect(Object.values(focusState.nodes)).toHaveLength(2); + + act(() => updateMountState(true)); + + nodeA = screen.getByTestId('nodeA'); + expect(nodeA).toHaveClass('isFocused'); + expect(nodeA).not.toHaveClass('isFocusedLeaf'); + + let nodeAA = screen.getByTestId('nodeA-A'); + expect(nodeAA).toHaveClass('isFocused'); + expect(nodeAA).not.toHaveClass('isFocusedLeaf'); + + let nodeAAA = screen.getByTestId('nodeA-A-A'); + expect(nodeAAA).toHaveClass('isFocused'); + expect(nodeAAA).toHaveClass('isFocusedLeaf'); + + focusState = focusStore.getState(); + expect(focusState.focusedNodeId).toEqual('nodeA-A-A'); + expect(focusState.focusHierarchy).toEqual([ + 'root', + 'nodeA', + 'nodeA-A', + 'nodeA-A-A', + ]); + expect(focusState.activeNodeId).toEqual(null); + expect(Object.values(focusState.nodes)).toHaveLength(4); + }); +}); diff --git a/src/update-focus/compute-focus-hierarchy.ts b/src/update-focus/compute-focus-hierarchy.ts index 1161bb9..e24504b 100644 --- a/src/update-focus/compute-focus-hierarchy.ts +++ b/src/update-focus/compute-focus-hierarchy.ts @@ -1,5 +1,5 @@ import { getParents, getChildren } from '../utils/tree-navigation'; -import warning from '../utils/warning'; +import { warning } from '../utils/warning'; import { FocusState, Id, Orientation, NodeHierarchy, Node } from '../types'; interface ComputeFocusHierarchyOptions { @@ -22,7 +22,7 @@ function generateFocusHierarchyFromId({ orientation, preferEnd, }: GenerateFocusHierarchyFromIdOptions): Id[] { - const node = (focusState.nodes[propagateFromId] as unknown) as Node; + const node = focusState.nodes[propagateFromId] as unknown as Node; let preferredChildren: NodeHierarchy = []; if (node.trap) { preferredChildren = node._focusTrapPreviousHierarchy; @@ -58,14 +58,16 @@ export default function computeFocusHierarchy({ // @ts-ignore const assignedNode = focusState.nodes[assignFocusTo]; - if (!assignedNode) { - warning( - 'You attempted to explicitly focus a node that was not found in the focus tree. ' + - 'This may represent a bug in your application. ' + - 'You should ensure that a node that matches onMountAssignFocusTo is created and not disabled. ' + - 'This onMountAssignFocusTo value has been ignored; focus will be computed automatically.', - 'EXPLICIT_FOCUS_ERROR' - ); + if (process.env.NODE_ENV !== 'production') { + if (!assignedNode) { + warning( + 'You attempted to explicitly focus a node that was not found in the focus tree. ' + + 'This may represent a bug in your application. ' + + 'You should ensure that a node that matches onMountAssignFocusTo is created and not disabled. ' + + 'This onMountAssignFocusTo value has been ignored; focus will be computed automatically.', + 'EXPLICIT_FOCUS_ERROR' + ); + } } const focusHierarchy = generateFocusHierarchyFromId({ diff --git a/src/utils/create-node.ts b/src/utils/create-node.ts index 3a2175e..7486a91 100644 --- a/src/utils/create-node.ts +++ b/src/utils/create-node.ts @@ -1,4 +1,4 @@ -import warning from '../utils/warning'; +import { warning } from '../utils/warning'; import { NodeDefinition, FocusState, NodeMap, Id, Node } from '../types'; import getIndex from './get-index'; @@ -34,13 +34,15 @@ export default function createNodeDefinitionHierarchy({ const isCreatingNewNode = !currentNode; if (nodeDefinition.onMountAssignFocusTo !== undefined) { - if (onMountAssignFocusTo !== null) { - warning( - '[Focus]: More than one onMountAssignFocusTo was encountered while creating a new focus subtree. This may represent an error in your code. ' + - 'We strongly encourage you to ensure that only a single node is assigned an onMountAssignFocusTo when creating a focus subtree. ' + - 'Your onMountAssignFocusTo has been ignored, and the first child will be assigned focus.', - 'MORE_THAN_ONE_ONMOUNTFOCUS' - ); + if (process.env.NODE_ENV !== 'production') { + if (onMountAssignFocusTo !== null) { + warning( + '[Focus]: More than one onMountAssignFocusTo was encountered while creating a new focus subtree. This may represent an error in your code. ' + + 'We strongly encourage you to ensure that only a single node is assigned an onMountAssignFocusTo when creating a focus subtree. ' + + 'Your onMountAssignFocusTo has been ignored, and the first child will be assigned focus.', + 'MORE_THAN_ONE_ONMOUNTFOCUS' + ); + } } onMountAssignFocusTo = nodeDefinition.onMountAssignFocusTo; @@ -105,12 +107,14 @@ export default function createNodeDefinitionHierarchy({ // The implementation of this feature ties this library very tightly with React. const isFinalNode = i === nodeDefinitionHierarchy.length - 1; const setFocusGoal = nodeDefinition.onMountAssignFocusTo; - if (isFinalNode && setFocusGoal && !focusState.nodes[setFocusGoal]) { - warning( - 'You configured an onMountAssignFocusTo that was not found in the focus tree. This may represent an error in your application. ' + - 'Please make sure that the node specified by onMountAssignFocusTo is created at the same time as the parent.', - 'NOT_FOUND_ON_MOUNT_FOCUS' - ); + if (process.env.NODE_ENV !== 'production') { + if (isFinalNode && setFocusGoal && !focusState.nodes[setFocusGoal]) { + warning( + 'You configured an onMountAssignFocusTo that was not found in the focus tree. This may represent an error in your application. ' + + 'Please make sure that the node specified by onMountAssignFocusTo is created at the same time as the parent.', + 'NOT_FOUND_ON_MOUNT_FOCUS' + ); + } } continue; @@ -120,8 +124,8 @@ export default function createNodeDefinitionHierarchy({ const parentDefinition = nodeDefinitionHierarchy[parentLoopIndex]; const parentId = parentDefinition.focusId; - const parentNode = ((focusState.nodes[parentId] || - nodeUpdates[parentId]) as unknown) as Node; + const parentNode = (focusState.nodes[parentId] || + nodeUpdates[parentId]) as unknown as Node; const parentChildren = parentNode.children; diff --git a/src/utils/delete-node.ts b/src/utils/delete-node.ts index e65a0d9..aaa461c 100644 --- a/src/utils/delete-node.ts +++ b/src/utils/delete-node.ts @@ -1,5 +1,5 @@ import nodeIdIsFocused from './node-id-is-focused'; -import warning from './warning'; +import { warning } from './warning'; import updateFocus from '../update-focus/update-focus'; import { FocusState, Id, NodeMap, Node } from '../types'; diff --git a/src/utils/warning.ts b/src/utils/warning.ts index 0a8626b..4f54199 100644 --- a/src/utils/warning.ts +++ b/src/utils/warning.ts @@ -4,7 +4,7 @@ interface CodeCache { let codeCache: CodeCache = {}; -export default function warning(message: string, code: string) { +export function warning(message: string, code: string) { // This ensures that each warning type is only logged out one time if (code) { if (codeCache[code]) {