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);
+ });
+});