diff --git a/jest-setup.js b/jest-setup.js new file mode 100644 index 0000000..7b0828b --- /dev/null +++ b/jest-setup.js @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; 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-node.tsx b/src/focus-node.tsx index 574b8ff..a36be41 100644 --- a/src/focus-node.tsx +++ b/src/focus-node.tsx @@ -459,15 +459,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..b580e8b --- /dev/null +++ b/src/focus-root.test.js @@ -0,0 +1,224 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { render, fireEvent, screen } from '@testing-library/react'; +import { FocusRoot, FocusNode, useFocusStoreDangerously } from './index'; + +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'); + }); + }); + + 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..a3235c1 100644 --- a/src/focus-store.ts +++ b/src/focus-store.ts @@ -204,6 +204,8 @@ export default function createFocusStore({ } } + return; + } else if (currentNode.disabled) { return; } else if (currentNode.isExiting) { if (process.env.NODE_ENV !== 'production') { 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-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..52cb2ce 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,47 @@ 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 (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 +71,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 +91,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-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-set-focus.test.js b/src/hooks/use-set-focus.test.js new file mode 100644 index 0000000..2209c12 --- /dev/null +++ b/src/hooks/use-set-focus.test.js @@ -0,0 +1,284 @@ +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'; + +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); + }); + + // TODO: add check for warning + 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); + }); +}); 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/grids.test.js b/src/tests/grids.test.js new file mode 100644 index 0000000..849f5c0 --- /dev/null +++ b/src/tests/grids.test.js @@ -0,0 +1,409 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { render, fireEvent, screen } from '@testing-library/react'; +import { FocusRoot, FocusNode, useFocusStoreDangerously } from '../index'; + +describe('Grids', () => { + 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); + }); + }); + + 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); + }); + + 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); + }); + + 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); + }); + + 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); + }); + + 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); + }); + }); +}); 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); + }); +});