diff --git a/README.md b/README.md index 48c3e80..782e47c 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![install size](https://packagephobia.com/badge?p=@arrow-navigation/core)](https://packagephobia.com/result?p=@arrow-navigation/core) -Light (~13kb) and zero-dependency module to navigate through elements using the arrow keys written in Typescript. +Light (~16kb) and zero-dependency module to navigate through elements using the arrow keys written in Typescript. For live demo, [visit this url](https://arrow-navigation-demo.vercel.app/). For ReactJS implementation, check [@arrow-navigation/react](https://www.npmjs.com/package/@arrow-navigation/react). @@ -26,7 +26,10 @@ At the top of your application, you need to initialize the module. This will add import { initArrowNavigation } from '@arrow-navigation/core' initArrowNavigation({ - preventScroll: true // Prevent the default behavior of the arrow keys to scroll the page. The default value is true + preventScroll: true // Prevent the default behavior of the arrow keys to scroll the page. The default value is true, + disableWebListeners: false, // Disable the web listeners. The default value is false + adapter: webAdapter, // The adapter to use. The default value is webAdapter included in the package. You can create your own adapter to use the module in other platforms like React Native. + initialFocusElement: 'element-0-0' // The element to be focused when the elements has been registered. The default value is null }) ``` @@ -72,6 +75,18 @@ navigationApi.registerElement(buttonElement2) Initialize the module. This will add the event listeners to the document and store the navigation state in a singleton instance. +## getElementIdByOrder + +Retrieve the element ID in the same order as the library when use group byOrder. This functionality proves valuable when you need to manually control the focus. + +```typescript +const api = getArrowNavigation() + +// Set the focus to the first element of the group-0 +const id = getElementIdByOrder('group-0', 0) // 'group-0-0' +api.setFocusedElement(id) +``` + ## getArrowNavigation Get the navigation API. This will return an object with the following methods: @@ -86,7 +101,7 @@ const container = document.createElement('div') // Is important to keep a unique id for each group and his elements container.id = 'group-0' -registerGroup(container): void +registerGroup(container.id): void ``` You can also pass a options object as the second parameter to customize the navigation behavior. @@ -97,7 +112,7 @@ const container = document.createElement('div') // Is important to keep a unique id for each group and his elements container.id = 'group-0' -api.registerGroup(container, { +api.registerGroup(container.id, { firstElement: 'element-0-0', // The first element to be focused when the focus enter the group nextGroupByDirection: { 'down': 'group-1', // The next group when the user press the down arrow key @@ -115,6 +130,26 @@ api.registerGroup(container, { }) ``` +### resetGroupState + +Reset the group state. This will reset states like lastElement from the group config. This is usefull when you are remounting the group, for example, a memory's route change. + +```typescript + +const container = document.createElement('div') +container.id = 'group-0' +api.registerGroup(container.id, { saveLast: true }) + +// ...Register all the elements considering element-0-0 as the first element +// ...Navigate to element-0-1 + +api.getGroupConfig('group-0').lastElement === 'element-0-1' // true + +api.resetGroupState('group-0') + +api.getGroupConfig('group-0').lastElement === undefined // true +``` + ### registerElement Register an element to be able to navigate to it. The element must be inside a group. @@ -127,7 +162,7 @@ const element = document.createElement('button') // Is important to keep a unique id for each element element.id = 'element-0-0' -api.registerElement(element, 'group-1') +api.registerElement(element.id, 'group-1') ``` You can also pass a options object as the third parameter to customize the navigation behavior. @@ -140,7 +175,7 @@ const element = document.createElement('button') // Is important to keep a unique id for each element element.id = 'element-0-0' -api.registerElement(element, 'group-1', { +api.registerElement(element.id, 'group-1', { nextByDirection: { // This will set the next element manually 'down': 'element-0-1', // The next element when the user press the down arrow key 'right': { id: 'group-1', kind: 'group' }, // The next group when the user press the right arrow key @@ -166,10 +201,10 @@ const container = document.createElement('div') container.id = 'group-0' element.id = 'element-0-0' -api.registerGroup(container) -api.registerElement(element, 'group-0') +api.registerGroup(container.id) +api.registerElement(element.id, 'group-0') -api.unregisterElement(element) +api.unregisterElement(element.id) ``` ### getFocusedElement @@ -185,8 +220,8 @@ const container = document.createElement('div') container.id = 'group-0' element.id = 'element-0-0' -api.registerGroup(container) -api.registerElement(element, 'group-0') +api.registerGroup(container.id) +api.registerElement(element.id, 'group-0') const focusedElement = api.getFocusedElement() ``` @@ -207,15 +242,31 @@ container.id = 'group-0' element.id = 'element-0-0' element2.id = 'element-0-1' -api.registerGroup(container) -api.registerElement(element, 'group-0') -api.registerElement(element2, 'group-0') +api.registerGroup(container.id) +api.registerElement(element.id, container.id) +api.registerElement(element2.id, container.id) api.setFocusedElement('element-0-1') document.activeElement.id === element2.id // true ``` +### setInitialFocusElement + +Set the initial focus element. This will be the element focused when the elements has been registered. + +```typescript +const api = getArrowNavigation() + +//... Register all the elements + +api.setInitialFocusElement('element-0-1') + +// Wait for 500ms to be sure that the focus has been setted + +document.activeElement.id === 'element-0-1' // true +``` + ### destroy Destroy the module. This will remove the event listeners from the document and remove the navigation state from the singleton instance. @@ -240,8 +291,8 @@ const container2 = document.createElement('div') container.id = 'group-0' container2.id = 'group-1' -api.registerGroup(container) -api.registerGroup(container2) +api.registerGroup(container.id) +api.registerGroup(container2.id) const currentGroups = api.getCurrentGroups() // Set { 'group-0', 'group-1' } ``` @@ -262,9 +313,9 @@ container.id = 'group-0' element.id = 'element-0-0' element2.id = 'element-0-1' -api.registerGroup(container) -api.registerElement(element, 'group-0') -api.registerElement(element2, 'group-0') +api.registerGroup(container.id) +api.registerElement(element.id, container.id) +api.registerElement(element2.id, container.id) const groupElements = api.getGroupElements('group-0') // Set { 'element-0-0', 'element-0-1' } ``` @@ -281,7 +332,7 @@ const container = document.createElement('div') // Is important to keep a unique id for each group and his elements container.id = 'group-0' -api.registerGroup(container) +api.registerGroup(container.id) const groupConfig = api.getGroupConfig('group-0') // { viewportSafe: true, threshold: 0, keepFocus: false } ``` @@ -303,9 +354,9 @@ container.id = 'group-0' element.id = 'element-0-0' element2.id = 'element-0-1' -api.registerGroup(container) -api.registerElement(element, 'group-0') -api.registerElement(element2, 'group-0') +api.registerGroup(container.id) +api.registerElement(element.id, 'group-0') +api.registerElement(element2.id, 'group-0') const registeredElements = api.getRegisteredElements() // Set { 'element-0-0', 'element-0-1' } ``` @@ -327,9 +378,9 @@ container.id = 'group-0' element.id = 'element-0-0' element2.id = 'element-0-1' -api.registerGroup(container) -api.registerElement(element, 'group-0') -api.registerElement(element2, 'group-0') +api.registerGroup(container.id) +api.registerElement(element, container.id) +api.registerElement(element2, container.id) const registeredElements = api.getNextElement({ direction: 'right', inGroup: true }) // 'element-0-1' // or @@ -360,18 +411,42 @@ container2.id = 'group-1' element3.id = 'element-1-0' element4.id = 'element-1-1' -api.registerGroup(container) -api.registerGroup(container2) -api.registerElement(element, 'group-0') -api.registerElement(element2, 'group-0') -api.registerElement(element3, 'group-1') -api.registerElement(element4, 'group-1') +api.registerGroup(container.id) +api.registerGroup(container2.id) +api.registerElement(element.id, container.id) +api.registerElement(element2.id, container.id) +api.registerElement(element3.id, container2.id) +api.registerElement(element4.id, container2.id) const nextGroup = api.getNextGroup({ direction: 'down' }) // 'group-1' // or const nextGroup = api.getNextGroup({ groupId: 'group-0', direction: 'down' }) // 'group-1' ``` +### handleDirectionPress + +Handle the arrow key press. This is useful if you want to handle the arrow key press manually or React Native. The first parameter is the direction and the second parameter is a boolean to specify is a repeated key press, for example, when the user keep the key pressed. The default value is false. + +```typescript +const api = getArrowNavigation() + +const container = document.createElement('div') +const element = document.createElement('button') +const element2 = document.createElement('button') + +// Is important to keep a unique id for each group and his elements + +container.id = 'group-0' +element.id = 'element-0-0' +element2.id = 'element-0-1' + +api.registerGroup(container.id) +api.registerElement(element.id, container.id) +api.registerElement(element2.id, container.id) + +api.handleDirectionPress('right', false) +``` + ## Events The API implements an Event Emitter to listen to events. The events are accessible through the `on` and `off` methods. All the events can be accesed through the `ArrowNavigationEvents` enum. @@ -386,19 +461,19 @@ This event is triggered when the current group is changed. The event will receiv ### element:focus -This event is triggered when an element is focused. The event will receive `(currentElement, direction, prevElement)`. +This event is triggered when an element is focused. The event will receive `({ current, direction, prev })`. ### element:blur -This event is triggered when an element is blurred. The event will receive `(currentElement, direction, nextElement)`. +This event is triggered when an element is blurred. The event will receive `({ current, direction, next })`. ### group:focus -This event is triggered when a group is focused. The event will receive `(currentGroup, direction, prevGroup)`. +This event is triggered when a group is focused. The event will receive `({ current, direction, prev })`. ### group:blur -This event is triggered when a group is blurred. The event will receive `(currentGroup, direction, nextGroup)`. +This event is triggered when a group is blurred. The event will receive `({ current, direction, next })`. ### groups:change @@ -412,6 +487,10 @@ This event is triggered when the elements are changed. The event will receive th This event is triggered when the groups configuration is changed. The event will receive the groups configuration as a parameter. +### elements:register-end + +This event is triggered when the elements are registered. The event will not receive any parameter. + # Using with CDN You can use the module with a CDN. The module is available in the following URL: @@ -430,3 +509,27 @@ You can use the module with a CDN. The module is available in the following URL: ``` +# Using with React Native + +You can use the module with React Native (Experimental). You need to create an adapter to use the module in React Native. The adapter is a simple object with the following methods: + +```typescript +type Adapter = { + type: 'web' | 'react-native' + getNodeRect: (focusable: FocusableElement | FocusableGroupConfig) => Rect + isNodeDisabled: (focusable: FocusableElement) => boolean + focusNode: (focusable: FocusableElement, opts?: FocusNodeOptions) => void + isNodeFocusable: (focusable: FocusableElement) => boolean + getNodeRef: (focusable: Focusable) => unknown // TextInput / View / TouchableOpacity / TouchableHighlight +} +``` + +You can use the `handleDirectionPress` method on API with TVHandler from React Native to handle the arrow key press manually. We will release a React Native package soon. + +# Contributing + +Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. Keep the coverage at +95% and run `yarn test` before commit. + +# License + +[MIT](https://choosealicense.com/licenses/mit/) diff --git a/example/script.js b/example/script.js index b8cb991..dce2a4a 100644 --- a/example/script.js +++ b/example/script.js @@ -2,21 +2,26 @@ window.arrowNavigation.init({ debug: true }) const arrowNavigationApi = window.arrowNavigation.get() +arrowNavigationApi.setInitialFocusElement('group-1-button-0') + const app = document.getElementById('app') const group0Container = document.createElement('container') app.appendChild(group0Container) group0Container.setAttribute('id', 'group-0') group0Container.classList.add('flex', 'flex-col', 'justify-center', 'items-center', 'h-full', 'p-4', 'bg-gray-600', 'gap-4') -arrowNavigationApi.registerGroup(group0Container) +arrowNavigationApi.registerGroup(group0Container.id, { + arrowDebounce: false +}) Array.from(Array(6).keys()).forEach(index => { const button = document.createElement('button') group0Container.appendChild(button) - button.setAttribute('id', `group-0-button-${index}`) + const id = `group-0-button-${index}` + button.setAttribute('id', id) button.classList.add('bg-blue-500', 'text-white', 'w-16', 'h-16', 'rounded', 'focus:outline-none', 'flex', 'focus:bg-yellow-500', 'justify-center', 'items-center', 'text-2xl', 'font-bold', 'disabled:opacity-50') button.innerText = index - arrowNavigationApi.registerElement(button, 'group-0') + arrowNavigationApi.registerElement(id, 'group-0') }) // Other Groups container @@ -30,7 +35,7 @@ const generateRightGroup = (groupIdx, qty, disabled) => { const groupContainer = document.createElement('container') rightSideContainer.appendChild(groupContainer) groupContainer.setAttribute('id', groupId) - arrowNavigationApi.registerGroup(groupContainer) + arrowNavigationApi.registerGroup(groupId) groupContainer.classList.add('flex', 'flex-row', 'justify-start', 'items-center', 'p-4', 'bg-gray-500', 'gap-4') Array.from(Array(qty).keys()).forEach(elementIndex => { @@ -39,10 +44,11 @@ const generateRightGroup = (groupIdx, qty, disabled) => { if (disabled) { button.setAttribute('disabled', true) } - button.setAttribute('id', `group-${groupIdx}-button-${elementIndex}`) + const id = `group-${groupIdx}-button-${elementIndex}` + button.setAttribute('id', id) button.classList.add('bg-blue-500', 'text-white', 'w-32', 'h-16', 'rounded', 'focus:outline-none', 'flex', 'focus:bg-yellow-500', 'justify-center', 'items-center', 'text-2xl', 'font-bold', 'disabled:opacity-50') button.innerText = elementIndex - arrowNavigationApi.registerElement(button, groupId) + arrowNavigationApi.registerElement(id, groupId) }) } @@ -50,7 +56,7 @@ generateRightGroup(1, 5) generateRightGroup(2, 3) generateRightGroup(3, 4) generateRightGroup(4, 2) -generateRightGroup(5, 1, true) +generateRightGroup(5, 3, true) generateRightGroup(6, 5) document.getElementById('group-0-button-3').setAttribute('disabled', true) diff --git a/package.json b/package.json index fb59593..59ac333 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@arrow-navigation/core", - "version": "1.2.11", + "version": "2.0.0", "description": "Zero-dependency library to navigate through UI elements using the keyboard arrow keys built with Typescript", "main": "./lib/index.js", "module": "./lib/index.mjs", @@ -55,17 +55,18 @@ "license": "MIT", "homepage": "https://github.com/borisbelmar/arrow-navigation/", "bugs": { - "url": "https://github.com/borisbelmar/arrow-navigation/issues" + "url": "https://github.com/borisbelmar/arrow-navigation/issues" }, "repository": { - "type": "git", - "url": "git+https://github.com/borisbelmar/arrow-navigation.git" + "type": "git", + "url": "git+https://github.com/borisbelmar/arrow-navigation.git" }, "devDependencies": { "@testing-library/jest-dom": "5.16.5", "@types/jest": "29.4.0", "@types/jsdom": "21.1.1", "@types/node": "18.15.11", + "@types/react-native": "0.72", "@typescript-eslint/eslint-plugin": "5.45.0", "@typescript-eslint/parser": "5.45.0", "eslint": "8.28.0", diff --git a/src/__mocks__/getHtmlElement.mock.ts b/src/__mocks__/getHtmlElement.mock.ts index f2f1c67..af08a1e 100644 --- a/src/__mocks__/getHtmlElement.mock.ts +++ b/src/__mocks__/getHtmlElement.mock.ts @@ -6,7 +6,8 @@ interface Props { x?: number, y?: number, width?: number, - height?: number + height?: number, + mountOn?: string } export default function getHtmlElementMock ({ @@ -15,7 +16,8 @@ export default function getHtmlElementMock ({ x = 0, y = 0, width = 0, - height = 0 + height = 0, + mountOn = '' }: Props): HTMLElement { const getBoundingClientRect = jest.fn(() => getDOMRectMock(x, y, width, height)) const focus = jest.fn() @@ -24,5 +26,7 @@ export default function getHtmlElementMock ({ element.id = id element.getBoundingClientRect = getBoundingClientRect element.focus = focus + const container = document.getElementById(mountOn) || document.body + container.appendChild(element) return element } diff --git a/src/__mocks__/viewNavigationState.mock.ts b/src/__mocks__/viewNavigationState.mock.ts index 78a93cb..9349d50 100644 --- a/src/__mocks__/viewNavigationState.mock.ts +++ b/src/__mocks__/viewNavigationState.mock.ts @@ -1,13 +1,22 @@ +/* eslint-disable no-underscore-dangle */ +import webAdapter from '@/utils/webAdapter' import { ArrowNavigationState, FocusableElement, FocusableGroup, FocusableGroupConfig } from '../types' import getHtmlElementMock from './getHtmlElement.mock' -export default function getViewNavigationStateMock (): ArrowNavigationState { +interface MockProps { + registerCooldown?: number +} + +export default function getViewNavigationStateMock ({ + registerCooldown +}: MockProps = {}): ArrowNavigationState { + document.body.innerHTML = '' const elements = new Map() const getSquareElement = (id: string, group: string, x: number, y: number) => { const focusableElement = { id, - el: getHtmlElementMock({ id, x, y, width: 10, height: 10 }), + _ref: getHtmlElementMock({ id, x, y, width: 10, height: 10, mountOn: group, tagName: 'button' }), group } elements.set(focusableElement.id, focusableElement) @@ -19,7 +28,7 @@ export default function getViewNavigationStateMock (): ArrowNavigationState { const group1: FocusableGroup = { id: 'group-0', - el: getHtmlElementMock({ id: 'group-0', x: 0, y: 0, width: 16, height: 52 }), + _ref: getHtmlElementMock({ id: 'group-0', x: 0, y: 0, width: 16, height: 52 }), elements: new Set() }; [0, 1, 2, 3].forEach(i => { @@ -28,11 +37,11 @@ export default function getViewNavigationStateMock (): ArrowNavigationState { group1.elements.add(id) }) groups.set(group1.id, group1) - groupsConfig.set(group1.id, { el: group1.el, id: group1.id }) + groupsConfig.set(group1.id, { _ref: group1._ref, id: group1.id }) const group2: FocusableGroup = { id: 'group-1', - el: getHtmlElementMock({ id: 'group-1', x: 16, y: 0, width: 52, height: 16 }), + _ref: getHtmlElementMock({ id: 'group-1', x: 16, y: 0, width: 52, height: 16 }), elements: new Set() }; [0, 1, 2, 3].forEach(i => { @@ -41,11 +50,11 @@ export default function getViewNavigationStateMock (): ArrowNavigationState { group2.elements.add(id) }) groups.set(group2.id, group2) - groupsConfig.set(group2.id, { el: group2.el, id: group2.id }) + groupsConfig.set(group2.id, { _ref: group2._ref, id: group2.id }) const group3: FocusableGroup = { id: 'group-2', - el: getHtmlElementMock({ id: 'group-2', x: 16, y: 18, width: 52, height: 16 }), + _ref: getHtmlElementMock({ id: 'group-2', x: 16, y: 18, width: 52, height: 16 }), elements: new Set() }; [0, 1, 2, 3].forEach(i => { @@ -54,11 +63,11 @@ export default function getViewNavigationStateMock (): ArrowNavigationState { group3.elements.add(id) }) groups.set(group3.id, group3) - groupsConfig.set(group3.id, { el: group3.el, id: group3.id }) + groupsConfig.set(group3.id, { _ref: group3._ref, id: group3.id }) const group4: FocusableGroup = { id: 'group-3', - el: getHtmlElementMock({ id: 'group-3', x: 16, y: 36, width: 52, height: 16 }), + _ref: getHtmlElementMock({ id: 'group-3', x: 16, y: 36, width: 52, height: 16 }), elements: new Set() }; [0, 1, 2, 3].forEach(i => { @@ -67,21 +76,29 @@ export default function getViewNavigationStateMock (): ArrowNavigationState { group4.elements.add(id) }) groups.set(group4.id, group4) - groupsConfig.set(group4.id, { el: group4.el, id: group4.id }) + groupsConfig.set(group4.id, { _ref: group4._ref, id: group4.id }) const group5: FocusableGroup = { id: 'group-4', - el: getHtmlElementMock({ id: 'group-4', x: 0, y: 52, width: 16, height: 16 }), + _ref: getHtmlElementMock({ id: 'group-4', x: 0, y: 52, width: 16, height: 16 }), elements: new Set() } getSquareElement('element-4-0', 'group-4', 3, 55) group5.elements.add('element-4-0') groups.set(group5.id, group5) - groupsConfig.set(group5.id, { el: group5.el, id: group5.id }) - return { - currentElement: 'element-0-0', + groupsConfig.set(group5.id, { _ref: group5._ref, id: group5.id }) + + const CURRENT_ELEMENT = 'element-0-0' + + elements.get(CURRENT_ELEMENT)?._ref?.focus() + + const state: ArrowNavigationState = { + currentElement: CURRENT_ELEMENT, elements, groups, - groupsConfig + groupsConfig, + registerCooldown, + adapter: webAdapter } + return state } diff --git a/src/arrowNavigation.test.ts b/src/arrowNavigation.test.ts index 7f68c44..70b1257 100644 --- a/src/arrowNavigation.test.ts +++ b/src/arrowNavigation.test.ts @@ -1,8 +1,10 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable no-underscore-dangle */ import { initArrowNavigation, getArrowNavigation, ERROR_MESSAGES } from './arrowNavigation' import EVENTS from './config/events' -import type { Direction, FocusableElement } from './types' +import type { Direction, FocusableElement, FocusEventResult } from './types' import getViewNavigationStateMock from './__mocks__/viewNavigationState.mock' +import { TIMEOUT_TIME_EMIT_ELEMENTS_CHANGED } from './handlers/registerElementHandler' describe('arrowNavigation', () => { beforeEach(() => { @@ -59,11 +61,11 @@ describe('arrowNavigation', () => { navigationApi._forceNavigate('ArrowDown') - expect(state.elements.get('element-0-1')?.el.focus).toHaveBeenCalled() + expect(state.elements.get('element-0-1')?._ref?.focus).toHaveBeenCalled() navigationApi._forceNavigate('ArrowDown') - expect(state.elements.get('element-0-2')?.el.focus).toHaveBeenCalled() + expect(state.elements.get('element-0-2')?._ref?.focus).toHaveBeenCalled() }) it('should not forceNavigate if debug is disabled', () => { @@ -74,7 +76,7 @@ describe('arrowNavigation', () => { navigationApi._forceNavigate('ArrowDown') - expect(state.elements.get('element-0-1')?.el.focus).not.toHaveBeenCalled() + expect(state.elements.get('element-0-1')?._ref?.focus).not.toHaveBeenCalled() }) it('should return the focused element', () => { @@ -195,21 +197,26 @@ describe('arrowNavigation', () => { }) it('check correct time on current reassigned and event consumption', () => { - initArrowNavigation({ debug: true }) + initArrowNavigation({ debug: true, disableWebListeners: false }) const navigationApi = getArrowNavigation() + const state = getViewNavigationStateMock() - navigationApi._setState(getViewNavigationStateMock()) + navigationApi._setState(state) - navigationApi.setFocusElement('element-0-2', 'group-0') + navigationApi.setFocusElement('element-0-2') const listener = jest.fn() - navigationApi.on(EVENTS.CURRENT_ELEMENT_CHANGE, ( - _el: FocusableElement, - direction: Direction - ) => { - if (!navigationApi.getNextElement({ direction: direction as Direction, inGroup: true })) { + navigationApi.on(EVENTS.CURRENT_ELEMENT_CHANGE, ({ + current: _, + direction + }: FocusEventResult) => { + const nextElement = navigationApi.getNextElement({ + direction: direction as Direction, + inGroup: true + }) + if (!nextElement) { listener('last') } }) @@ -219,4 +226,137 @@ describe('arrowNavigation', () => { expect(listener).toHaveBeenCalledWith('last') expect(navigationApi.getFocusedElement()?.id).toBe('element-0-3') }) + + it('should focus the next element if direction press handler is executed', () => { + initArrowNavigation({ debug: true }) + + const navigationApi = getArrowNavigation() + const state = getViewNavigationStateMock() + + navigationApi._setState(state) + + navigationApi.setFocusElement('element-0-2') + + navigationApi.handleDirectionPress('down', false) + + expect(navigationApi.getFocusedElement()?.id).toBe('element-0-3') + }) + + it('should not set the eventListeners if disableWebListeners is true', () => { + window.addEventListener = jest.fn() + initArrowNavigation({ disableWebListeners: true }) + + expect(window.addEventListener).not.toHaveBeenCalled() + }) + + it('should set the eventListeners if disableWebListeners is false', () => { + window.addEventListener = jest.fn() + initArrowNavigation({ disableWebListeners: false }) + + expect(window.addEventListener).toHaveBeenCalled() + }) + + it('should focus the initialFocusElement if it is set', () => { + jest.useFakeTimers() + initArrowNavigation({ initialFocusElement: 'element-0-2' }) + + const navigationApi = getArrowNavigation() + + const groupContainer = document.createElement('div') + groupContainer.id = 'group-0' + document.body.appendChild(groupContainer) + navigationApi.registerGroup(groupContainer.id) + + const element = document.createElement('button') + element.id = 'element-0-1' + groupContainer.appendChild(element) + navigationApi.registerElement(element.id, 'group-0') + + const element2 = document.createElement('button') + element2.id = 'element-0-2' + groupContainer.appendChild(element2) + navigationApi.registerElement(element2.id, 'group-0') + + expect(navigationApi.getFocusedElement()?.id).toBe(undefined) + + jest.advanceTimersByTime(TIMEOUT_TIME_EMIT_ELEMENTS_CHANGED) + + expect(navigationApi.getFocusedElement()?.id).toBe('element-0-2') + + navigationApi.unregisterElement('element-0-2') + navigationApi.unregisterElement('element-0-1') + + navigationApi.setInitialFocusElement('element-0-1') + + navigationApi.registerElement(element.id, 'group-0') + navigationApi.registerElement(element2.id, 'group-0') + + expect(navigationApi.getFocusedElement()?.id).toBe(undefined) + + jest.advanceTimersByTime(TIMEOUT_TIME_EMIT_ELEMENTS_CHANGED) + + expect(navigationApi.getFocusedElement()?.id).toBe('element-0-1') + + navigationApi.unregisterElement('element-0-2') + navigationApi.unregisterElement('element-0-1') + + navigationApi.setInitialFocusElement('non-existing-element') + + navigationApi.registerElement(element.id, 'group-0') + navigationApi.registerElement(element2.id, 'group-0') + + expect(navigationApi.getFocusedElement()?.id).toBe(undefined) + + jest.advanceTimersByTime(TIMEOUT_TIME_EMIT_ELEMENTS_CHANGED) + + expect(navigationApi.getFocusedElement()?.id).toBe('element-0-1') + + navigationApi.unregisterElement('element-0-2') + navigationApi.unregisterElement('element-0-1') + + navigationApi.setInitialFocusElement(null as unknown as string) + + navigationApi.registerElement(element.id, 'group-0') + + expect(navigationApi.getFocusedElement()?.id).toBe(undefined) + + jest.advanceTimersByTime(TIMEOUT_TIME_EMIT_ELEMENTS_CHANGED) + + expect(navigationApi.getFocusedElement()?.id).toBe('element-0-1') + }) + + it('should focus the initialFocusElement if the focused node is not the current', () => { + jest.useFakeTimers() + initArrowNavigation({ initialFocusElement: 'element-0-1', debug: true }) + + const navigationApi = getArrowNavigation() + + const groupContainer = document.createElement('div') + groupContainer.id = 'group-0' + document.body.appendChild(groupContainer) + navigationApi.registerGroup(groupContainer.id) + + const element = document.createElement('button') + element.id = 'element-0-0' + groupContainer.appendChild(element) + navigationApi.registerElement(element.id, 'group-0') + + navigationApi.setFocusElement('element-0-0') + + const element1 = document.createElement('button') + element1.id = 'element-0-1' + groupContainer.appendChild(element1) + navigationApi.registerElement(element1.id, 'group-0') + + const nonRegisteredItem = document.createElement('button') + document.body.appendChild(nonRegisteredItem) + nonRegisteredItem.focus() + + expect(navigationApi.getFocusedElement()?.id).toBe('element-0-0') + expect(document.activeElement).toBe(nonRegisteredItem) + + jest.advanceTimersByTime(TIMEOUT_TIME_EMIT_ELEMENTS_CHANGED) + + expect(document.activeElement).toBe(element) + }) }) diff --git a/src/arrowNavigation.ts b/src/arrowNavigation.ts index 0bb38fa..3701d92 100644 --- a/src/arrowNavigation.ts +++ b/src/arrowNavigation.ts @@ -1,18 +1,22 @@ /* eslint-disable @typescript-eslint/no-empty-function */ import type { ArrowNavigationInstance, ArrowNavigationOptions, ArrowNavigationState, Direction, FocusableElement } from '@/types' import { + directionPressHandler, getArrowPressHandler, getNextElementHandler, getNextGroupHandler, globalFocusHandler, registerElementHandler, registerGroupHandler, + resetGroupStateHandler, setFocusHandler, unregisterElementHandler } from './handlers' import changeFocusEventHandler from './handlers/changeFocusEventHandler' import createEventEmitter from './utils/createEventEmitter' import getCurrentElement from './utils/getCurrentElement' +import getInitialArrowNavigationState from './utils/getInitialArrowNavigationState' +import EVENTS from './config/events' let arrowNavigation: ArrowNavigationInstance | null @@ -25,21 +29,24 @@ export const ERROR_MESSAGES = { export function initArrowNavigation ({ errorOnReinit = false, debug = false, - preventScroll = true + preventScroll = true, + disableWebListeners = false, + adapter, + initialFocusElement, + registerCooldown = 500 }: ArrowNavigationOptions = {}) { - const state: ArrowNavigationState = { - currentElement: null, - groups: new Map(), - groupsConfig: new Map(), - elements: new Map(), - debug - } + const state: ArrowNavigationState = getInitialArrowNavigationState({ + debug, + adapter, + initialFocusElement, + registerCooldown + }) const emitter = createEventEmitter() const changeFocusElementHandler = (nextElement: FocusableElement, direction?: Direction) => { const prevElement = getCurrentElement(state) as FocusableElement state.currentElement = nextElement.id - nextElement.el.focus({ preventScroll }) + state.adapter.focusNode(nextElement, { preventScroll }) changeFocusEventHandler({ nextElement, prevElement, @@ -49,6 +56,30 @@ export function initArrowNavigation ({ }) } + emitter.on(EVENTS.ELEMENTS_REGISTER_END, () => { + const currentElement = getCurrentElement(state) + + if ((!currentElement) && state.elements.size) { + const initialElement = state.elements.get(state.initialFocusElement || '') + if (initialElement) { + changeFocusElementHandler(initialElement) + return + } + const firstElement = state.elements.values().next().value + if (firstElement) { + changeFocusElementHandler(firstElement) + return + } + } + + const focusedRef = state.adapter.getFocusedNode() + const currentRef = currentElement && state.adapter.getNodeRef(currentElement) + + if (currentElement && focusedRef !== currentRef) { + state.adapter.focusNode(currentElement, { preventScroll }) + } + }) + if (arrowNavigation) { if (errorOnReinit) { throw new Error(ERROR_MESSAGES.RE_INIT_ERROR) @@ -59,22 +90,47 @@ export function initArrowNavigation ({ arrowNavigation.destroy() } - const onKeyPress = getArrowPressHandler(state, changeFocusElementHandler) + const onKeyPress = getArrowPressHandler({ + state, + onChangeCurrentElement: changeFocusElementHandler + }) const onGlobalFocus = (event: FocusEvent) => globalFocusHandler(state, event, preventScroll) - window.addEventListener('keydown', onKeyPress) - window.addEventListener('focus', onGlobalFocus, true) + if (!disableWebListeners) { + window.addEventListener('keydown', onKeyPress) + window.addEventListener('focus', onGlobalFocus, true) + } arrowNavigation = { - getFocusedElement: () => state.elements.get(state.currentElement as string) || null, - setFocusElement: setFocusHandler(state, changeFocusElementHandler), - registerGroup: registerGroupHandler(state, emitter.emit), - registerElement: registerElementHandler(state, changeFocusElementHandler, emitter.emit), - unregisterElement: unregisterElementHandler(state, changeFocusElementHandler, emitter.emit), + getFocusedElement: () => ( + state.elements.get(state.currentElement as string) || null + ), + setFocusElement: setFocusHandler({ state, onChangeCurrentElement: changeFocusElementHandler }), + setInitialFocusElement: (id: string) => { + state.initialFocusElement = id + }, + registerGroup: registerGroupHandler({ + state, + emit: emitter.emit + }), + registerElement: registerElementHandler({ + state, + emit: emitter.emit + }), + unregisterElement: unregisterElementHandler({ + state, + emit: emitter.emit + }), + resetGroupState: resetGroupStateHandler({ + state, + emit: emitter.emit + }), destroy () { - window.removeEventListener('keydown', onKeyPress) - window.removeEventListener('focus', onGlobalFocus, true) + if (!disableWebListeners) { + window.removeEventListener('keydown', onKeyPress) + window.removeEventListener('focus', onGlobalFocus, true) + } arrowNavigation = null }, getCurrentGroups () { @@ -94,6 +150,14 @@ export function initArrowNavigation ({ }, getNextElement: getNextElementHandler(state), getNextGroup: getNextGroupHandler(state), + handleDirectionPress: (direction: Direction, repeat?: boolean) => { + directionPressHandler({ + state, + direction, + repeat: !!repeat, + onChangeCurrentElement: changeFocusElementHandler + }) + }, _forceNavigate (key) { if (!state.debug) return onKeyPress({ @@ -105,10 +169,7 @@ export function initArrowNavigation ({ }, _setState (newState: ArrowNavigationState) { if (!state.debug) return - state.currentElement = newState.currentElement - state.groups = newState.groups - state.groupsConfig = newState.groupsConfig - state.elements = newState.elements + Object.assign(state, newState) }, on: emitter.on, off: emitter.off diff --git a/src/config/events.ts b/src/config/events.ts index a3ae97b..2a3cf85 100644 --- a/src/config/events.ts +++ b/src/config/events.ts @@ -7,7 +7,8 @@ const EVENTS = { GROUP_BLUR: 'group:blur', GROUP_FOCUS: 'group:focus', ELEMENT_FOCUS: 'element:focus', - ELEMENT_BLUR: 'element:blur' + ELEMENT_BLUR: 'element:blur', + ELEMENTS_REGISTER_END: 'elements:register-end' } export default EVENTS diff --git a/src/handlers/changeFocusEventHandler.test.ts b/src/handlers/changeFocusEventHandler.test.ts index 963c892..9102f1c 100644 --- a/src/handlers/changeFocusEventHandler.test.ts +++ b/src/handlers/changeFocusEventHandler.test.ts @@ -55,10 +55,26 @@ describe('changeFocusEventHandler', () => { emit: emitter.emit }) - expect(events.onElementFocus).toHaveBeenCalledWith(nextElement, 'down', prevElement) - expect(events.onElementBlur).toHaveBeenCalledWith(getCurrentElement(state) as FocusableElement, 'down', nextElement) - expect(events.onGroupBlur).toHaveBeenCalledWith(state.groupsConfig.get('group-0'), 'down', state.groupsConfig.get('group-1')) - expect(events.onGroupFocus).toHaveBeenCalledWith(state.groupsConfig.get('group-1'), 'down', state.groupsConfig.get('group-0')) + expect(events.onElementFocus).toHaveBeenCalledWith({ + current: nextElement, + direction: 'down', + prev: prevElement + }) + expect(events.onElementBlur).toHaveBeenCalledWith({ + current: getCurrentElement(state) as FocusableElement, + direction: 'down', + next: nextElement + }) + expect(events.onGroupBlur).toHaveBeenCalledWith({ + current: state.groupsConfig.get('group-0'), + direction: 'down', + next: state.groupsConfig.get('group-1') + }) + expect(events.onGroupFocus).toHaveBeenCalledWith({ + current: state.groupsConfig.get('group-1'), + direction: 'down', + prev: state.groupsConfig.get('group-0') + }) }) it('should call onFocus and onBlur on group and element', () => { @@ -139,10 +155,18 @@ describe('changeFocusEventHandler', () => { emit: emitter.emit }) - expect(events.onElementFocus).toHaveBeenCalledWith(nextElement, 'down', null) + expect(events.onElementFocus).toHaveBeenCalledWith({ + current: nextElement, + direction: 'down', + prev: null + }) expect(events.onElementBlur).not.toHaveBeenCalled() expect(events.onGroupBlur).not.toHaveBeenCalled() - expect(events.onGroupFocus).toHaveBeenCalledWith(state.groupsConfig.get('group-1'), 'down', undefined) + expect(events.onGroupFocus).toHaveBeenCalledWith({ + current: state.groupsConfig.get('group-1'), + direction: 'down', + prev: undefined + }) }) it('should save the last element of the group if saveLast is true', () => { diff --git a/src/handlers/changeFocusEventHandler.ts b/src/handlers/changeFocusEventHandler.ts index 56b6e2b..650fab6 100644 --- a/src/handlers/changeFocusEventHandler.ts +++ b/src/handlers/changeFocusEventHandler.ts @@ -29,7 +29,11 @@ export default function changeFocusEventHandler ({ next: nextGroup, direction }) - emit(EVENTS.GROUP_BLUR, prevGroup, direction, nextGroup) + emit(EVENTS.GROUP_BLUR, { + current: prevGroup, + next: nextGroup, + direction + }) } if (nextGroup) { @@ -38,8 +42,16 @@ export default function changeFocusEventHandler ({ prev: prevGroup, direction }) - emit(EVENTS.GROUP_FOCUS, nextGroup, direction, prevGroup) - emit(EVENTS.CURRENT_GROUP_CHANGE, nextGroup, direction, prevGroup) + emit(EVENTS.GROUP_FOCUS, { + current: nextGroup, + prev: prevGroup, + direction + }) + emit(EVENTS.CURRENT_GROUP_CHANGE, { + current: nextGroup, + prev: prevGroup, + direction + }) } } if (prevElement) { @@ -48,13 +60,25 @@ export default function changeFocusEventHandler ({ next: nextElement, direction }) - emit(EVENTS.ELEMENT_BLUR, prevElement, direction, nextElement) + emit(EVENTS.ELEMENT_BLUR, { + current: prevElement, + next: nextElement, + direction + }) } nextElement.onFocus?.({ current: nextElement, prev: prevElement, direction }) - emit(EVENTS.ELEMENT_FOCUS, nextElement, direction, prevElement) - emit(EVENTS.CURRENT_ELEMENT_CHANGE, nextElement, direction, prevElement) + emit(EVENTS.ELEMENT_FOCUS, { + current: nextElement, + prev: prevElement, + direction + }) + emit(EVENTS.CURRENT_ELEMENT_CHANGE, { + current: nextElement, + prev: prevElement, + direction + }) } diff --git a/src/handlers/directionPressHandler.test.ts b/src/handlers/directionPressHandler.test.ts new file mode 100644 index 0000000..5916786 --- /dev/null +++ b/src/handlers/directionPressHandler.test.ts @@ -0,0 +1,74 @@ +import getViewNavigationStateMock from '@/__mocks__/viewNavigationState.mock' +import type { ArrowNavigationState } from '@/types' +import directionPressHandler, { ERROR_MESSAGES } from './directionPressHandler' + +describe('directionPressHandler Function', () => { + let state: ArrowNavigationState + + beforeEach(() => { + state = getViewNavigationStateMock() + window.innerWidth = 50 + window.innerHeight = 50 + }) + + beforeAll(() => { + global.console = { + ...global.console, + warn: jest.fn() + } + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it('should log a warn message if not currentElement and elements is empty', () => { + const focusNextElement = jest.fn() + state.currentElement = null + state.elements = new Map() + directionPressHandler({ + state, + onChangeCurrentElement: focusNextElement, + direction: 'down', + repeat: false + }) + expect(console.warn).toHaveBeenCalledWith(ERROR_MESSAGES.NO_ELEMENT_FOCUSED) + }) + + it('should focus a random element on map of elements if currentElement is null and elements is not empty', () => { + const focusNextElement = jest.fn() + state.currentElement = null + state.elements = new Map().set('element-0-0', { + el: document.createElement('button'), + group: 'group-0' + }) + directionPressHandler({ + state, + direction: 'down', + onChangeCurrentElement: focusNextElement, + repeat: false + }) + + expect(console.warn).not.toHaveBeenCalledWith(ERROR_MESSAGES.NO_ELEMENT_FOCUSED) + expect(focusNextElement).toHaveBeenCalled() + }) + + it('should focus the initialFocusElement if currentElement is null and elements is not empty', () => { + const focusNextElement = jest.fn() + state.currentElement = null + state.initialFocusElement = 'element-0-0' + state.elements = new Map().set('element-0-0', { + el: document.createElement('button'), + group: 'group-0' + }) + directionPressHandler({ + state, + direction: 'down', + onChangeCurrentElement: focusNextElement, + repeat: false + }) + + expect(console.warn).not.toHaveBeenCalledWith(ERROR_MESSAGES.NO_ELEMENT_FOCUSED) + expect(focusNextElement).toHaveBeenCalled() + }) +}) diff --git a/src/handlers/directionPressHandler.ts b/src/handlers/directionPressHandler.ts new file mode 100644 index 0000000..fa68e74 --- /dev/null +++ b/src/handlers/directionPressHandler.ts @@ -0,0 +1,57 @@ +import type { ArrowNavigationState, Direction, FocusableElement } from '@/types' +import getCurrentElement from '@/utils/getCurrentElement' +import findNextElement from './utils/findNextElement' + +interface DirectionPressHandlerProps { + state: ArrowNavigationState + direction: Direction + onChangeCurrentElement: (element: FocusableElement, dir: Direction) => void + repeat: boolean +} + +export const ERROR_MESSAGES = { + NO_ELEMENT_FOCUSED: 'No element is focused. Check if you have registered any elements' +} + +export default function directionPressHandler ({ + state, + direction, + repeat, + onChangeCurrentElement +}: DirectionPressHandlerProps) { + const currentElement = getCurrentElement(state) + + if (!currentElement) { + const initialElement = state.elements.get(state.initialFocusElement || '') + if (initialElement) { + onChangeCurrentElement(initialElement, direction) + return + } + const firstRegisteredElement = state.elements.values().next().value + if (firstRegisteredElement) { + onChangeCurrentElement(firstRegisteredElement, direction) + } else { + console.warn(ERROR_MESSAGES.NO_ELEMENT_FOCUSED) + } + return + } + + const focusRef = state.adapter.getFocusedNode() + const currentRef = currentElement && state.adapter.getNodeRef(currentElement) + + if (currentElement && focusRef !== currentRef) { + state.adapter.focusNode(currentElement, { preventScroll: true }) + } + + const currentGroupConfig = state.groupsConfig.get(currentElement.group) + + if (currentGroupConfig?.arrowDebounce && repeat) { + return + } + + const nextElement = findNextElement({ direction, state, fromElement: currentElement }) + + if (nextElement) { + onChangeCurrentElement(nextElement, direction as Direction) + } +} diff --git a/src/handlers/getArrowPressHandler.test.ts b/src/handlers/getArrowPressHandler.test.ts index 6afacf4..fa508eb 100644 --- a/src/handlers/getArrowPressHandler.test.ts +++ b/src/handlers/getArrowPressHandler.test.ts @@ -1,6 +1,6 @@ -import { ArrowNavigationState } from '../types' +import { ArrowNavigationState, FocusableGroupConfig } from '../types' import getViewNavigationStateMock from '../__mocks__/viewNavigationState.mock' -import getArrowPressHandler, { ERROR_MESSAGES } from './getArrowPressHandler' +import getArrowPressHandler from './getArrowPressHandler' describe('getArrowPressHandler', () => { let state: ArrowNavigationState @@ -23,51 +23,73 @@ describe('getArrowPressHandler', () => { }) it('should return a function', () => { - const handler = getArrowPressHandler(state, jest.fn()) + const handler = getArrowPressHandler({ + state, + onChangeCurrentElement: jest.fn() + }) expect(typeof handler).toBe('function') }) it('should call the focusNextElement function', () => { const focusNextElement = jest.fn() - const handler = getArrowPressHandler(state, focusNextElement) + const handler = getArrowPressHandler({ + state, + onChangeCurrentElement: focusNextElement + }) const event = new KeyboardEvent('keydown', { key: 'ArrowDown' }) handler(event) expect(focusNextElement).toHaveBeenCalled() }) - it('should log a warn message if not currentElement and elements is empty', () => { + it('should not call the focusNextElement function if not a valid key', () => { const focusNextElement = jest.fn() - state.currentElement = null - state.elements = new Map() - const handler = getArrowPressHandler(state, focusNextElement) + const handler = getArrowPressHandler({ + state, + onChangeCurrentElement: focusNextElement + }) - const event = new KeyboardEvent('keydown', { key: 'ArrowDown' }) + const event = new KeyboardEvent('keydown', { key: 'Enter' }) handler(event) - expect(console.warn).toHaveBeenCalledWith(ERROR_MESSAGES.NO_ELEMENT_FOCUSED) + expect(focusNextElement).not.toHaveBeenCalled() }) - it('should focus a random element on map of elements if currentElement is null and elements is not empty', () => { - const focusNextElement = jest.fn() - state.currentElement = null - state.elements = new Map().set('element-0-0', { - el: document.createElement('button'), - group: 'group-0' + it('should not call the onChange callback if event is repeat when debounce is true', () => { + const focusNextElement = jest.fn(); + (state.groupsConfig.get('group-0') as FocusableGroupConfig).arrowDebounce = true + const handler = getArrowPressHandler({ + state, + onChangeCurrentElement: focusNextElement }) - const handler = getArrowPressHandler(state, focusNextElement) - const event = new KeyboardEvent('keydown', { key: 'ArrowDown' }) + const event = new KeyboardEvent('keydown', { key: 'ArrowDown', repeat: true }) + handler(event) + expect(focusNextElement).not.toHaveBeenCalled() + }) + + it('should call the onChange callback if event is repeat when debounce is false', () => { + const focusNextElement = jest.fn(); + (state.groupsConfig.get('group-0') as FocusableGroupConfig).arrowDebounce = false + const handler = getArrowPressHandler({ + state, + onChangeCurrentElement: focusNextElement + }) + + const event = new KeyboardEvent('keydown', { key: 'ArrowDown', repeat: true }) handler(event) - expect(console.warn).not.toHaveBeenCalledWith(ERROR_MESSAGES.NO_ELEMENT_FOCUSED) expect(focusNextElement).toHaveBeenCalled() }) - it('should not call the focusNextElement function if not a valid key', () => { + it('should call the onChange callback if event is repeat when group config is undefined', () => { const focusNextElement = jest.fn() - const handler = getArrowPressHandler(state, focusNextElement) + state.groupsConfig.delete('group-0') + const handler = getArrowPressHandler({ + state, + onChangeCurrentElement: focusNextElement + }) - const event = new KeyboardEvent('keydown', { key: 'Enter' }) + const event = new KeyboardEvent('keydown', { key: 'ArrowDown', repeat: true }) handler(event) - expect(focusNextElement).not.toHaveBeenCalled() + expect(focusNextElement).toHaveBeenCalled() }) }) diff --git a/src/handlers/getArrowPressHandler.ts b/src/handlers/getArrowPressHandler.ts index cf3dd01..b14d864 100644 --- a/src/handlers/getArrowPressHandler.ts +++ b/src/handlers/getArrowPressHandler.ts @@ -1,43 +1,34 @@ +/* eslint-disable no-param-reassign */ import type { ArrowNavigationState, Direction, FocusableElement } from '@/types' -import getCurrentElement from '@/utils/getCurrentElement' -import focusNextElement from './utils/focusNextElement' +import directionPressHandler from './directionPressHandler' -const keyToDirection: { [x: string]: string } = { +const keyToDirection: { [x: string]: Direction } = { ArrowLeft: 'left', ArrowRight: 'right', ArrowUp: 'up', ArrowDown: 'down' } -export const ERROR_MESSAGES = { - NO_ELEMENT_FOCUSED: 'No element is focused. Check if you have registered any elements' +interface GetArrowPressHandlerProps { + state: ArrowNavigationState + onChangeCurrentElement: (element: FocusableElement, dir: Direction) => void } -export default function getArrowPressHandler ( - state: ArrowNavigationState, - onChangeCurrentElement: (element: FocusableElement, dir: Direction) => void -) { +export default function getArrowPressHandler ({ + state, + onChangeCurrentElement +}: GetArrowPressHandlerProps) { return (event: KeyboardEvent) => { const { key } = event const direction = keyToDirection[key] if (!direction) return - const currentElement = getCurrentElement(state) - if (!currentElement) { - const firstRegisteredElement = state.elements.values().next().value - if (firstRegisteredElement) { - onChangeCurrentElement(firstRegisteredElement, direction as Direction) - } else { - console.warn(ERROR_MESSAGES.NO_ELEMENT_FOCUSED) - } - return - } - - focusNextElement({ - direction, + directionPressHandler({ state, - onChangeCurrentElement + direction, + onChangeCurrentElement, + repeat: event.repeat }) } } diff --git a/src/handlers/globalFocusHandler.test.ts b/src/handlers/globalFocusHandler.test.ts index ab4ef8f..e445d06 100644 --- a/src/handlers/globalFocusHandler.test.ts +++ b/src/handlers/globalFocusHandler.test.ts @@ -1,7 +1,8 @@ +/* eslint-disable no-underscore-dangle */ import getViewNavigationStateMock from '@/__mocks__/viewNavigationState.mock' import getCurrentElement from '@/utils/getCurrentElement' import globalFocusHandler from './globalFocusHandler' -import { ArrowNavigationState, FocusableElement } from '..' +import type { ArrowNavigationState } from '..' describe('globalFocusHandler', () => { let state: ArrowNavigationState @@ -14,7 +15,7 @@ describe('globalFocusHandler', () => { const event = { target: { id: 'another-element' } } as unknown as FocusEvent state.currentElement = 'element-0-0' const focusMock = jest.fn(); - (getCurrentElement(state) as FocusableElement).el.focus = focusMock + (getCurrentElement(state)?._ref as HTMLElement).focus = focusMock globalFocusHandler(state, event, true) expect(state.currentElement).toBe('element-0-0') expect(focusMock).toHaveBeenCalled() @@ -24,7 +25,7 @@ describe('globalFocusHandler', () => { const event = { target: { id: 'element-0-0' } } as unknown as FocusEvent state.currentElement = 'element-0-0' const focusMock = jest.fn(); - (getCurrentElement(state) as FocusableElement).el.focus = focusMock + (getCurrentElement(state)?._ref as HTMLElement).focus = focusMock globalFocusHandler(state, event, true) expect(state.currentElement).toBe('element-0-0') expect(focusMock).not.toHaveBeenCalled() @@ -37,4 +38,15 @@ describe('globalFocusHandler', () => { expect(state.currentElement).toBe('non-existent-current') expect(getCurrentElement(state)).toBe(null) }) + + it('should not focus if the currentElement doesnt exists on DOM', () => { + // For coverage only + const event = { target: { id: 'non-existent' } } as unknown as FocusEvent + state.currentElement = 'element-0-0' + document.getElementById('element-0-0')?.remove() + globalFocusHandler(state, event, true) + expect(state.currentElement).toBe('element-0-0') + expect(getCurrentElement(state)?.id).toBe('element-0-0') + expect((state.adapter.getFocusedNode() as HTMLElement)?.id).not.toBe('element-0-0') + }) }) diff --git a/src/handlers/globalFocusHandler.ts b/src/handlers/globalFocusHandler.ts index 83e03d8..3c4169f 100644 --- a/src/handlers/globalFocusHandler.ts +++ b/src/handlers/globalFocusHandler.ts @@ -1,6 +1,9 @@ import getCurrentElement from '@/utils/getCurrentElement' import { ArrowNavigationState } from '@/types' +/** + * Web Only handler + */ const globalFocusHandler = ( state: ArrowNavigationState, event: FocusEvent, @@ -10,7 +13,8 @@ const globalFocusHandler = ( const currentElement = getCurrentElement(state) if (!currentElement) return if (target && target.id !== currentElement.id) { - currentElement.el.focus({ preventScroll }) + const element = state.adapter.getNodeRef(currentElement) as HTMLElement + element?.focus({ preventScroll }) } } diff --git a/src/handlers/index.ts b/src/handlers/index.ts index af9aa8f..5f030a2 100644 --- a/src/handlers/index.ts +++ b/src/handlers/index.ts @@ -6,3 +6,5 @@ export { default as unregisterElementHandler } from './unregisterElementHandler' export { default as getNextElementHandler } from './getNextElementHandler' export { default as getNextGroupHandler } from './getNextGroupHandler' export { default as globalFocusHandler } from './globalFocusHandler' +export { default as directionPressHandler } from './directionPressHandler' +export { default as resetGroupStateHandler } from './resetGroupStateHandler' diff --git a/src/handlers/registerElementHandler.test.ts b/src/handlers/registerElementHandler.test.ts index 886e22e..fb79f56 100644 --- a/src/handlers/registerElementHandler.test.ts +++ b/src/handlers/registerElementHandler.test.ts @@ -1,32 +1,39 @@ +/* eslint-disable no-underscore-dangle */ import type { ArrowNavigationState, FocusableGroup } from '@/types' import getViewNavigationStateMock from '@/__mocks__/viewNavigationState.mock' import createEventEmitter, { EventEmitter } from '@/utils/createEventEmitter' -import registerElementHandler, { ERROR_MESSAGES } from './registerElementHandler' +import EVENTS from '@/config/events' +import registerElementHandler, { ERROR_MESSAGES, TIMEOUT_TIME_EMIT_ELEMENTS_CHANGED } from './registerElementHandler' describe('registerElementHandler', () => { let state: ArrowNavigationState let emitter: EventEmitter - let onChangeElement: () => void beforeEach(() => { state = getViewNavigationStateMock() emitter = createEventEmitter() - onChangeElement = jest.fn() }) it('should register the element on a new group', () => { - const registerElement = registerElementHandler(state, onChangeElement, emitter.emit) + const registerElement = registerElementHandler({ + state, + emit: emitter.emit + }) const element = document.createElement('button') element.id = 'element-5-0' - registerElement(element, 'group-5') + document.body.appendChild(element) + registerElement(element.id, 'group-5') expect(state.elements.has(element.id)).toBe(true) - expect(state.elements.get(element.id)?.el).toBe(element) + expect(state.elements.get(element.id)?._ref).toBe(element) }) it('should register the element on an existing group', () => { - const registerElement = registerElementHandler(state, onChangeElement, emitter.emit) + const registerElement = registerElementHandler({ + state, + emit: emitter.emit + }) const groupId = 'group-0' const group = state.groups.get(groupId) as FocusableGroup const groupTotalElements = group.elements.size @@ -34,92 +41,107 @@ describe('registerElementHandler', () => { const element = document.createElement('button') element.id = `element-0-${groupTotalElements}` - registerElement(element, groupId) + document.body.appendChild(element) + + registerElement(element.id, groupId) expect(state.elements.has(element.id)).toBe(true) - expect(state.elements.get(element.id)?.el).toBe(element) + expect(state.elements.get(element.id)?._ref).toBe(element) expect(state.groups.get('group-0')?.elements.has(element.id)).toBe(true) }) it('should throw an error if the element id is not defined', () => { - const registerElement = registerElementHandler(state, onChangeElement, emitter.emit) - - const element = document.createElement('button') - expect(() => registerElement(element, 'group-1')).toThrowError(ERROR_MESSAGES.ELEMENT_ID_REQUIRED) + const registerElement = registerElementHandler({ + state, + emit: emitter.emit + }) + expect(() => registerElement(null as unknown as string, 'group-1')).toThrowError(ERROR_MESSAGES.ELEMENT_ID_REQUIRED) }) it('should throw an error if the group id is not defined', () => { - const registerElement = registerElementHandler(state, onChangeElement, emitter.emit) + const registerElement = registerElementHandler({ + state, + emit: emitter.emit + }) const element = document.createElement('button') element.id = 'element-1-0' - expect(() => registerElement(element, '')).toThrowError(ERROR_MESSAGES.GROUP_REQUIRED) + document.body.appendChild(element) + expect(() => registerElement(element.id, '')).toThrowError(ERROR_MESSAGES.GROUP_REQUIRED) }) it('should log a warn message if element id is already registered and not register the element', () => { global.console.warn = jest.fn() - const registerElement = registerElementHandler(state, onChangeElement, emitter.emit) + const registerElement = registerElementHandler({ + state, + emit: emitter.emit + }) const element = document.createElement('button') element.id = 'element-0-0' - registerElement(element, 'group-0') + document.body.appendChild(element) + registerElement(element.id, 'group-0') expect(console.warn).toHaveBeenCalledWith( ERROR_MESSAGES.ELEMENT_ID_ALREADY_REGISTERED(element.id) ) }) - it('should set the element as the current element if current is null', () => { - state.currentElement = null - const registerElement = registerElementHandler(state, onChangeElement, emitter.emit) - - const element = document.createElement('button') - element.id = 'element-5-0' - registerElement(element, 'group-5') - - expect(onChangeElement).toHaveBeenCalledWith({ el: element, group: 'group-5', id: 'element-5-0' }) - }) - it('should throw an error if the element is not focusable', () => { - const registerElement = registerElementHandler(state, onChangeElement, emitter.emit) + const registerElement = registerElementHandler({ + state, + emit: emitter.emit + }) const element = document.createElement('div') element.id = 'element-5-0' - expect(() => registerElement(element, 'group-5')).toThrowError(ERROR_MESSAGES.ELEMENT_NOT_FOCUSABLE(element.id)) + document.body.appendChild(element) + expect(() => registerElement(element.id, 'group-5')).toThrowError(ERROR_MESSAGES.ELEMENT_NOT_FOCUSABLE(element.id)) }) it('should keep the group element if the groups doesnt exists but config exists', () => { - const registerElement = registerElementHandler(state, onChangeElement, emitter.emit) + const registerElement = registerElementHandler({ + state, + emit: emitter.emit + }) const group = document.createElement('div') group.id = 'group-10' state.groupsConfig.set(group.id, { id: group.id, - el: group + _ref: group }) + document.body.appendChild(group) + const element = document.createElement('button') element.id = 'element-10-0' - registerElement(element, 'group-10') + group.appendChild(element) + registerElement(element.id, 'group-10') - expect(state.groups.get('group-10')?.el).toBe(group) + expect(state.groups.get('group-10')?._ref).toBe(group) }) it('should register an element with order', () => { state.groupsConfig.set('group-6', { id: 'group-6', - el: state.groups.get('group-6')?.el as HTMLElement, + _ref: state.groups.get('group-6')?._ref as HTMLElement, byOrder: 'horizontal' }) - const registerElement = registerElementHandler(state, onChangeElement, emitter.emit) + const registerElement = registerElementHandler({ + state, + emit: emitter.emit + }) const element = document.createElement('button') - registerElement(element, 'group-6', { order: 0 }) + element.id = 'element-6-0' + document.body.appendChild(element) + registerElement(element.id, 'group-6', { order: 0 }) expect(state.elements.has('group-6-0')).toBe(true) - expect(state.elements.get(element.id)?.el).toBe(element) - expect(state.elements.get(element.id)?.el.id).toBe('group-6-0') + expect(state.elements.get(element.id)?._ref).toBe(element) + expect(state.elements.get(element.id)?._ref?.id).toBe('group-6-0') expect(state.elements.get(element.id)?.id).toBe('group-6-0') expect(state.groups.get('group-6')?.elements.has('group-6-0')).toBe(true) }) @@ -127,12 +149,69 @@ describe('registerElementHandler', () => { it('should not register an element with order if the group is byOrder but the element doesnt have order', () => { state.groupsConfig.set('group-6', { id: 'group-6', - el: state.groups.get('group-6')?.el as HTMLElement, + _ref: state.groups.get('group-6')?._ref as HTMLElement, byOrder: 'horizontal' }) - const registerElement = registerElementHandler(state, onChangeElement, emitter.emit) + const registerElement = registerElementHandler({ + state, + emit: emitter.emit + }) const element = document.createElement('button') - expect(() => registerElement(element, 'group-6')).toThrowError(ERROR_MESSAGES.ELEMENT_ID_REQUIRED) + expect(() => registerElement(element.id, 'group-6')).toThrowError(ERROR_MESSAGES.ELEMENT_ID_REQUIRED) + }) + + it('should emit the elements register end event', () => { + jest.useFakeTimers() + + const emitMock = jest.fn() + + const registerElement = registerElementHandler({ + state, + emit: emitMock + }) + + const element = document.createElement('button') + element.id = 'element-5-0' + document.body.appendChild(element) + registerElement(element.id, 'group-5') + + // Group 5 is registered and element-5-0 is registered + expect(emitMock).toHaveBeenCalledTimes(2) + expect(emitMock).not.toHaveBeenCalledWith(EVENTS.ELEMENTS_REGISTER_END) + + jest.advanceTimersByTime(TIMEOUT_TIME_EMIT_ELEMENTS_CHANGED) + + expect(emitMock).toHaveBeenCalledWith(EVENTS.ELEMENTS_REGISTER_END) + expect(emitMock).toHaveBeenCalledTimes(3) + + jest.resetAllMocks() + + const element2 = document.createElement('button') + element2.id = 'element-5-1' + document.body.appendChild(element2) + registerElement(element2.id, 'group-5') + + const element3 = document.createElement('button') + element3.id = 'element-5-2' + document.body.appendChild(element3) + registerElement(element3.id, 'group-5') + + expect(emitMock).not.toHaveBeenCalledWith(EVENTS.ELEMENTS_REGISTER_END) + // Group 5 is already registered, but element-5-1 and element-5-2 are not + expect(emitMock).toHaveBeenCalledTimes(2) + + jest.advanceTimersByTime(TIMEOUT_TIME_EMIT_ELEMENTS_CHANGED) + expect(emitMock).toHaveBeenCalledWith(EVENTS.ELEMENTS_REGISTER_END) + expect(emitMock).toHaveBeenCalledTimes(3) + }) + + it('should throw an error if the element not exists at DOM', () => { + const registerElement = registerElementHandler({ + state, + emit: emitter.emit + }) + + expect(() => registerElement('not-exists', 'group-5')).toThrowError(ERROR_MESSAGES.ELEMENT_DOES_NOT_EXIST('not-exists')) }) }) diff --git a/src/handlers/registerElementHandler.ts b/src/handlers/registerElementHandler.ts index 6ffef8e..7977117 100644 --- a/src/handlers/registerElementHandler.ts +++ b/src/handlers/registerElementHandler.ts @@ -1,24 +1,31 @@ +/* eslint-disable no-param-reassign */ import EVENTS from '@/config/events' import type { ArrowNavigationState, FocusableElement, FocusableElementOptions } from '@/types' import type { EventEmitter } from '@/utils/createEventEmitter' import getElementIdByOrder from '@/utils/getElementIdByOrder' -import isElementDisabled from './utils/isElementDisabled' -import isFocusableElement from './utils/isFocusableElement' export const ERROR_MESSAGES = { GROUP_REQUIRED: 'Group is required', ELEMENT_ID_REQUIRED: 'Element ID is required', + ELEMENT_DOES_NOT_EXIST: (id: string) => `Element with id ${id} does not exist. Check if you are not registering an element that does not exist.`, ELEMENT_ID_ALREADY_REGISTERED: (id: string) => `Element with id ${id} is already registered. Check if you are not registering the same element twice. If you are, use the "unregisterElement" method to unregister it first.`, ELEMENT_NOT_FOCUSABLE: (id: string) => `Element with id ${id} is not focusable. Check if you are not registering an element that is not focusable.` } -export default function registerElementHandler ( - state: ArrowNavigationState, - onChangeCurrentElement: (element: FocusableElement) => void, +export const TIMEOUT_TIME_EMIT_ELEMENTS_CHANGED = 500 +let timeout: ReturnType + +interface RegisterElementHandlerProps { + state: ArrowNavigationState emit: EventEmitter['emit'] -) { +} + +export default function registerElementHandler ({ + state, + emit +}: RegisterElementHandlerProps) { return ( - element: HTMLElement, + id: string, group: string, options?: FocusableElementOptions ) => { @@ -32,49 +39,58 @@ export default function registerElementHandler ( const isByOrder = existentGroupConfig?.byOrder && options?.order !== undefined - if (!element.id && !isByOrder) { + const element = state.adapter.getNodeRef({ id }) as HTMLElement + + if (!id && !isByOrder) { throw new Error(ERROR_MESSAGES.ELEMENT_ID_REQUIRED) } - if (!isFocusableElement(element)) { - throw new Error(ERROR_MESSAGES.ELEMENT_NOT_FOCUSABLE(element.id)) + if (!element) { + throw new Error(ERROR_MESSAGES.ELEMENT_DOES_NOT_EXIST(id)) } - if (state.elements.get(element.id)) { - console.warn(ERROR_MESSAGES.ELEMENT_ID_ALREADY_REGISTERED(element.id)) + if (state.elements.get(id)) { + console.warn(ERROR_MESSAGES.ELEMENT_ID_ALREADY_REGISTERED(id)) return } - const id = isByOrder + const finalId = isByOrder ? getElementIdByOrder(group, options.order || 0) - : element.id + : id - element.setAttribute('id', id) + element.setAttribute('id', finalId) - const focusableElement = { - id, - el: element, + const focusableElement: FocusableElement = { + id: finalId, + _ref: element, group, ...options } - state.elements.set(id, focusableElement) + if (!state.adapter.isNodeFocusable(focusableElement)) { + throw new Error(ERROR_MESSAGES.ELEMENT_NOT_FOCUSABLE(element.id)) + } + + clearTimeout(timeout) + + state.elements.set(finalId, focusableElement) emit(EVENTS.ELEMENTS_CHANGED, state.elements) if (!existentGroup) { - const elementsSet = new Set().add(id) + const elementsSet = new Set().add(finalId) state.groups.set(group, { id: group, elements: elementsSet, - el: existentGroupConfig?.el || null as unknown as HTMLElement + // eslint-disable-next-line no-underscore-dangle + _ref: existentGroupConfig?._ref || null as unknown as HTMLElement }) emit(EVENTS.GROUPS_CHANGED, state.groups) } else { - existentGroup.elements.add(id) + existentGroup.elements.add(finalId) } - if (!state.currentElement && !isElementDisabled(focusableElement.el)) { - onChangeCurrentElement(focusableElement) - } + timeout = setTimeout(() => { + emit(EVENTS.ELEMENTS_REGISTER_END) + }, state.registerCooldown || TIMEOUT_TIME_EMIT_ELEMENTS_CHANGED) } } diff --git a/src/handlers/registerGroupHandler.test.ts b/src/handlers/registerGroupHandler.test.ts index 491923b..98e30c3 100644 --- a/src/handlers/registerGroupHandler.test.ts +++ b/src/handlers/registerGroupHandler.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-underscore-dangle */ import createEventEmitter, { EventEmitter } from '@/utils/createEventEmitter' import { ArrowNavigationState, FocusableGroup } from '../types' import getViewNavigationStateMock from '../__mocks__/viewNavigationState.mock' @@ -13,39 +14,56 @@ describe('registerGroupHandler', () => { }) it('should register the group', () => { - const registerGroup = registerGroupHandler(state, emitter.emit) + const registerGroup = registerGroupHandler({ + state, + emit: emitter.emit + }) const group = document.createElement('div') group.id = 'group-5' - registerGroup(group) + document.body.appendChild(group) + registerGroup(group.id) expect(state.groups.has(group.id)).toBe(true) - expect(state.groups.get(group.id)?.el).toBe(group) + expect(state.groups.get(group.id)?._ref).toBe(group) }) it('should throw an error if the group id is not defined', () => { - const registerGroup = registerGroupHandler(state, emitter.emit) + const registerGroup = registerGroupHandler({ + state, + emit: emitter.emit + }) const group = document.createElement('div') - expect(() => registerGroup(group)).toThrowError(ERROR_MESSAGES.GROUP_ID_REQUIRED) + expect(() => registerGroup(group.id)).toThrowError(ERROR_MESSAGES.GROUP_ID_REQUIRED) }) it('if the group is already registered, just changes the group config and keep the elements', () => { - const registerGroup = registerGroupHandler(state, emitter.emit) + state = getViewNavigationStateMock() + const registerGroup = registerGroupHandler({ + state, + emit: emitter.emit + }) const groupId = 'group-0' const group = state.groups.get(groupId) as FocusableGroup const groupTotalElements = group.elements.size - const element = document.createElement('button') - element.id = `element-0-${groupTotalElements}` - expect(state.groupsConfig.get(group.id)?.viewportSafe).toBeUndefined() - registerGroup(group.el, { + registerGroup(groupId, { viewportSafe: true }) expect(state.groupsConfig.get(group.id)?.viewportSafe).toBe(true) expect(state.groups.get(groupId)?.elements.size).toBe(groupTotalElements) }) + + it('should throw an error if the group does not exist at DOM', () => { + const registerGroup = registerGroupHandler({ + state, + emit: emitter.emit + }) + expect(() => registerGroup('non-existent-group')) + .toThrowError(ERROR_MESSAGES.GROUP_DOES_NOT_EXIST('non-existent-group')) + }) }) diff --git a/src/handlers/registerGroupHandler.ts b/src/handlers/registerGroupHandler.ts index 2bca3b0..76109ed 100644 --- a/src/handlers/registerGroupHandler.ts +++ b/src/handlers/registerGroupHandler.ts @@ -8,26 +8,37 @@ const defaultGroupConfig: FocusableGroupOptions = { nextGroupByDirection: undefined, saveLast: false, viewportSafe: true, - threshold: 0 + threshold: 0, + arrowDebounce: true } export const ERROR_MESSAGES = { - GROUP_ID_REQUIRED: 'Group ID is required' + GROUP_ID_REQUIRED: 'Group ID is required', + GROUP_DOES_NOT_EXIST: (id: string) => `Group with id ${id} does not exist. Check if you are not registering a group that does not exist.` } -export default function registerGroupHandler ( - state: ArrowNavigationState, +interface RegisterGroupHandlerProps { + state: ArrowNavigationState emit: EventEmitter['emit'] -) { +} + +export default function registerGroupHandler ({ + state, + emit +}: RegisterGroupHandlerProps) { return ( - element: HTMLElement, + id: string, options?: FocusableGroupOptions ) => { - const id = element.id if (!id) { throw new Error(ERROR_MESSAGES.GROUP_ID_REQUIRED) } + const element = state.adapter.getNodeRef({ id }) as HTMLElement + if (!element) { + throw new Error(ERROR_MESSAGES.GROUP_DOES_NOT_EXIST(id)) + } + const existentGroup = state.groups.get(id) const prevElements: FocusableGroup['elements'] = existentGroup?.elements || new Set() @@ -36,7 +47,7 @@ export default function registerGroupHandler ( state.groups.set(id, { id, elements: prevElements, - el: element + _ref: element }) emit(EVENTS.GROUPS_CHANGED, state.groups) @@ -45,7 +56,7 @@ export default function registerGroupHandler ( ...options, id, lastElement: prevConfig?.lastElement || undefined, - el: element + _ref: element }) emit(EVENTS.GROUPS_CONFIG_CHANGED, state.groupsConfig) } diff --git a/src/handlers/resetGroupStateHandler.test.ts b/src/handlers/resetGroupStateHandler.test.ts new file mode 100644 index 0000000..1cc51a9 --- /dev/null +++ b/src/handlers/resetGroupStateHandler.test.ts @@ -0,0 +1,39 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import getViewNavigationStateMock from '@/__mocks__/viewNavigationState.mock' +import createEventEmitter from '@/utils/createEventEmitter' +import resetGroupStateHandler, { ERROR_MESSAGES } from './resetGroupStateHandler' + +describe('resetGroupStateHandler', () => { + it('should reset the group state', () => { + const state = getViewNavigationStateMock() + const emitter = createEventEmitter() + + const handler = resetGroupStateHandler({ + state, + emit: emitter.emit + }) + + state.groupsConfig.get('group-0')!.saveLast = true + state.groupsConfig.get('group-0')!.lastElement = 'element-0-1' + + expect(state.groupsConfig.get('group-0')!.saveLast).toBe(true) + expect(state.groupsConfig.get('group-0')!.lastElement).toBe('element-0-1') + + handler('group-0') + + expect(state.groupsConfig.get('group-0')!.saveLast).toBe(true) + expect(state.groupsConfig.get('group-0')!.lastElement).toBe(undefined) + }) + + it('should throw an error if the group id is not provided', () => { + const state = getViewNavigationStateMock() + const emitter = createEventEmitter() + + const handler = resetGroupStateHandler({ + state, + emit: emitter.emit + }) + + expect(() => handler(null as unknown as string)).toThrowError(ERROR_MESSAGES.GROUP_ID_REQUIRED) + }) +}) diff --git a/src/handlers/resetGroupStateHandler.ts b/src/handlers/resetGroupStateHandler.ts new file mode 100644 index 0000000..a312659 --- /dev/null +++ b/src/handlers/resetGroupStateHandler.ts @@ -0,0 +1,48 @@ +import EVENTS from '@/config/events' +import type { ArrowNavigationState, FocusableGroupOptions } from '@/types' +import { EventEmitter } from '@/utils/createEventEmitter' + +const defaultGroupConfig: FocusableGroupOptions = { + firstElement: undefined, + lastElement: undefined, + nextGroupByDirection: undefined, + saveLast: false, + viewportSafe: true, + threshold: 0, + arrowDebounce: true +} + +export const ERROR_MESSAGES = { + GROUP_ID_REQUIRED: 'Group ID is required' +} + +interface RegisterGroupHandlerProps { + state: ArrowNavigationState + emit: EventEmitter['emit'] +} + +export default function resetGroupStateHandler ({ + state, + emit +}: RegisterGroupHandlerProps) { + return ( + id: string + ) => { + if (!id) { + throw new Error(ERROR_MESSAGES.GROUP_ID_REQUIRED) + } + + const element = state.adapter.getNodeRef({ id }) as HTMLElement + + const prevConfig = state.groupsConfig.get(id) + + state.groupsConfig.set(id, { + ...defaultGroupConfig, + ...prevConfig, + lastElement: undefined, + id, + _ref: element + }) + emit(EVENTS.GROUPS_CONFIG_CHANGED, state.groupsConfig) + } +} diff --git a/src/handlers/setFocusHandler.test.ts b/src/handlers/setFocusHandler.test.ts index 9ae06cd..8f38b7d 100644 --- a/src/handlers/setFocusHandler.test.ts +++ b/src/handlers/setFocusHandler.test.ts @@ -10,7 +10,10 @@ describe('setFocusHandler', () => { it('should set the focus to the element', () => { const onChange = jest.fn() - const setFocus = setFocusHandler(state, onChange) + const setFocus = setFocusHandler({ + state, + onChangeCurrentElement: onChange + }) setFocus('element-1-0') @@ -19,7 +22,10 @@ describe('setFocusHandler', () => { it('should not set the focus to the element if it is not registered', () => { const onChange = jest.fn() - const setFocus = setFocusHandler(state, onChange) + const setFocus = setFocusHandler({ + state, + onChangeCurrentElement: onChange + }) setFocus('not-registered-element') diff --git a/src/handlers/setFocusHandler.ts b/src/handlers/setFocusHandler.ts index b075bab..0c84c66 100644 --- a/src/handlers/setFocusHandler.ts +++ b/src/handlers/setFocusHandler.ts @@ -1,9 +1,14 @@ import type { ArrowNavigationState, Direction, FocusableElement } from '@/types' -export default function setFocusHandler ( - state: ArrowNavigationState, +interface SetFocusHandlerProps { + state: ArrowNavigationState onChangeCurrentElement: (element: FocusableElement, direction?: Direction) => void -) { +} + +export default function setFocusHandler ({ + state, + onChangeCurrentElement +}: SetFocusHandlerProps) { return (id: string) => { const focusableElement = state.elements.get(id) diff --git a/src/handlers/unregisterElementHandler.test.ts b/src/handlers/unregisterElementHandler.test.ts index 26f1f6d..43167b6 100644 --- a/src/handlers/unregisterElementHandler.test.ts +++ b/src/handlers/unregisterElementHandler.test.ts @@ -13,56 +13,50 @@ describe('unregisterElementHandler', () => { }) it('should unregister the element', () => { - const unregisterElement = unregisterElementHandler(state, jest.fn(), emitter.emit) + const unregisterElement = unregisterElementHandler({ + state, + emit: emitter.emit + }) - const element = document.createElement('div') - element.id = 'element-0-3' - unregisterElement(element) - - expect(state.elements.has(element.id)).toBe(false) - expect(state.groups.get('group-0')?.elements.has(element.id)).toBe(false) - expect(state.groups.get('group-0')?.elements.has('element-0-3')).toBe(false) - }) + const elementId = 'element-0-3' - it('should delete the group if it is empty', () => { - const unregisterElement = unregisterElementHandler(state, jest.fn(), emitter.emit) + expect(state.elements.has(elementId)).toBe(true) - const element = document.createElement('div') - element.id = 'element-4-0' - unregisterElement(element) + unregisterElement(elementId) - expect(state.groups.has('group-4')).toBe(false) + expect(state.elements.has(elementId)).toBe(false) + expect(state.groups.get('group-0')?.elements.has(elementId)).toBe(false) }) - it('should not unregister the element if it is not registered', () => { - const unregisterElement = unregisterElementHandler(state, jest.fn(), emitter.emit) - - const element = document.createElement('div') - element.id = 'not-registered-element' - unregisterElement(element) + it('should delete the group if it is empty', () => { + const unregisterElement = unregisterElementHandler({ + state, + emit: emitter.emit + }) - expect(state.elements.has(element.id)).toBe(false) - }) + const elementId = 'element-4-0' + const groupId = 'group-4' - it('should unregister the element given the element id only', () => { - const unregisterElement = unregisterElementHandler(state, jest.fn(), emitter.emit) + expect(state.groups.has(groupId)).toBe(true) + expect(state.groups.get(groupId)?.elements.has(elementId)).toBe(true) + expect(state.elements.has(elementId)).toBe(true) + expect(state.groups.get(groupId)?.elements.size).toBe(1) - const elementId = 'element-0-3' unregisterElement(elementId) + expect(state.groups.has(groupId)).toBe(false) expect(state.elements.has(elementId)).toBe(false) - expect(state.groups.get('group-0')?.elements.has(elementId)).toBe(false) - expect(state.groups.get('group-0')?.elements.has('element-0-3')).toBe(false) }) - it('should focus the next element if the current element is unregistered', () => { - const onFocusChange = jest.fn() - const unregisterElement = unregisterElementHandler(state, onFocusChange, emitter.emit) + it('should not unregister the element if it is not registered', () => { + const unregisterElement = unregisterElementHandler({ + state, + emit: emitter.emit + }) - const element = document.createElement('div') - element.id = 'element-0-0' - unregisterElement(element) + const elementId = 'not-registered-element' + unregisterElement(elementId) - expect(onFocusChange).toHaveBeenCalledWith(state.elements.get('element-0-1'), undefined) + expect(state.elements.has(elementId)).toBe(false) }) }) diff --git a/src/handlers/unregisterElementHandler.ts b/src/handlers/unregisterElementHandler.ts index 105ec33..3e0bd3b 100644 --- a/src/handlers/unregisterElementHandler.ts +++ b/src/handlers/unregisterElementHandler.ts @@ -1,26 +1,20 @@ import EVENTS from '@/config/events' -import type { ArrowNavigationState, FocusableElement } from '@/types' +import type { ArrowNavigationState } from '@/types' import { EventEmitter } from '@/utils/createEventEmitter' -import focusNextElement from './utils/focusNextElement' -export default function unregisterElementHandler ( - state: ArrowNavigationState, - onChangeCurrentElement: (element: FocusableElement) => void, +interface UnregisterElementHandlerProps { + state: ArrowNavigationState emit: EventEmitter['emit'] -) { - return (element: HTMLElement | string) => { - const elementId = typeof element === 'string' ? element : element.id - const groupId = state.elements.get(elementId)?.group as string - - if (elementId === state.currentElement) { - focusNextElement({ - direction: undefined, - state, - onChangeCurrentElement - }) - } +} + +export default function unregisterElementHandler ({ + state, + emit +}: UnregisterElementHandlerProps) { + return (id: string) => { + const groupId = state.elements.get(id)?.group as string - state.elements.delete(elementId) + state.elements.delete(id) emit(EVENTS.ELEMENTS_CHANGED, state.elements) const focusableGroup = state.groups.get(groupId) @@ -29,11 +23,16 @@ export default function unregisterElementHandler ( return } - focusableGroup.elements.delete(elementId) + focusableGroup.elements.delete(id) if (focusableGroup.elements.size === 0) { state.groups.delete(groupId) emit(EVENTS.GROUPS_CHANGED, state.groups) } + + if (state.currentElement === id) { + // eslint-disable-next-line no-param-reassign + state.currentElement = null + } } } diff --git a/src/handlers/utils/findClosestElementInGroup.test.ts b/src/handlers/utils/findClosestElementInGroup.test.ts index 3df0fad..f9a1d47 100644 --- a/src/handlers/utils/findClosestElementInGroup.test.ts +++ b/src/handlers/utils/findClosestElementInGroup.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-underscore-dangle */ import getCurrentElement from '@/utils/getCurrentElement' import { ArrowNavigationState, FocusableElement, FocusableGroup } from '../../types' import findClosestElementInGroup from './findClosestElementInGroup' @@ -159,7 +160,7 @@ describe('findClosestElementInGroup', () => { it('should return the closest element, but not the disabled element', () => { const group1 = state.groups.get('group-1') as FocusableGroup - state.elements.get('element-1-1')?.el.setAttribute('disabled', '') + state.elements.get('element-1-1')?._ref?.setAttribute('disabled', '') const closesElement = findClosestElementInGroup({ direction: 'right', diff --git a/src/handlers/utils/findClosestElementInGroup.ts b/src/handlers/utils/findClosestElementInGroup.ts index 4f73c81..96ba393 100644 --- a/src/handlers/utils/findClosestElementInGroup.ts +++ b/src/handlers/utils/findClosestElementInGroup.ts @@ -1,7 +1,6 @@ import type { ArrowNavigationState, FocusableElement } from '@/types' import getEuclideanDistance from './getEuclideanDistance' import getReferencePointsByDirection from './getReferencePointsByDirection' -import isElementDisabled from './isElementDisabled' import isEligibleCandidate from './isEligibleCandidate/isEligibleCandidate' interface Result { @@ -32,15 +31,15 @@ export default function findClosestElementInGroup ({ (acc, id) => { const candidate = state.elements.get(id) as FocusableElement if ( - candidate.el === currentFocusElement?.el - || !currentFocusElement?.el - || !candidate.el + candidate.id === currentFocusElement?.id + || !currentFocusElement + || !candidate ) return acc - const currentRect = currentFocusElement.el.getBoundingClientRect() - const candidateRect = candidate.el.getBoundingClientRect() + const currentRect = state.adapter.getNodeRect(currentFocusElement) + const candidateRect = state.adapter.getNodeRect(candidate) - if (isElementDisabled(candidate.el)) return acc + if (state.adapter.isNodeDisabled(candidate)) return acc if (!allValidCandidates && !isEligibleCandidate({ direction, diff --git a/src/handlers/utils/findClosestGroup.test.ts b/src/handlers/utils/findClosestGroup.test.ts index d8e3b15..fb4c683 100644 --- a/src/handlers/utils/findClosestGroup.test.ts +++ b/src/handlers/utils/findClosestGroup.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-underscore-dangle */ import getCurrentElement from '@/utils/getCurrentElement' import findClosestGroup from './findClosestGroup' import { ArrowNavigationState, FocusableElement, FocusableGroup } from '../../types' @@ -69,7 +70,7 @@ describe('findClosestGroup', () => { state.currentElement = 'element-1-0' state.groups.get('group-2')?.elements.forEach(id => { - state.elements.get(id)?.el.setAttribute('disabled', 'true') + state.elements.get(id)?._ref?.setAttribute('disabled', 'true') }) const closestGroup = findClosestGroup({ diff --git a/src/handlers/utils/findClosestGroup.ts b/src/handlers/utils/findClosestGroup.ts index 50fc082..963822b 100644 --- a/src/handlers/utils/findClosestGroup.ts +++ b/src/handlers/utils/findClosestGroup.ts @@ -26,7 +26,7 @@ interface GroupAndElement { export default function findClosestGroup ({ isViewportSafe = false, - threshold = 2, + threshold = 0, candidateGroups, currentElement, direction, @@ -40,15 +40,13 @@ export default function findClosestGroup ({ const currentGroup = candidateGroups.get(currentElement?.group) as FocusableGroup const candidate = candidateGroups.get(candidateKey) as FocusableGroup if ( - candidate.el === currentGroup?.el - || !currentElement?.el + candidate.id === currentGroup?.id || !currentGroup - || !candidate.el ) return acc - const currentElementRect = currentElement.el.getBoundingClientRect() - const currentGroupRect = currentGroup.el.getBoundingClientRect() - const candidateGroupRect = candidate.el.getBoundingClientRect() + const currentElementRect = state.adapter.getNodeRect(currentElement) + const currentGroupRect = state.adapter.getNodeRect(currentGroup) + const candidateGroupRect = state.adapter.getNodeRect(candidate) if (!allValidCandidates && !isEligibleCandidate({ direction, diff --git a/src/handlers/utils/findNextByDirection.test.ts b/src/handlers/utils/findNextByDirection.test.ts index 855b51e..7e5a4ed 100644 --- a/src/handlers/utils/findNextByDirection.test.ts +++ b/src/handlers/utils/findNextByDirection.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-underscore-dangle */ import getViewNavigationStateMock from '@/__mocks__/viewNavigationState.mock' import { ArrowNavigationState, FocusableElement, FocusableGroup, FocusableGroupConfig } from '@/types' import findNextByDirection from './findNextByDirection' @@ -62,7 +63,7 @@ describe('findNextByDirection', () => { it('should return the subsequent element if the next element is disabled', () => { const nextElement = state.elements.get('element-0-1') as FocusableElement const subsequentElement = state.elements.get('element-0-2') as FocusableElement - nextElement.el.setAttribute('disabled', 'true') + nextElement._ref?.setAttribute('disabled', 'true') state.elements.set('element-0-0', { ...state.elements.get('element-0-0') as FocusableElement, nextByDirection: { @@ -89,7 +90,7 @@ describe('findNextByDirection', () => { const nextGroup = state.groups.get('group-1') as FocusableGroup nextGroup.elements.forEach(id => { - state.elements.get(id)?.el.setAttribute('disabled', 'true') + state.elements.get(id)?._ref?.setAttribute('disabled', 'true') }) state.elements.set('element-0-0', { @@ -168,7 +169,7 @@ describe('findNextByDirection', () => { }) nextGroup.elements.forEach(id => { - state.elements.get(id)?.el.setAttribute('disabled', 'true') + state.elements.get(id)?._ref?.setAttribute('disabled', 'true') }) state.groupsConfig.set('group-1', { diff --git a/src/handlers/utils/findNextByDirection.ts b/src/handlers/utils/findNextByDirection.ts index 88614a4..ccd5b8b 100644 --- a/src/handlers/utils/findNextByDirection.ts +++ b/src/handlers/utils/findNextByDirection.ts @@ -1,7 +1,6 @@ import type { ArrowNavigationState, Direction, FocusableElement, FocusableGroupConfig, FocusableWithKind } from '@/types' import findNextGroupElement from './findNextGroupElement' import findNextGroupByDirection from './findNextGroupByDirection' -import isElementDisabled from './isElementDisabled' import getFocusableWithKind from './isFocusableWithKind' import getNextByOrder from './getNextByOrder' @@ -45,7 +44,7 @@ export default function findNextByDirection ({ if (focusableWithKind.kind === 'element') { const nextElement = state.elements.get(focusableWithKind.id as string) if (nextElement) { - if (!isElementDisabled(nextElement.el)) return nextElement + if (!state.adapter.isNodeDisabled(nextElement)) return nextElement return findNextByDirection({ fromElement: nextElement, direction, diff --git a/src/handlers/utils/findNextElement.test.ts b/src/handlers/utils/findNextElement.test.ts index 10d72f1..13c667a 100644 --- a/src/handlers/utils/findNextElement.test.ts +++ b/src/handlers/utils/findNextElement.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-underscore-dangle */ import getCurrentElement from '@/utils/getCurrentElement' import getViewNavigationStateMock from '@/__mocks__/viewNavigationState.mock' import type { ArrowNavigationState, FocusableElement, FocusableGroupConfig } from '@/types' @@ -14,9 +15,9 @@ describe('findNextElement', () => { it('should return the subsequent element if the next element is disabled', () => { const element = state.elements.get('element-0-0') as FocusableElement - state.elements.get('element-0-1')?.el.setAttribute('disabled', 'true') + state.elements.get('element-0-1')?._ref?.setAttribute('disabled', 'true') - element.nextElementByDirection = { + element.nextByDirection = { down: 'element-0-1' } @@ -29,7 +30,7 @@ describe('findNextElement', () => { expect(nextElement).toBe(state.elements.get('element-0-2')) - state.elements.get('element-0-2')?.el.setAttribute('disabled', 'true') + state.elements.get('element-0-2')?._ref?.setAttribute('disabled', 'true') const nextElement2 = findNextElement({ direction: 'down', @@ -47,7 +48,7 @@ describe('findNextElement', () => { state.groups.get('group-0')?.elements.delete('element-0-1') state.elements.delete('element-0-1') - element.nextElementByDirection = { + element.nextByDirection = { down: 'element-0-1' } @@ -115,4 +116,13 @@ describe('findNextElement', () => { }) expect(next).toBe(state.elements.get('element-0-1')) }) + + it('should return the next element from the next group', () => { + const nextElement = findNextElement({ + direction: 'right', + state, + fromElement: state.elements.get('element-0-0') as FocusableElement + }) + expect(nextElement).toBe(state.elements.get('element-1-0')) + }) }) diff --git a/src/handlers/utils/findNextElement.ts b/src/handlers/utils/findNextElement.ts index 733bc3a..d307805 100644 --- a/src/handlers/utils/findNextElement.ts +++ b/src/handlers/utils/findNextElement.ts @@ -1,7 +1,6 @@ import type { ArrowNavigationState, FocusableElement, FocusableGroup } from '@/types' import getCurrentElement from '@/utils/getCurrentElement' import findClosestElementInGroup from './findClosestElementInGroup' -import findNextElementByDirection from './findNextElementByDirection' import findNextGroup from './findNextGroup' import findNextByDirection from './findNextByDirection' @@ -32,18 +31,6 @@ export default function findNextElement ({ state }) if (nextElement === null) return null - } else if (selectedElement?.nextElementByDirection) { - /** - * If the current element has a nextElementByDirection property, we use it - * to find the next element. - * This will be removed in the next major version. - */ - nextElement = findNextElementByDirection({ - fromElement: selectedElement, - direction, - state - }) - if (nextElement === null) return null } if (!nextElement) { diff --git a/src/handlers/utils/findNextElementByDirection.ts b/src/handlers/utils/findNextElementByDirection.ts deleted file mode 100644 index b9da3e1..0000000 --- a/src/handlers/utils/findNextElementByDirection.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { ArrowNavigationState, Direction, FocusableElement } from '@/types' -import isElementDisabled from './isElementDisabled' - -interface Props { - direction: string | undefined - state: ArrowNavigationState - fromElement: FocusableElement -} - -/** - * @deprecated - * Use findNextByDirection instead - */ -export default function findNextElementByDirection ({ - fromElement, - direction, - state -}: Props): FocusableElement | null | undefined { - let nextElement: FocusableElement | null | undefined = null - const nextElementId = fromElement.nextElementByDirection?.[direction as Direction] - - if (nextElementId === null) return null - - nextElement = state.elements.get(nextElementId as string) - - if (nextElement && isElementDisabled(nextElement.el)) { - return findNextElementByDirection({ - fromElement: nextElement, - direction, - state - }) - } - - return nextElement -} diff --git a/src/handlers/utils/findNextGroupByDirection.test.ts b/src/handlers/utils/findNextGroupByDirection.test.ts index 55d3210..7fdb5ff 100644 --- a/src/handlers/utils/findNextGroupByDirection.test.ts +++ b/src/handlers/utils/findNextGroupByDirection.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-underscore-dangle */ import { ArrowNavigationState, FocusableGroup, FocusableGroupConfig } from '@/types' import getViewNavigationStateMock from '@/__mocks__/viewNavigationState.mock' import findNextGroupByDirection from './findNextGroupByDirection' @@ -10,6 +11,10 @@ describe('findNextGroupByDirection', () => { window.innerHeight = 52 }) + afterEach(() => { + document.body.innerHTML = '' + }) + it('should return the next group in the direction', () => { const group0Config = state.groupsConfig.get('group-0') as FocusableGroupConfig const group1 = state.groups.get('group-1') as FocusableGroup @@ -33,7 +38,7 @@ describe('findNextGroupByDirection', () => { const group1 = state.groups.get('group-1') as FocusableGroup group1.elements.forEach(id => { - state.elements.get(id)?.el.setAttribute('disabled', 'true') + state.elements.get(id)?._ref?.setAttribute('disabled', 'true') }) group0Config.nextGroupByDirection = { diff --git a/src/handlers/utils/findNextGroupElement.test.ts b/src/handlers/utils/findNextGroupElement.test.ts index 3979d44..9f20e85 100644 --- a/src/handlers/utils/findNextGroupElement.test.ts +++ b/src/handlers/utils/findNextGroupElement.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-underscore-dangle */ import getViewNavigationStateMock from '../../__mocks__/viewNavigationState.mock' import { ArrowNavigationState, FocusableElement, FocusableGroup, FocusableGroupConfig } from '../../types' import findNextGroupElement from './findNextGroupElement' @@ -25,7 +26,7 @@ describe('findNextGroupElement', () => { const nextGroup = state.groups.get('group-2') as FocusableGroup state.groupsConfig.set('group-0', { id: 'group-0', - el: state.groups.get('group-0')?.el as HTMLElement, + _ref: state.groups.get('group-0')?._ref as HTMLElement, nextGroupByDirection: { right: nextGroup.id } @@ -46,7 +47,7 @@ describe('findNextGroupElement', () => { const nextGroup = state.groups.get('group-1') as FocusableGroup state.groupsConfig.set('group-1', { id: 'group-1', - el: nextGroup.el as HTMLElement, + _ref: nextGroup._ref as HTMLElement, firstElement: 'element-1-2' }) const nextElement = state.elements.get('element-1-2') @@ -66,10 +67,10 @@ describe('findNextGroupElement', () => { const nextGroup = state.groups.get('group-1') as FocusableGroup state.groupsConfig.set('group-1', { id: 'group-1', - el: nextGroup.el as HTMLElement, + _ref: nextGroup._ref as HTMLElement, firstElement: 'element-1-0' }) - state.elements.get('element-1-0')?.el.setAttribute('disabled', 'true') + state.elements.get('element-1-0')?._ref?.setAttribute('disabled', 'true') const nextElement = state.elements.get('element-1-1') const fromElement = state.elements.get('element-0-0') as FocusableElement @@ -87,11 +88,11 @@ describe('findNextGroupElement', () => { const nextGroup = state.groups.get('group-1') as FocusableGroup state.groupsConfig.set('group-1', { id: 'group-1', - el: nextGroup.el as HTMLElement, + _ref: nextGroup._ref as HTMLElement, firstElement: 'element-1-0' }) - state.elements.get('element-1-0')?.el.setAttribute('disabled', 'true') - state.elements.get('element-1-1')?.el.setAttribute('disabled', 'true') + state.elements.get('element-1-0')?._ref?.setAttribute('disabled', 'true') + state.elements.get('element-1-1')?._ref?.setAttribute('disabled', 'true') const nextElement = state.elements.get('element-1-2') const fromElement = state.elements.get('element-0-0') as FocusableElement @@ -109,13 +110,13 @@ describe('findNextGroupElement', () => { const nextGroup = state.groups.get('group-1') as FocusableGroup state.groupsConfig.set('group-1', { id: 'group-1', - el: nextGroup.el as HTMLElement, + _ref: nextGroup._ref as HTMLElement, firstElement: 'element-1-0' }); - (state.elements.get('element-1-0') as FocusableElement).nextElementByDirection = { + (state.elements.get('element-1-0') as FocusableElement).nextByDirection = { right: null } - state.elements.get('element-1-0')?.el.setAttribute('disabled', 'true') + state.elements.get('element-1-0')?._ref?.setAttribute('disabled', 'true') const fromElement = state.elements.get('element-0-0') as FocusableElement const element = findNextGroupElement({ @@ -148,7 +149,7 @@ describe('findNextGroupElement', () => { it('should return null if the manual setted next group is null', () => { state.groupsConfig.set('group-0', { id: 'group-0', - el: state.groups.get('group-0')?.el as HTMLElement, + _ref: state.groups.get('group-0')?._ref as HTMLElement, nextGroupByDirection: { right: null } diff --git a/src/handlers/utils/findNextGroupElement.ts b/src/handlers/utils/findNextGroupElement.ts index f12881a..5f14c15 100644 --- a/src/handlers/utils/findNextGroupElement.ts +++ b/src/handlers/utils/findNextGroupElement.ts @@ -1,7 +1,6 @@ import type { ArrowNavigationState, FocusableElement, FocusableGroup } from '@/types' import findClosestElementInGroup from './findClosestElementInGroup' -import isElementDisabled from './isElementDisabled' -import findNextElementByDirection from './findNextElementByDirection' +import findNextByDirection from './findNextByDirection' interface Props { fromElement: FocusableElement @@ -26,8 +25,8 @@ export default function findNextGroupElement ({ if (firstElement) { nextElement = state.elements.get(firstElement) as FocusableElement if (nextElement) { - if (isElementDisabled(nextElement.el)) { - nextElement = findNextElementByDirection({ + if (state.adapter.isNodeDisabled(nextElement)) { + nextElement = findNextByDirection({ fromElement: nextElement, direction, state diff --git a/src/handlers/utils/focusNextElement.test.ts b/src/handlers/utils/focusNextElement.test.ts deleted file mode 100644 index 0b66287..0000000 --- a/src/handlers/utils/focusNextElement.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { ArrowNavigationState, FocusableElement, FocusableGroupConfig } from '@/types' -import getCurrentElement from '@/utils/getCurrentElement' -import getViewNavigationStateMock from '../../__mocks__/viewNavigationState.mock' -import focusNextElement from './focusNextElement' - -describe('focusNextElement', () => { - let state: ArrowNavigationState - - beforeEach(() => { - state = getViewNavigationStateMock() - window.innerWidth = 50 - window.innerHeight = 50 - }) - - it('should focus the next element', () => { - state.currentElement = 'element-0-0' - - const onFocusChange = jest.fn(element => { - state.currentElement = element.id - }) - - focusNextElement({ direction: 'down', state, onChangeCurrentElement: onFocusChange }) - - expect(state.currentElement).toBe('element-0-1') - expect(onFocusChange).toHaveBeenCalledWith(state.elements.get('element-0-1'), 'down') - }) - - it('should focus the next element with manual next element', () => { - const currentElement = state.elements.get('element-0-0') as FocusableElement - currentElement.nextElementByDirection = { - down: 'element-0-2' - } - state.currentElement = 'element-0-0' - - const onFocusChange = jest.fn(element => { - state.currentElement = element.id - }) - - focusNextElement({ direction: 'down', state, onChangeCurrentElement: onFocusChange }) - - expect(state.currentElement).toBe('element-0-2') - expect(onFocusChange).toHaveBeenCalledWith(state.elements.get('element-0-2'), 'down') - }) - - it('should focus nothing with manual null', () => { - (getCurrentElement(state) as FocusableElement).nextElementByDirection = { - down: null - } - - const onFocusChange = jest.fn() - - focusNextElement({ direction: 'down', state, onChangeCurrentElement: onFocusChange }) - - expect(onFocusChange).not.toHaveBeenCalled() - }) - - it('should focus the next group if arent candidates on current group for given direction', () => { - state.currentElement = 'element-0-0' - - const onFocusChange = jest.fn(element => { - state.currentElement = element - }) - - focusNextElement({ direction: 'right', state, onChangeCurrentElement: onFocusChange }) - - expect(state.currentElement).toBe(state.elements.get('element-1-0')) - expect(onFocusChange).toHaveBeenCalledWith(state.elements.get('element-1-0'), 'right') - }) - - it('should works normally if there no current group config', () => { - state.currentElement = 'element-0-0' - state.groupsConfig.delete('group-0') - - const onFocusChange = jest.fn(element => { - state.currentElement = element.id - }) - - focusNextElement({ direction: 'down', state, onChangeCurrentElement: onFocusChange }) - - expect(state.currentElement).toBe('element-0-1') - expect(onFocusChange).toHaveBeenCalledWith(state.elements.get('element-0-1'), 'down') - }) - - it('should keep the focus on group if keepFocus is true on currentGroup', () => { - const currentGroupConfig = state.groupsConfig.get('group-0') as FocusableGroupConfig - currentGroupConfig.keepFocus = true - state.currentElement = 'element-0-0' - - const onFocusChange = jest.fn(element => { - state.currentElement = element - }) - - focusNextElement({ - direction: 'right', - state, - onChangeCurrentElement: onFocusChange - }) - - expect(state.currentElement).toBe('element-0-0') - expect(onFocusChange).not.toHaveBeenCalled() - }) -}) diff --git a/src/handlers/utils/focusNextElement.ts b/src/handlers/utils/focusNextElement.ts deleted file mode 100644 index c1f70f1..0000000 --- a/src/handlers/utils/focusNextElement.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { ArrowNavigationState, Direction, FocusableElement } from '@/types' -import findNextElement from './findNextElement' - -interface Props { - direction: string | undefined - state: ArrowNavigationState - onChangeCurrentElement: (element: FocusableElement, dir: Direction) => void -} - -export default function focusNextElement ({ - direction, - state, - onChangeCurrentElement -}: Props) { - const nextElement = findNextElement({ direction, state }) - - if (nextElement) { - onChangeCurrentElement(nextElement, direction as Direction) - } -} diff --git a/src/handlers/utils/getAxisCenter.ts b/src/handlers/utils/getAxisCenter.ts index aea5c69..03ddead 100644 --- a/src/handlers/utils/getAxisCenter.ts +++ b/src/handlers/utils/getAxisCenter.ts @@ -1,4 +1,6 @@ -export default function getAxisCenter (rect: DOMRect) { +import type { Rect } from '@/types' + +export default function getAxisCenter (rect: Rect) { return { x: (rect.left + rect.right) / 2, y: (rect.top + rect.bottom) / 2 diff --git a/src/handlers/utils/getReferencePointsByCenter.ts b/src/handlers/utils/getReferencePointsByCenter.ts index 20ddb61..33247cc 100644 --- a/src/handlers/utils/getReferencePointsByCenter.ts +++ b/src/handlers/utils/getReferencePointsByCenter.ts @@ -1,8 +1,9 @@ +import type { Rect } from '@/types' import getAxisCenter from './getAxisCenter' export default function getReferencePointsByCenter ( - currentRect: DOMRect, - candidateRect: DOMRect + currentRect: Rect, + candidateRect: Rect ) { const candidateCenter = getAxisCenter(candidateRect) const currentCenter = getAxisCenter(currentRect) diff --git a/src/handlers/utils/getReferencePointsByDirection.ts b/src/handlers/utils/getReferencePointsByDirection.ts index c2dc588..622ffb6 100644 --- a/src/handlers/utils/getReferencePointsByDirection.ts +++ b/src/handlers/utils/getReferencePointsByDirection.ts @@ -1,9 +1,10 @@ +import type { Rect } from '@/types' import getAxisCenter from './getAxisCenter' export default function getReferencePointsByDirection ( direction: string | undefined, - currentRect: DOMRect, - candidateRect: DOMRect + currentRect: Rect, + candidateRect: Rect ) { const currentCenter = getAxisCenter(currentRect) const candidateCenter = getAxisCenter(candidateRect) diff --git a/src/handlers/utils/index.ts b/src/handlers/utils/index.ts index 54ffaf4..939f679 100644 --- a/src/handlers/utils/index.ts +++ b/src/handlers/utils/index.ts @@ -5,6 +5,5 @@ export { default as getReferencePointsByDirection } from './getReferencePointsBy export { default as isEligibleCandidate } from './isEligibleCandidate/isEligibleCandidate' export { default as findClosestElementInGroup } from './findClosestElementInGroup' export { default as findClosestGroup } from './findClosestGroup' -export { default as isFocusableElement } from './isFocusableElement' export { default as findNextElement } from './findNextElement' export { default as findNextGroup } from './findNextGroup' diff --git a/src/handlers/utils/isElementDisabled.test.ts b/src/handlers/utils/isElementDisabled.test.ts deleted file mode 100644 index 9f4f87c..0000000 --- a/src/handlers/utils/isElementDisabled.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import isElementDisabled from './isElementDisabled' - -test('returns true if the element is disabled', () => { - const element = document.createElement('button') - element.setAttribute('disabled', '') - - expect(isElementDisabled(element)).toBe(true) - - element.removeAttribute('disabled') - - expect(isElementDisabled(element)).toBe(false) -}) diff --git a/src/handlers/utils/isElementDisabled.ts b/src/handlers/utils/isElementDisabled.ts deleted file mode 100644 index 7ce1e93..0000000 --- a/src/handlers/utils/isElementDisabled.ts +++ /dev/null @@ -1,3 +0,0 @@ -export default function isElementDisabled (element: HTMLElement): boolean { - return element.getAttribute('disabled') !== null -} diff --git a/src/handlers/utils/isEligibleCandidate/isEligibleCandidate.ts b/src/handlers/utils/isEligibleCandidate/isEligibleCandidate.ts index 088eff2..d5ce7c5 100644 --- a/src/handlers/utils/isEligibleCandidate/isEligibleCandidate.ts +++ b/src/handlers/utils/isEligibleCandidate/isEligibleCandidate.ts @@ -1,10 +1,11 @@ +import type { Rect } from '@/types' import isElementInDirection from './utils/isElementInDirection' import isElementPartiallyInViewport from './utils/isElementPartiallyInViewport' interface Props { direction?: string - currentRect: DOMRect - candidateRect: DOMRect + currentRect: Rect + candidateRect: Rect isViewportSafe?: boolean threshold?: number } diff --git a/src/handlers/utils/isEligibleCandidate/utils/isElementInDirection.ts b/src/handlers/utils/isEligibleCandidate/utils/isElementInDirection.ts index 0a4c6d1..d071e80 100644 --- a/src/handlers/utils/isEligibleCandidate/utils/isElementInDirection.ts +++ b/src/handlers/utils/isEligibleCandidate/utils/isElementInDirection.ts @@ -1,9 +1,10 @@ +import type { Rect } from '@/types' import { isXAxisIntersecting, isYAxisIntersecting } from './isIntersecting' interface Props { direction: string - currentRect: DOMRect - candidateRect: DOMRect + currentRect: Rect + candidateRect: Rect threshold?: number } diff --git a/src/handlers/utils/isEligibleCandidate/utils/isElementPartiallyInViewport.ts b/src/handlers/utils/isEligibleCandidate/utils/isElementPartiallyInViewport.ts index ad1393f..8cc8047 100644 --- a/src/handlers/utils/isEligibleCandidate/utils/isElementPartiallyInViewport.ts +++ b/src/handlers/utils/isEligibleCandidate/utils/isElementPartiallyInViewport.ts @@ -1,5 +1,7 @@ +import type { Rect } from '@/types' + export default function isElementPartiallyInViewport ( - elementRect: DOMRect, + elementRect: Rect, viewport: { innerWidth: number, innerHeight: number } ): boolean { const isHorizontalOverlap = ( diff --git a/src/handlers/utils/isEligibleCandidate/utils/isIntersecting.ts b/src/handlers/utils/isEligibleCandidate/utils/isIntersecting.ts index 3f708de..daf2012 100644 --- a/src/handlers/utils/isEligibleCandidate/utils/isIntersecting.ts +++ b/src/handlers/utils/isEligibleCandidate/utils/isIntersecting.ts @@ -1,6 +1,8 @@ +import type { Rect } from '@/types' + export const isYAxisIntersecting = ( - currentRect: DOMRect, - candidateRect: DOMRect, + currentRect: Rect, + candidateRect: Rect, threshold = 0 ): boolean => ( currentRect.left - threshold <= candidateRect.right @@ -8,8 +10,8 @@ export const isYAxisIntersecting = ( ) export const isXAxisIntersecting = ( - currentRect: DOMRect, - candidateRect: DOMRect, + currentRect: Rect, + candidateRect: Rect, threshold = 0 ): boolean => ( currentRect.top - threshold <= candidateRect.bottom diff --git a/src/handlers/utils/isFocusableElement.test.ts b/src/handlers/utils/isFocusableElement.test.ts deleted file mode 100644 index f1316f3..0000000 --- a/src/handlers/utils/isFocusableElement.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import isFocusableElement from './isFocusableElement' - -describe('isFocusableElement', () => { - it('should return true if the element is focusable', () => { - const element = document.createElement('button') - document.body.appendChild(element) - expect(typeof element.focus).toBe('function') - expect(isFocusableElement(element)).toBe(true) - element.focus() - expect(document.activeElement).toBe(element) - - const element2 = document.createElement('a') - element2.href = 'https://www.google.com' - document.body.appendChild(element2) - expect(typeof element2.focus).toBe('function') - expect(isFocusableElement(element2)).toBe(true) - element2.focus() - expect(document.activeElement).toBe(element2) - - const element3 = document.createElement('input') - document.body.appendChild(element3) - expect(typeof element3.focus).toBe('function') - expect(isFocusableElement(element3)).toBe(true) - element3.focus() - expect(document.activeElement).toBe(element3) - - const element4 = document.createElement('select') - document.body.appendChild(element4) - expect(typeof element4.focus).toBe('function') - expect(isFocusableElement(element4)).toBe(true) - element4.focus() - expect(document.activeElement).toBe(element4) - - const element5 = document.createElement('textarea') - document.body.appendChild(element5) - expect(typeof element5.focus).toBe('function') - expect(isFocusableElement(element5)).toBe(true) - element5.focus() - expect(document.activeElement).toBe(element5) - - const element6 = document.createElement('div') - document.body.appendChild(element6) - element6.setAttribute('tabindex', '0') - expect(typeof element6.focus).toBe('function') - expect(isFocusableElement(element6)).toBe(true) - element6.focus() - expect(document.activeElement).toBe(element6) - - const element7 = document.createElement('div') - document.body.appendChild(element7) - element7.setAttribute('contenteditable', 'true') - expect(typeof element7.focus).toBe('function') - expect(isFocusableElement(element7)).toBe(true) - element7.focus() - expect(document.activeElement).toBe(element7) - - const element8 = document.createElement('div') - document.body.appendChild(element8) - expect(typeof element8.focus).toBe('function') - expect(isFocusableElement(element8)).toBe(false) - element8.focus() - expect(document.activeElement).not.toBe(element8) - expect(document.activeElement).toBe(element7) - }) -}) diff --git a/src/handlers/utils/isFocusableElement.ts b/src/handlers/utils/isFocusableElement.ts deleted file mode 100644 index 0fa635c..0000000 --- a/src/handlers/utils/isFocusableElement.ts +++ /dev/null @@ -1,4 +0,0 @@ -export default function isFocusableElement (element: HTMLElement) { - const focusableSelector = 'input, select, textarea, button, a[href], [tabindex], [contenteditable]' - return element.matches(focusableSelector) -} diff --git a/src/index.ts b/src/index.ts index 2fda68f..859b1de 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,5 +17,11 @@ export type { FocusableElementOptions, FocusableGroupOptions, FocusableWithKind, - FocusableByDirection + FocusableByDirection, + FocusNodeOptions, + Adapter, + Focusable, + Rect, + FocusEventResult, + BlurEventResult } from './types' diff --git a/src/types.ts b/src/types.ts index f92377f..34945c2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,4 @@ +import type { TextInput, TouchableHighlight, TouchableOpacity, View } from 'react-native' import type { EventEmitter } from './utils/createEventEmitter' export type Direction = 'up' | 'down' | 'left' | 'right' @@ -30,26 +31,26 @@ type EventResult = { direction: Direction | undefined | null } -type FocusEventResult = EventResult & { +export type FocusEventResult = EventResult & { prev: T | undefined | null } -type BlurEventResult = EventResult & { +export type BlurEventResult = EventResult & { next: T | undefined | null } export type Focusable = { id: string - el: HTMLElement + /** + * @deprecated + * For test purposes only. + */ + _ref?: HTMLElement } export type FocusableElement = Focusable & { + instance?: TextInput | TouchableHighlight | TouchableOpacity group: string - /** - * @deprecated - * Use nextByDirection instead. This property will be removed in the next major version. - */ - nextElementByDirection?: ElementByDirection /** * If group is setted byOrder, the order will be used to find the next element. * nextByDirection will be overrided by this property. @@ -60,13 +61,14 @@ export type FocusableElement = Focusable & { onBlur?: (result: BlurEventResult) => void } -export type FocusableElementOptions = Omit +export type FocusableElementOptions = Omit export type FocusableGroup = Focusable & { elements: Set } export type FocusableGroupConfig = Focusable & { + instance?: View firstElement?: string lastElement?: string nextGroupByDirection?: ElementByDirection @@ -78,9 +80,35 @@ export type FocusableGroupConfig = Focusable & { onFocus?: (result: FocusEventResult) => void onBlur?: (result: BlurEventResult) => void keepFocus?: boolean + arrowDebounce?: boolean } -export type FocusableGroupOptions = Omit +export type FocusableGroupOptions = Omit + +export type Rect = { + x: number + y: number + width: number + height: number + left: number + top: number + right: number + bottom: number +} + +export type FocusNodeOptions = { + preventScroll?: boolean +} + +export type Adapter = { + type: 'web' | 'react-native' + getNodeRect: (focusable: FocusableElement | FocusableGroupConfig) => Rect + isNodeDisabled: (focusable: FocusableElement) => boolean + focusNode: (focusable: FocusableElement, opts?: FocusNodeOptions) => void + isNodeFocusable: (focusable: FocusableElement) => boolean + getNodeRef: (focusable: Focusable) => unknown, + getFocusedNode: () => unknown +} export type ArrowNavigationState = { currentElement: string | null, @@ -88,12 +116,19 @@ export type ArrowNavigationState = { groups: Map elements: Map debug?: boolean + readonly adapter: Adapter + initialFocusElement?: string + readonly registerCooldown?: number } export type ArrowNavigationOptions = { debug?: boolean errorOnReinit?: boolean preventScroll?: boolean + adapter?: Adapter + disableWebListeners?: boolean + initialFocusElement?: string + registerCooldown?: number } export type GetNextOptions = { @@ -117,10 +152,12 @@ export type ChangeFocusEventHandlerOptions = { export type ArrowNavigationInstance = { getFocusedElement: () => FocusableElement | null - setFocusElement: (id: string, group: string) => void - registerGroup: (element: HTMLElement, options?: FocusableGroupOptions) => void - registerElement: (element: HTMLElement, group: string, options?: FocusableElementOptions) => void - unregisterElement: (element: string | HTMLElement) => void + setFocusElement: (id: string) => void + setInitialFocusElement: (id: string) => void + registerGroup: (id: string, options?: FocusableGroupOptions) => void + registerElement: (id: string, groupId: string, options?: FocusableElementOptions) => void + unregisterElement: (id: string) => void + resetGroupState: (id: string) => void destroy: () => void getCurrentGroups: () => Set getGroupElements: (group: string) => Set @@ -129,6 +166,7 @@ export type ArrowNavigationInstance = { getFocusedGroup: () => string | undefined getNextElement: (opts: GetNextElementOptions) => string | null getNextGroup: (opts: GetNextGroupOptions) => string | null + handleDirectionPress: (direction: Direction, repeat: boolean) => void on: EventEmitter['on'] off: EventEmitter['off'] /** diff --git a/src/utils/getInitialArrowNavigationState.ts b/src/utils/getInitialArrowNavigationState.ts new file mode 100644 index 0000000..ecbdfc1 --- /dev/null +++ b/src/utils/getInitialArrowNavigationState.ts @@ -0,0 +1,27 @@ +import type { Adapter, ArrowNavigationState } from '@/types' +import webAdapter from './webAdapter' + +interface ArrowNavigationStateProps { + debug?: boolean + adapter?: Adapter + initialFocusElement?: string + registerCooldown?: number +} + +export default function getInitialArrowNavigationState ({ + debug, + adapter = webAdapter, + initialFocusElement, + registerCooldown +}: ArrowNavigationStateProps): ArrowNavigationState { + return { + currentElement: null, + groups: new Map(), + groupsConfig: new Map(), + elements: new Map(), + debug, + adapter, + initialFocusElement, + registerCooldown + } +} diff --git a/src/utils/webAdapter/focusNode.test.ts b/src/utils/webAdapter/focusNode.test.ts new file mode 100644 index 0000000..626ed13 --- /dev/null +++ b/src/utils/webAdapter/focusNode.test.ts @@ -0,0 +1,28 @@ +import type { FocusableElement } from '@/types' +import focusNode from './focusNode' + +describe('focusNode', () => { + it('should focus the element', () => { + const element = document.createElement('div') + element.id = 'element-0' + document.body.appendChild(element) + element.focus = jest.fn() + + const focusable = { id: 'element-0' } as FocusableElement + + focusNode(focusable) + + expect(element.focus).toBeCalledTimes(1) + + focusNode(focusable, { preventScroll: true }) + + expect(element.focus).toBeCalledTimes(2) + expect(element.focus).toBeCalledWith({ preventScroll: true }) + + document.body.removeChild(element) + + focusNode(focusable) + + expect(element.focus).toBeCalledTimes(2) + }) +}) diff --git a/src/utils/webAdapter/focusNode.ts b/src/utils/webAdapter/focusNode.ts new file mode 100644 index 0000000..f7ce838 --- /dev/null +++ b/src/utils/webAdapter/focusNode.ts @@ -0,0 +1,6 @@ +import { FocusNodeOptions, FocusableElement } from '@/types' + +export default function focusNode (focusable: FocusableElement, opts?: FocusNodeOptions) { + const element = document.getElementById(focusable.id) + element?.focus({ preventScroll: opts?.preventScroll }) +} diff --git a/src/utils/webAdapter/getFocusedNode.test.ts b/src/utils/webAdapter/getFocusedNode.test.ts new file mode 100644 index 0000000..9633270 --- /dev/null +++ b/src/utils/webAdapter/getFocusedNode.test.ts @@ -0,0 +1,14 @@ +import getFocusedNode from './getFocusedNode' + +describe('getFocusedNode', () => { + it('should return the focused element', () => { + const element = document.createElement('button') + element.id = 'element-0' + document.body.appendChild(element) + element.focus() + + expect(getFocusedNode()).toBe(element) + + document.body.removeChild(element) + }) +}) diff --git a/src/utils/webAdapter/getFocusedNode.ts b/src/utils/webAdapter/getFocusedNode.ts new file mode 100644 index 0000000..b582000 --- /dev/null +++ b/src/utils/webAdapter/getFocusedNode.ts @@ -0,0 +1,3 @@ +export default function getFocusedNode () { + return document.activeElement +} diff --git a/src/utils/webAdapter/getNodeRect.test.ts b/src/utils/webAdapter/getNodeRect.test.ts new file mode 100644 index 0000000..790bfde --- /dev/null +++ b/src/utils/webAdapter/getNodeRect.test.ts @@ -0,0 +1,43 @@ +import { Focusable } from '@/types' +import getNodeRect from './getNodeRect' + +describe('getNodeRect', () => { + it('should return the element rect', () => { + const element = document.createElement('div') + element.id = 'element-0' + const settedRect = { + x: 20, + y: 10, + width: 100, + height: 200, + top: 10, + right: 120, + bottom: 210, + left: 20 + } + element.getBoundingClientRect = jest.fn(() => settedRect as DOMRect) + + document.body.appendChild(element) + + const focusable = { id: 'element-0' } as Focusable + + const rect = getNodeRect(focusable) + + expect(rect).toEqual(settedRect) + + document.body.removeChild(element) + + const rect2 = getNodeRect(focusable) + + expect(rect2).toEqual({ + x: 0, + y: 0, + width: 0, + height: 0, + top: 0, + right: 0, + bottom: 0, + left: 0 + }) + }) +}) diff --git a/src/utils/webAdapter/getNodeRect.ts b/src/utils/webAdapter/getNodeRect.ts new file mode 100644 index 0000000..b683da3 --- /dev/null +++ b/src/utils/webAdapter/getNodeRect.ts @@ -0,0 +1,7 @@ +import { FocusableElement, FocusableGroupConfig, Rect } from '@/types' + +export default function getNodeRect (focusable: FocusableElement | FocusableGroupConfig): Rect { + const element = document.getElementById(focusable.id) + const rect = element?.getBoundingClientRect() + return rect || { x: 0, y: 0, width: 0, height: 0, top: 0, right: 0, bottom: 0, left: 0 } +} diff --git a/src/utils/webAdapter/getNodeRef.test.ts b/src/utils/webAdapter/getNodeRef.test.ts new file mode 100644 index 0000000..baf7900 --- /dev/null +++ b/src/utils/webAdapter/getNodeRef.test.ts @@ -0,0 +1,18 @@ +import { FocusableElement } from '@/types' +import getNodeRef from './getNodeRef' + +describe('getNodeRef', () => { + it('should return the element', () => { + const element = document.createElement('div') + element.id = 'element-0' + document.body.appendChild(element) + + const focusable = { id: 'element-0' } as FocusableElement + + expect(getNodeRef(focusable)).toBe(element) + + document.body.removeChild(element) + + expect(getNodeRef(focusable)).toBe(null) + }) +}) diff --git a/src/utils/webAdapter/getNodeRef.ts b/src/utils/webAdapter/getNodeRef.ts new file mode 100644 index 0000000..2e8df88 --- /dev/null +++ b/src/utils/webAdapter/getNodeRef.ts @@ -0,0 +1,6 @@ +import type { Focusable } from '@/types' + +export default function getNodeRef (focusable: Focusable): unknown { + const element = document.getElementById(focusable.id) + return element +} diff --git a/src/utils/webAdapter/index.ts b/src/utils/webAdapter/index.ts new file mode 100644 index 0000000..76c1c62 --- /dev/null +++ b/src/utils/webAdapter/index.ts @@ -0,0 +1,19 @@ +import { Adapter } from '@/types' +import getNodeRect from './getNodeRect' +import isNodeDisabled from './isNodeDisabled' +import focusNode from './focusNode' +import isNodeFocusable from './isNodeFocusable' +import getNodeRef from './getNodeRef' +import getFocusedNode from './getFocusedNode' + +const webAdapter: Adapter = { + type: 'web', + getNodeRect, + isNodeDisabled, + focusNode, + isNodeFocusable, + getNodeRef, + getFocusedNode +} + +export default webAdapter diff --git a/src/utils/webAdapter/isNodeDisabled.test.ts b/src/utils/webAdapter/isNodeDisabled.test.ts new file mode 100644 index 0000000..b4540cd --- /dev/null +++ b/src/utils/webAdapter/isNodeDisabled.test.ts @@ -0,0 +1,19 @@ +import type { FocusableElement } from '@/types' +import isNodeDisabled from './isNodeDisabled' + +describe('isNodeDisabled', () => { + it('should return true if the element is disabled', () => { + const element = document.createElement('div') + element.id = 'element-0' + element.setAttribute('disabled', '') + document.body.appendChild(element) + + const focusable = { id: 'element-0' } as FocusableElement + + expect(isNodeDisabled(focusable)).toBe(true) + + document.body.removeChild(element) + + expect(isNodeDisabled(focusable)).toBe(true) + }) +}) diff --git a/src/utils/webAdapter/isNodeDisabled.ts b/src/utils/webAdapter/isNodeDisabled.ts new file mode 100644 index 0000000..4278432 --- /dev/null +++ b/src/utils/webAdapter/isNodeDisabled.ts @@ -0,0 +1,7 @@ +import { FocusableElement } from '@/types' + +export default function isNodeDisabled (focusable: FocusableElement): boolean { + const element = document.getElementById(focusable.id) + if (!element) return true + return element.getAttribute('disabled') !== null +} diff --git a/src/utils/webAdapter/isNodeFocusable.test.ts b/src/utils/webAdapter/isNodeFocusable.test.ts new file mode 100644 index 0000000..f6ccd1c --- /dev/null +++ b/src/utils/webAdapter/isNodeFocusable.test.ts @@ -0,0 +1,86 @@ +import { FocusableElement } from '@/types' +import isNodeFocusable from './isNodeFocusable' + +describe('isNodeFocusable', () => { + it('should return true if the element is focusable', () => { + const elementDiv = document.createElement('div') + elementDiv.id = 'element-0' + document.body.appendChild(elementDiv) + + const notFocusable = { id: 'element-0' } as FocusableElement + + expect(isNodeFocusable(notFocusable)).toBe(false) + + const elementInput = document.createElement('input') + elementInput.id = 'element-1' + document.body.appendChild(elementInput) + + const focusableInput = { id: 'element-1' } as FocusableElement + + expect(isNodeFocusable(focusableInput)).toBe(true) + + const elementButton = document.createElement('button') + elementButton.id = 'element-2' + document.body.appendChild(elementButton) + + const focusableButton = { id: 'element-2' } as FocusableElement + + expect(isNodeFocusable(focusableButton)).toBe(true) + + const elementA = document.createElement('a') + elementA.id = 'element-3' + document.body.appendChild(elementA) + + const focusableA = { id: 'element-3' } as FocusableElement + + expect(isNodeFocusable(focusableA)).toBe(true) + + const elementSelect = document.createElement('select') + elementSelect.id = 'element-4' + document.body.appendChild(elementSelect) + + const focusableSelect = { id: 'element-4' } as FocusableElement + + expect(isNodeFocusable(focusableSelect)).toBe(true) + + const elementTextarea = document.createElement('textarea') + elementTextarea.id = 'element-5' + document.body.appendChild(elementTextarea) + + const focusableTextarea = { id: 'element-5' } as FocusableElement + + expect(isNodeFocusable(focusableTextarea)).toBe(true) + + const elementContenteditable = document.createElement('div') + elementContenteditable.id = 'element-6' + elementContenteditable.setAttribute('contenteditable', '') + + document.body.appendChild(elementContenteditable) + + const focusableContenteditable = { id: 'element-6' } as FocusableElement + + expect(isNodeFocusable(focusableContenteditable)).toBe(true) + + const elementTabindex = document.createElement('div') + elementTabindex.id = 'element-7' + elementTabindex.setAttribute('tabindex', '0') + + document.body.appendChild(elementTabindex) + + const focusableTabindex = { id: 'element-7' } as FocusableElement + + expect(isNodeFocusable(focusableTabindex)).toBe(true) + + const elementTabindexMinusOne = document.createElement('div') + elementTabindexMinusOne.id = 'element-8' + document.body.appendChild(elementTabindexMinusOne) + + const focusableTabindexMinusOne = { id: 'element-8' } as FocusableElement + + expect(isNodeFocusable(focusableTabindexMinusOne)).toBe(false) + + document.body.removeChild(elementInput) + + expect(isNodeFocusable(focusableInput)).toBe(false) + }) +}) diff --git a/src/utils/webAdapter/isNodeFocusable.ts b/src/utils/webAdapter/isNodeFocusable.ts new file mode 100644 index 0000000..7a53011 --- /dev/null +++ b/src/utils/webAdapter/isNodeFocusable.ts @@ -0,0 +1,8 @@ +import { FocusableElement } from '@/types' + +export default function isNodeFocusable (focusable: FocusableElement) { + const element = document.getElementById(focusable.id) + if (!element) return false + const focusableSelector = 'input, select, textarea, button, a, [tabindex], [contenteditable]' + return element.matches(focusableSelector) +} diff --git a/yarn.lock b/yarn.lock index b8a7c33..92190ae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -685,6 +685,14 @@ tiny-glob "^0.2.9" tslib "^2.4.0" +"@react-native/virtualized-lists@^0.72.4": + version "0.72.4" + resolved "https://registry.yarnpkg.com/@react-native/virtualized-lists/-/virtualized-lists-0.72.4.tgz#926ddb5eced27dc3975dad0915cb96d1f9c01c62" + integrity sha512-2t8WBVACkKEadtsiGYJaYTix575J/5VQJyqnyL7iDIsd3iG7ODjfMDsTGsVyAA2Av/xeVIuVQRUX0ZzV3cucug== + dependencies: + invariant "^2.2.4" + nullthrows "^1.1.1" + "@sinclair/typebox@^0.25.16": version "0.25.24" resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.25.24.tgz#8c7688559979f7079aacaf31aa881c3aa410b718" @@ -862,6 +870,33 @@ resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.7.2.tgz#6c2324641cc4ba050a8c710b2b251b377581fbf0" integrity sha512-KufADq8uQqo1pYKVIYzfKbJfBAc0sOeXqGbFaSpv8MRmC/zXgowNZmFcbngndGk922QDmOASEXUZCaY48gs4cg== +"@types/prop-types@*": + version "15.7.5" + resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.5.tgz#5f19d2b85a98e9558036f6a3cacc8819420f05cf" + integrity sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w== + +"@types/react-native@0.72": + version "0.72.0" + resolved "https://registry.yarnpkg.com/@types/react-native/-/react-native-0.72.0.tgz#d19f0115936129caca770618af82b16e15ea8bec" + integrity sha512-g1PJXUQ0SnYTimfTeN9dRqj8VfzvgJjt/eakEH7+tlm/ZiEPiL9xCool4iKmqalthwtM0/BkGhjwrKnJyg1JDA== + dependencies: + "@react-native/virtualized-lists" "^0.72.4" + "@types/react" "*" + +"@types/react@*": + version "18.2.6" + resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.6.tgz#5cd53ee0d30ffc193b159d3516c8c8ad2f19d571" + integrity sha512-wRZClXn//zxCFW+ye/D2qY65UsYP1Fpex2YXorHc8awoNamkMZSvBxwxdYVInsHOZZd2Ppq8isnSzJL5Mpf8OA== + dependencies: + "@types/prop-types" "*" + "@types/scheduler" "*" + csstype "^3.0.2" + +"@types/scheduler@*": + version "0.16.3" + resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.3.tgz#cef09e3ec9af1d63d2a6cc5b383a737e24e6dcf5" + integrity sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ== + "@types/semver@^7.3.12": version "7.3.13" resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.3.13.tgz#da4bfd73f49bd541d28920ab0e2bf0ee80f71c91" @@ -1455,6 +1490,11 @@ cssstyle@^3.0.0: dependencies: rrweb-cssom "^0.6.0" +csstype@^3.0.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.2.tgz#1d4bf9d572f11c14031f0436e1c10bc1f571f50b" + integrity sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ== + data-urls@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-3.0.2.tgz#9cf24a477ae22bcef5cd5f6f0bfbc1d2d3be9143" @@ -2523,6 +2563,13 @@ internal-slot@^1.0.4: has "^1.0.3" side-channel "^1.0.4" +invariant@^2.2.4: + version "2.2.4" + resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" + integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA== + dependencies: + loose-envify "^1.0.0" + is-arguments@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.1.1.tgz#15b3f88fda01f2a97fec84ca761a560f123efa9b" @@ -3195,7 +3242,7 @@ js-sdsl@^4.1.4: resolved "https://registry.yarnpkg.com/js-sdsl/-/js-sdsl-4.3.0.tgz#aeefe32a451f7af88425b11fdb5f58c90ae1d711" integrity sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ== -js-tokens@^4.0.0: +"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== @@ -3386,6 +3433,13 @@ lodash@^4.17.15: resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== +loose-envify@^1.0.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" + integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== + dependencies: + js-tokens "^3.0.0 || ^4.0.0" + lru-cache@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" @@ -3527,6 +3581,11 @@ npm-run-path@^4.0.1: dependencies: path-key "^3.0.0" +nullthrows@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/nullthrows/-/nullthrows-1.1.1.tgz#7818258843856ae971eae4208ad7d7eb19a431b1" + integrity sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw== + nwsapi@^2.2.2: version "2.2.4" resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.4.tgz#fd59d5e904e8e1f03c25a7d5a15cfa16c714a1e5"