From 195273745ee7d2e1e8e5cc02d5ca6a3b7768f272 Mon Sep 17 00:00:00 2001 From: Boris Belmar Date: Mon, 15 May 2023 16:12:43 -0400 Subject: [PATCH 1/7] Refactor the lib and add new features --- README.md | 71 ++++++++++- example/index.html | 2 +- example/script.js | 6 +- package.json | 7 +- src/__mocks__/viewNavigationState.mock.ts | 19 ++- src/arrowNavigation.test.ts | 118 ++++++++++++++++-- src/arrowNavigation.ts | 91 ++++++++++---- src/config/events.ts | 3 +- src/handlers/changeFocusEventHandler.test.ts | 36 +++++- src/handlers/changeFocusEventHandler.ts | 36 +++++- src/handlers/directionPressHandler.test.ts | 74 +++++++++++ src/handlers/directionPressHandler.ts | 48 +++++++ src/handlers/getArrowPressHandler.test.ts | 68 ++++++---- src/handlers/getArrowPressHandler.ts | 37 +++--- src/handlers/index.ts | 1 + src/handlers/registerElementHandler.test.ts | 104 +++++++++++---- src/handlers/registerElementHandler.ts | 36 +++--- src/handlers/registerGroupHandler.test.ts | 15 ++- src/handlers/registerGroupHandler.ts | 14 ++- src/handlers/setFocusHandler.test.ts | 10 +- src/handlers/setFocusHandler.ts | 11 +- src/handlers/unregisterElementHandler.test.ts | 64 +++++----- src/handlers/unregisterElementHandler.ts | 37 +++--- .../utils/findClosestElementInGroup.ts | 13 +- src/handlers/utils/findClosestGroup.ts | 10 +- src/handlers/utils/findNextByDirection.ts | 3 +- src/handlers/utils/findNextElement.test.ts | 13 +- src/handlers/utils/findNextElement.ts | 13 -- .../utils/findNextElementByDirection.ts | 35 ------ .../utils/findNextGroupElement.test.ts | 2 +- src/handlers/utils/findNextGroupElement.ts | 7 +- src/handlers/utils/focusNextElement.test.ts | 102 --------------- src/handlers/utils/focusNextElement.ts | 20 --- src/handlers/utils/getAxisCenter.ts | 4 +- .../utils/getReferencePointsByCenter.ts | 5 +- .../utils/getReferencePointsByDirection.ts | 5 +- src/handlers/utils/index.ts | 1 - src/handlers/utils/isElementDisabled.test.ts | 12 -- src/handlers/utils/isElementDisabled.ts | 3 - .../isEligibleCandidate.ts | 5 +- .../utils/isElementInDirection.ts | 5 +- .../utils/isElementPartiallyInViewport.ts | 4 +- .../utils/isIntersecting.ts | 10 +- src/handlers/utils/isFocusableElement.test.ts | 65 ---------- src/handlers/utils/isFocusableElement.ts | 4 - src/index.ts | 4 +- src/types.ts | 56 +++++++-- src/utils/getInitialArrowNavigationState.ts | 24 ++++ src/utils/webAdapter/focusNode.test.ts | 28 +++++ src/utils/webAdapter/focusNode.ts | 6 + src/utils/webAdapter/getNodeRect.test.ts | 43 +++++++ src/utils/webAdapter/getNodeRect.ts | 7 ++ src/utils/webAdapter/index.ts | 15 +++ src/utils/webAdapter/isNodeDisabled.test.ts | 19 +++ src/utils/webAdapter/isNodeDisabled.ts | 7 ++ src/utils/webAdapter/isNodeFocusable.test.ts | 86 +++++++++++++ src/utils/webAdapter/isNodeFocusable.ts | 8 ++ yarn.lock | 61 ++++++++- 58 files changed, 1102 insertions(+), 511 deletions(-) create mode 100644 src/handlers/directionPressHandler.test.ts create mode 100644 src/handlers/directionPressHandler.ts delete mode 100644 src/handlers/utils/findNextElementByDirection.ts delete mode 100644 src/handlers/utils/focusNextElement.test.ts delete mode 100644 src/handlers/utils/focusNextElement.ts delete mode 100644 src/handlers/utils/isElementDisabled.test.ts delete mode 100644 src/handlers/utils/isElementDisabled.ts delete mode 100644 src/handlers/utils/isFocusableElement.test.ts delete mode 100644 src/handlers/utils/isFocusableElement.ts create mode 100644 src/utils/getInitialArrowNavigationState.ts create mode 100644 src/utils/webAdapter/focusNode.test.ts create mode 100644 src/utils/webAdapter/focusNode.ts create mode 100644 src/utils/webAdapter/getNodeRect.test.ts create mode 100644 src/utils/webAdapter/getNodeRect.ts create mode 100644 src/utils/webAdapter/index.ts create mode 100644 src/utils/webAdapter/isNodeDisabled.test.ts create mode 100644 src/utils/webAdapter/isNodeDisabled.ts create mode 100644 src/utils/webAdapter/isNodeFocusable.test.ts create mode 100644 src/utils/webAdapter/isNodeFocusable.ts diff --git a/README.md b/README.md index 48c3e80..0543199 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 (~14kb) 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: @@ -216,6 +231,22 @@ 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. @@ -372,6 +403,30 @@ const nextGroup = api.getNextGroup({ direction: 'down' }) // 'group-1' 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) +api.registerElement(element, 'group-0') +api.registerElement(element2, 'group-0') + +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 +441,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 +467,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: diff --git a/example/index.html b/example/index.html index 8b2a4f4..46bc586 100644 --- a/example/index.html +++ b/example/index.html @@ -9,7 +9,7 @@
- + \ No newline at end of file diff --git a/example/script.js b/example/script.js index b8cb991..405f9e8 100644 --- a/example/script.js +++ b/example/script.js @@ -2,13 +2,17 @@ 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, { + arrowDebounce: false +}) Array.from(Array(6).keys()).forEach(index => { const button = document.createElement('button') diff --git a/package.json b/package.json index fb59593..3e5af4f 100644 --- a/package.json +++ b/package.json @@ -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__/viewNavigationState.mock.ts b/src/__mocks__/viewNavigationState.mock.ts index 78a93cb..5841d1b 100644 --- a/src/__mocks__/viewNavigationState.mock.ts +++ b/src/__mocks__/viewNavigationState.mock.ts @@ -1,7 +1,9 @@ -import { ArrowNavigationState, FocusableElement, FocusableGroup, FocusableGroupConfig } from '../types' +import { Adapter, ArrowNavigationState, Focusable, FocusableElement, FocusableGroup, FocusableGroupConfig, Rect } from '../types' import getHtmlElementMock from './getHtmlElement.mock' -export default function getViewNavigationStateMock (): ArrowNavigationState { +export default function getViewNavigationStateMock ( + adapter?: Adapter +): ArrowNavigationState { const elements = new Map() const getSquareElement = (id: string, group: string, x: number, y: number) => { @@ -82,6 +84,17 @@ export default function getViewNavigationStateMock (): ArrowNavigationState { currentElement: 'element-0-0', elements, groups, - groupsConfig + groupsConfig, + adapter: { + type: 'web', + getNodeRect: (focusable: Focusable) => focusable?.el?.getBoundingClientRect() as Rect, + focusNode: (focusable: FocusableElement) => focusable?.el.focus(), + isNodeDisabled: (focusable: FocusableElement) => focusable?.el.getAttribute('disabled') !== null, + isNodeFocusable: (focusable: FocusableElement) => { + const focusableSelector = 'input, select, textarea, button, a, [tabindex], [contenteditable]' + return focusable.el.matches(focusableSelector) + }, + ...adapter + } } } diff --git a/src/arrowNavigation.test.ts b/src/arrowNavigation.test.ts index 7f68c44..dea5bcb 100644 --- a/src/arrowNavigation.test.ts +++ b/src/arrowNavigation.test.ts @@ -1,8 +1,9 @@ /* 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(() => { @@ -195,21 +196,25 @@ describe('arrowNavigation', () => { }) it('check correct time on current reassigned and event consumption', () => { - initArrowNavigation({ debug: true }) + initArrowNavigation({ debug: true, disableWebListeners: false }) const navigationApi = getArrowNavigation() navigationApi._setState(getViewNavigationStateMock()) - 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 +224,101 @@ 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() + + navigationApi._setState(getViewNavigationStateMock()) + + 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) + + const element = document.createElement('button') + element.id = 'element-0-1' + groupContainer.appendChild(element) + navigationApi.registerElement(element, 'group-0') + + const element2 = document.createElement('button') + element2.id = 'element-0-2' + groupContainer.appendChild(element2) + navigationApi.registerElement(element2, '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, 'group-0') + navigationApi.registerElement(element2, '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, 'group-0') + navigationApi.registerElement(element2, '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, 'group-0') + + expect(navigationApi.getFocusedElement()?.id).toBe(undefined) + + jest.advanceTimersByTime(TIMEOUT_TIME_EMIT_ELEMENTS_CHANGED) + + expect(navigationApi.getFocusedElement()?.id).toBe('element-0-1') + }) }) diff --git a/src/arrowNavigation.ts b/src/arrowNavigation.ts index 0bb38fa..6b73f47 100644 --- a/src/arrowNavigation.ts +++ b/src/arrowNavigation.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-empty-function */ import type { ArrowNavigationInstance, ArrowNavigationOptions, ArrowNavigationState, Direction, FocusableElement } from '@/types' import { + directionPressHandler, getArrowPressHandler, getNextElementHandler, getNextGroupHandler, @@ -13,6 +14,8 @@ import { 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 +28,22 @@ export const ERROR_MESSAGES = { export function initArrowNavigation ({ errorOnReinit = false, debug = false, - preventScroll = true + preventScroll = true, + disableWebListeners = false, + adapter, + initialFocusElement }: ArrowNavigationOptions = {}) { - const state: ArrowNavigationState = { - currentElement: null, - groups: new Map(), - groupsConfig: new Map(), - elements: new Map(), - debug - } + const state: ArrowNavigationState = getInitialArrowNavigationState({ + debug, + adapter, + initialFocusElement + }) 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 +53,21 @@ 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) + } else { + const firstElement = state.elements.values().next().value + if (firstElement) { + changeFocusElementHandler(firstElement) + } + } + } + }) + if (arrowNavigation) { if (errorOnReinit) { throw new Error(ERROR_MESSAGES.RE_INIT_ERROR) @@ -59,22 +78,43 @@ 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 + }), 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 +134,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 +153,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..9be1fa8 --- /dev/null +++ b/src/handlers/directionPressHandler.ts @@ -0,0 +1,48 @@ +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 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/index.ts b/src/handlers/index.ts index af9aa8f..0661cfc 100644 --- a/src/handlers/index.ts +++ b/src/handlers/index.ts @@ -6,3 +6,4 @@ 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' diff --git a/src/handlers/registerElementHandler.test.ts b/src/handlers/registerElementHandler.test.ts index 886e22e..173228c 100644 --- a/src/handlers/registerElementHandler.test.ts +++ b/src/handlers/registerElementHandler.test.ts @@ -1,21 +1,23 @@ 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' @@ -26,7 +28,10 @@ describe('registerElementHandler', () => { }) 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 @@ -42,14 +47,20 @@ describe('registerElementHandler', () => { }) it('should throw an error if the element id is not defined', () => { - const registerElement = registerElementHandler(state, onChangeElement, emitter.emit) + const registerElement = registerElementHandler({ + state, + emit: emitter.emit + }) const element = document.createElement('button') expect(() => registerElement(element, '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' @@ -58,7 +69,10 @@ describe('registerElementHandler', () => { 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' @@ -69,19 +83,11 @@ describe('registerElementHandler', () => { ) }) - 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' @@ -89,7 +95,10 @@ describe('registerElementHandler', () => { }) 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' @@ -112,7 +121,10 @@ describe('registerElementHandler', () => { el: state.groups.get('group-6')?.el 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 }) @@ -130,9 +142,55 @@ describe('registerElementHandler', () => { el: state.groups.get('group-6')?.el 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) }) + + 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' + + registerElement(element, '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' + registerElement(element2, 'group-5') + + const element3 = document.createElement('button') + element3.id = 'element-5-2' + registerElement(element3, '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) + }) }) diff --git a/src/handlers/registerElementHandler.ts b/src/handlers/registerElementHandler.ts index 6ffef8e..13f1a49 100644 --- a/src/handlers/registerElementHandler.ts +++ b/src/handlers/registerElementHandler.ts @@ -1,9 +1,8 @@ +/* eslint-disable no-param-reassign */ import EVENTS from '@/config/events' -import type { ArrowNavigationState, FocusableElement, FocusableElementOptions } from '@/types' +import type { ArrowNavigationState, 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', @@ -12,11 +11,18 @@ export const ERROR_MESSAGES = { 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, group: string, @@ -36,10 +42,6 @@ export default function registerElementHandler ( throw new Error(ERROR_MESSAGES.ELEMENT_ID_REQUIRED) } - if (!isFocusableElement(element)) { - throw new Error(ERROR_MESSAGES.ELEMENT_NOT_FOCUSABLE(element.id)) - } - if (state.elements.get(element.id)) { console.warn(ERROR_MESSAGES.ELEMENT_ID_ALREADY_REGISTERED(element.id)) return @@ -58,6 +60,12 @@ export default function registerElementHandler ( ...options } + if (!state.adapter.isNodeFocusable(focusableElement)) { + throw new Error(ERROR_MESSAGES.ELEMENT_NOT_FOCUSABLE(element.id)) + } + + clearTimeout(timeout) + state.elements.set(id, focusableElement) emit(EVENTS.ELEMENTS_CHANGED, state.elements) @@ -73,8 +81,8 @@ export default function registerElementHandler ( existentGroup.elements.add(id) } - if (!state.currentElement && !isElementDisabled(focusableElement.el)) { - onChangeCurrentElement(focusableElement) - } + timeout = setTimeout(() => { + emit(EVENTS.ELEMENTS_REGISTER_END) + }, TIMEOUT_TIME_EMIT_ELEMENTS_CHANGED) } } diff --git a/src/handlers/registerGroupHandler.test.ts b/src/handlers/registerGroupHandler.test.ts index 491923b..8c623c4 100644 --- a/src/handlers/registerGroupHandler.test.ts +++ b/src/handlers/registerGroupHandler.test.ts @@ -13,7 +13,10 @@ 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' @@ -24,14 +27,20 @@ describe('registerGroupHandler', () => { }) 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) }) it('if the group is already registered, just changes the group config and keep the elements', () => { - const registerGroup = registerGroupHandler(state, emitter.emit) + const registerGroup = registerGroupHandler({ + state, + emit: emitter.emit + }) const groupId = 'group-0' const group = state.groups.get(groupId) as FocusableGroup const groupTotalElements = group.elements.size diff --git a/src/handlers/registerGroupHandler.ts b/src/handlers/registerGroupHandler.ts index 2bca3b0..82014e6 100644 --- a/src/handlers/registerGroupHandler.ts +++ b/src/handlers/registerGroupHandler.ts @@ -8,17 +8,23 @@ 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' } -export default function registerGroupHandler ( - state: ArrowNavigationState, +interface RegisterGroupHandlerProps { + state: ArrowNavigationState emit: EventEmitter['emit'] -) { +} + +export default function registerGroupHandler ({ + state, + emit +}: RegisterGroupHandlerProps) { return ( element: HTMLElement, options?: FocusableGroupOptions 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.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.ts b/src/handlers/utils/findClosestGroup.ts index 50fc082..d128e57 100644 --- a/src/handlers/utils/findClosestGroup.ts +++ b/src/handlers/utils/findClosestGroup.ts @@ -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.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..7776436 100644 --- a/src/handlers/utils/findNextElement.test.ts +++ b/src/handlers/utils/findNextElement.test.ts @@ -16,7 +16,7 @@ describe('findNextElement', () => { state.elements.get('element-0-1')?.el.setAttribute('disabled', 'true') - element.nextElementByDirection = { + element.nextByDirection = { down: 'element-0-1' } @@ -47,7 +47,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 +115,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/findNextGroupElement.test.ts b/src/handlers/utils/findNextGroupElement.test.ts index 3979d44..397cf57 100644 --- a/src/handlers/utils/findNextGroupElement.test.ts +++ b/src/handlers/utils/findNextGroupElement.test.ts @@ -112,7 +112,7 @@ describe('findNextGroupElement', () => { el: nextGroup.el 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') 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..4557727 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,5 +17,7 @@ export type { FocusableElementOptions, FocusableGroupOptions, FocusableWithKind, - FocusableByDirection + FocusableByDirection, + FocusNodeOptions, + Adapter } from './types' diff --git a/src/types.ts b/src/types.ts index f92377f..fadb09c 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,27 @@ 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 + /** + * @deprecated + * Now it uses the id to find the element and ref for React Native adapter. + * It keeps the el property for backward compatibility. + */ el: 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. @@ -67,6 +69,7 @@ export type FocusableGroup = Focusable & { } export type FocusableGroupConfig = Focusable & { + instance?: View firstElement?: string lastElement?: string nextGroupByDirection?: ElementByDirection @@ -78,22 +81,51 @@ export type FocusableGroupConfig = Focusable & { onFocus?: (result: FocusEventResult) => void onBlur?: (result: BlurEventResult) => void keepFocus?: boolean + arrowDebounce?: boolean } 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 +} + export type ArrowNavigationState = { currentElement: string | null, groupsConfig: Map groups: Map elements: Map debug?: boolean + readonly adapter: Adapter + initialFocusElement?: string } export type ArrowNavigationOptions = { debug?: boolean errorOnReinit?: boolean preventScroll?: boolean + adapter?: Adapter + disableWebListeners?: boolean + initialFocusElement?: string } export type GetNextOptions = { @@ -117,10 +149,11 @@ 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: (el: HTMLElement, options?: FocusableGroupOptions) => void + registerElement: (el: HTMLElement, groupId: string, options?: FocusableElementOptions) => void + unregisterElement: (id: string) => void destroy: () => void getCurrentGroups: () => Set getGroupElements: (group: string) => Set @@ -129,6 +162,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..b98a43c --- /dev/null +++ b/src/utils/getInitialArrowNavigationState.ts @@ -0,0 +1,24 @@ +import type { Adapter, ArrowNavigationState } from '@/types' +import webAdapter from './webAdapter' + +interface ArrowNavigationStateProps { + debug?: boolean + adapter?: Adapter + initialFocusElement?: string +} + +export default function getInitialArrowNavigationState ({ + debug, + adapter = webAdapter, + initialFocusElement +}: ArrowNavigationStateProps): ArrowNavigationState { + return { + currentElement: null, + groups: new Map(), + groupsConfig: new Map(), + elements: new Map(), + debug, + adapter, + initialFocusElement + } +} 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/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/index.ts b/src/utils/webAdapter/index.ts new file mode 100644 index 0000000..6088a38 --- /dev/null +++ b/src/utils/webAdapter/index.ts @@ -0,0 +1,15 @@ +import { Adapter } from '@/types' +import getNodeRect from './getNodeRect' +import isNodeDisabled from './isNodeDisabled' +import focusNode from './focusNode' +import isNodeFocusable from './isNodeFocusable' + +const webAdapter: Adapter = { + type: 'web', + getNodeRect, + isNodeDisabled, + focusNode, + isNodeFocusable +} + +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" From 978cefa33291dc0b11c93a3aad687c10081f1090 Mon Sep 17 00:00:00 2001 From: Boris Belmar Date: Mon, 15 May 2023 20:58:45 -0400 Subject: [PATCH 2/7] Remove el from api --- example/index.html | 2 +- example/script.js | 16 ++-- src/__mocks__/viewNavigationState.mock.ts | 32 ++++---- src/arrowNavigation.test.ts | 22 +++--- src/handlers/globalFocusHandler.test.ts | 18 ++++- src/handlers/globalFocusHandler.ts | 6 +- src/handlers/registerElementHandler.test.ts | 77 +++++++++++++------ src/handlers/registerElementHandler.ts | 38 +++++---- src/handlers/registerGroupHandler.test.ts | 34 +++++--- src/handlers/registerGroupHandler.ts | 15 ++-- .../utils/findClosestElementInGroup.test.ts | 3 +- src/handlers/utils/findClosestGroup.test.ts | 3 +- .../utils/findNextByDirection.test.ts | 7 +- src/handlers/utils/findNextElement.test.ts | 5 +- .../utils/findNextGroupByDirection.test.ts | 3 +- .../utils/findNextGroupElement.test.ts | 21 ++--- src/types.ts | 14 ++-- src/utils/webAdapter/getNodeRef.test.ts | 18 +++++ src/utils/webAdapter/getNodeRef.ts | 6 ++ src/utils/webAdapter/index.ts | 4 +- 20 files changed, 226 insertions(+), 118 deletions(-) create mode 100644 src/utils/webAdapter/getNodeRef.test.ts create mode 100644 src/utils/webAdapter/getNodeRef.ts diff --git a/example/index.html b/example/index.html index 46bc586..8b2a4f4 100644 --- a/example/index.html +++ b/example/index.html @@ -9,7 +9,7 @@
- + \ No newline at end of file diff --git a/example/script.js b/example/script.js index 405f9e8..dce2a4a 100644 --- a/example/script.js +++ b/example/script.js @@ -10,17 +10,18 @@ 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 @@ -34,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 => { @@ -43,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) }) } @@ -54,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/src/__mocks__/viewNavigationState.mock.ts b/src/__mocks__/viewNavigationState.mock.ts index 5841d1b..8c9765f 100644 --- a/src/__mocks__/viewNavigationState.mock.ts +++ b/src/__mocks__/viewNavigationState.mock.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-underscore-dangle */ import { Adapter, ArrowNavigationState, Focusable, FocusableElement, FocusableGroup, FocusableGroupConfig, Rect } from '../types' import getHtmlElementMock from './getHtmlElement.mock' @@ -9,7 +10,7 @@ export default function getViewNavigationStateMock ( 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 }), group } elements.set(focusableElement.id, focusableElement) @@ -21,7 +22,7 @@ export default function getViewNavigationStateMock ( 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 => { @@ -30,11 +31,11 @@ export default function getViewNavigationStateMock ( 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 => { @@ -43,11 +44,11 @@ export default function getViewNavigationStateMock ( 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 => { @@ -56,11 +57,11 @@ export default function getViewNavigationStateMock ( 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 => { @@ -69,17 +70,17 @@ export default function getViewNavigationStateMock ( 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 }) + groupsConfig.set(group5.id, { _ref: group5._ref, id: group5.id }) return { currentElement: 'element-0-0', elements, @@ -87,13 +88,14 @@ export default function getViewNavigationStateMock ( groupsConfig, adapter: { type: 'web', - getNodeRect: (focusable: Focusable) => focusable?.el?.getBoundingClientRect() as Rect, - focusNode: (focusable: FocusableElement) => focusable?.el.focus(), - isNodeDisabled: (focusable: FocusableElement) => focusable?.el.getAttribute('disabled') !== null, + getNodeRect: (focusable: Focusable) => focusable?._ref?.getBoundingClientRect() as Rect, + focusNode: (focusable: FocusableElement) => focusable?._ref?.focus(), + isNodeDisabled: (focusable: FocusableElement) => focusable?._ref?.getAttribute('disabled') !== null, isNodeFocusable: (focusable: FocusableElement) => { const focusableSelector = 'input, select, textarea, button, a, [tabindex], [contenteditable]' - return focusable.el.matches(focusableSelector) + return focusable._ref?.matches(focusableSelector) || false }, + getNodeRef: (focusable: Focusable) => focusable?._ref, ...adapter } } diff --git a/src/arrowNavigation.test.ts b/src/arrowNavigation.test.ts index dea5bcb..e413baf 100644 --- a/src/arrowNavigation.test.ts +++ b/src/arrowNavigation.test.ts @@ -60,11 +60,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', () => { @@ -75,7 +75,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', () => { @@ -262,17 +262,17 @@ describe('arrowNavigation', () => { const groupContainer = document.createElement('div') groupContainer.id = 'group-0' document.body.appendChild(groupContainer) - navigationApi.registerGroup(groupContainer) + navigationApi.registerGroup(groupContainer.id) const element = document.createElement('button') element.id = 'element-0-1' groupContainer.appendChild(element) - navigationApi.registerElement(element, 'group-0') + navigationApi.registerElement(element.id, 'group-0') const element2 = document.createElement('button') element2.id = 'element-0-2' groupContainer.appendChild(element2) - navigationApi.registerElement(element2, 'group-0') + navigationApi.registerElement(element2.id, 'group-0') expect(navigationApi.getFocusedElement()?.id).toBe(undefined) @@ -285,8 +285,8 @@ describe('arrowNavigation', () => { navigationApi.setInitialFocusElement('element-0-1') - navigationApi.registerElement(element, 'group-0') - navigationApi.registerElement(element2, 'group-0') + navigationApi.registerElement(element.id, 'group-0') + navigationApi.registerElement(element2.id, 'group-0') expect(navigationApi.getFocusedElement()?.id).toBe(undefined) @@ -299,8 +299,8 @@ describe('arrowNavigation', () => { navigationApi.setInitialFocusElement('non-existing-element') - navigationApi.registerElement(element, 'group-0') - navigationApi.registerElement(element2, 'group-0') + navigationApi.registerElement(element.id, 'group-0') + navigationApi.registerElement(element2.id, 'group-0') expect(navigationApi.getFocusedElement()?.id).toBe(undefined) @@ -313,7 +313,7 @@ describe('arrowNavigation', () => { navigationApi.setInitialFocusElement(null as unknown as string) - navigationApi.registerElement(element, 'group-0') + navigationApi.registerElement(element.id, 'group-0') expect(navigationApi.getFocusedElement()?.id).toBe(undefined) diff --git a/src/handlers/globalFocusHandler.test.ts b/src/handlers/globalFocusHandler.test.ts index ab4ef8f..ffbbf10 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' + delete state.elements.get('element-0-0')?._ref + globalFocusHandler(state, event, true) + expect(state.currentElement).toBe('element-0-0') + expect(getCurrentElement(state)?.id).toBe('element-0-0') + expect(getCurrentElement(state)?._ref).toBe(undefined) + }) }) 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/registerElementHandler.test.ts b/src/handlers/registerElementHandler.test.ts index 173228c..3345413 100644 --- a/src/handlers/registerElementHandler.test.ts +++ b/src/handlers/registerElementHandler.test.ts @@ -1,15 +1,22 @@ -import type { ArrowNavigationState, FocusableGroup } from '@/types' +/* eslint-disable no-underscore-dangle */ +import type { Adapter, ArrowNavigationState, FocusableGroup } from '@/types' import getViewNavigationStateMock from '@/__mocks__/viewNavigationState.mock' import createEventEmitter, { EventEmitter } from '@/utils/createEventEmitter' import EVENTS from '@/config/events' +import webAdapter from '@/utils/webAdapter' import registerElementHandler, { ERROR_MESSAGES, TIMEOUT_TIME_EMIT_ELEMENTS_CHANGED } from './registerElementHandler' describe('registerElementHandler', () => { let state: ArrowNavigationState let emitter: EventEmitter + let adapterMock: Adapter beforeEach(() => { - state = getViewNavigationStateMock() + adapterMock = { + getNodeRef: webAdapter.getNodeRef, + isNodeFocusable: webAdapter.isNodeFocusable + } as unknown as Adapter + state = getViewNavigationStateMock(adapterMock) emitter = createEventEmitter() }) @@ -21,10 +28,11 @@ describe('registerElementHandler', () => { 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', () => { @@ -39,10 +47,12 @@ 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) }) @@ -51,9 +61,7 @@ describe('registerElementHandler', () => { state, emit: emitter.emit }) - - const element = document.createElement('button') - expect(() => registerElement(element, 'group-1')).toThrowError(ERROR_MESSAGES.ELEMENT_ID_REQUIRED) + 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', () => { @@ -64,7 +72,8 @@ describe('registerElementHandler', () => { 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', () => { @@ -76,7 +85,8 @@ describe('registerElementHandler', () => { 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) @@ -91,7 +101,8 @@ describe('registerElementHandler', () => { 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', () => { @@ -105,20 +116,23 @@ describe('registerElementHandler', () => { 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({ @@ -127,11 +141,13 @@ describe('registerElementHandler', () => { }) 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) }) @@ -139,7 +155,7 @@ 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({ @@ -148,7 +164,7 @@ describe('registerElementHandler', () => { }) 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', () => { @@ -163,8 +179,8 @@ describe('registerElementHandler', () => { const element = document.createElement('button') element.id = 'element-5-0' - - registerElement(element, 'group-5') + document.body.appendChild(element) + registerElement(element.id, 'group-5') // Group 5 is registered and element-5-0 is registered expect(emitMock).toHaveBeenCalledTimes(2) @@ -179,11 +195,13 @@ describe('registerElementHandler', () => { const element2 = document.createElement('button') element2.id = 'element-5-1' - registerElement(element2, 'group-5') + document.body.appendChild(element2) + registerElement(element2.id, 'group-5') const element3 = document.createElement('button') element3.id = 'element-5-2' - registerElement(element3, 'group-5') + 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 @@ -193,4 +211,13 @@ describe('registerElementHandler', () => { 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 13f1a49..f29c447 100644 --- a/src/handlers/registerElementHandler.ts +++ b/src/handlers/registerElementHandler.ts @@ -1,12 +1,13 @@ /* eslint-disable no-param-reassign */ import EVENTS from '@/config/events' -import type { ArrowNavigationState, FocusableElementOptions } from '@/types' +import type { ArrowNavigationState, FocusableElement, FocusableElementOptions } from '@/types' import type { EventEmitter } from '@/utils/createEventEmitter' import getElementIdByOrder from '@/utils/getElementIdByOrder' 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.` } @@ -24,7 +25,7 @@ export default function registerElementHandler ({ emit }: RegisterElementHandlerProps) { return ( - element: HTMLElement, + id: string, group: string, options?: FocusableElementOptions ) => { @@ -38,24 +39,30 @@ 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 (state.elements.get(element.id)) { - console.warn(ERROR_MESSAGES.ELEMENT_ID_ALREADY_REGISTERED(element.id)) + if (!element) { + throw new Error(ERROR_MESSAGES.ELEMENT_DOES_NOT_EXIST(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 } @@ -66,19 +73,20 @@ export default function registerElementHandler ({ clearTimeout(timeout) - state.elements.set(id, focusableElement) + 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) } timeout = setTimeout(() => { diff --git a/src/handlers/registerGroupHandler.test.ts b/src/handlers/registerGroupHandler.test.ts index 8c623c4..4216276 100644 --- a/src/handlers/registerGroupHandler.test.ts +++ b/src/handlers/registerGroupHandler.test.ts @@ -1,14 +1,20 @@ +/* eslint-disable no-underscore-dangle */ import createEventEmitter, { EventEmitter } from '@/utils/createEventEmitter' -import { ArrowNavigationState, FocusableGroup } from '../types' +import webAdapter from '@/utils/webAdapter' +import { Adapter, ArrowNavigationState, FocusableGroup } from '../types' import getViewNavigationStateMock from '../__mocks__/viewNavigationState.mock' import registerGroupHandler, { ERROR_MESSAGES } from './registerGroupHandler' describe('registerGroupHandler', () => { let state: ArrowNavigationState let emitter: EventEmitter + let adapterMock: Adapter beforeEach(() => { - state = getViewNavigationStateMock() + adapterMock = { + getNodeRef: webAdapter.getNodeRef + } as unknown as Adapter + state = getViewNavigationStateMock(adapterMock) emitter = createEventEmitter() }) @@ -20,10 +26,11 @@ describe('registerGroupHandler', () => { 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', () => { @@ -33,10 +40,13 @@ describe('registerGroupHandler', () => { }) 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', () => { + state = getViewNavigationStateMock({ + getNodeRef: () => state.groups.get('group-0')?._ref + } as unknown as Adapter) const registerGroup = registerGroupHandler({ state, emit: emitter.emit @@ -45,16 +55,22 @@ describe('registerGroupHandler', () => { 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 82014e6..76109ed 100644 --- a/src/handlers/registerGroupHandler.ts +++ b/src/handlers/registerGroupHandler.ts @@ -13,7 +13,8 @@ const defaultGroupConfig: FocusableGroupOptions = { } 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.` } interface RegisterGroupHandlerProps { @@ -26,14 +27,18 @@ export default function registerGroupHandler ({ 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() @@ -42,7 +47,7 @@ export default function registerGroupHandler ({ state.groups.set(id, { id, elements: prevElements, - el: element + _ref: element }) emit(EVENTS.GROUPS_CHANGED, state.groups) @@ -51,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/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/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/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/findNextElement.test.ts b/src/handlers/utils/findNextElement.test.ts index 7776436..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,7 +15,7 @@ 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.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', diff --git a/src/handlers/utils/findNextGroupByDirection.test.ts b/src/handlers/utils/findNextGroupByDirection.test.ts index 55d3210..ff0ba5c 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' @@ -33,7 +34,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 397cf57..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).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/types.ts b/src/types.ts index fadb09c..ee886b8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -43,10 +43,9 @@ export type Focusable = { id: string /** * @deprecated - * Now it uses the id to find the element and ref for React Native adapter. - * It keeps the el property for backward compatibility. + * For test purposes only. */ - el: HTMLElement + _ref?: HTMLElement } export type FocusableElement = Focusable & { @@ -62,7 +61,7 @@ export type FocusableElement = Focusable & { onBlur?: (result: BlurEventResult) => void } -export type FocusableElementOptions = Omit +export type FocusableElementOptions = Omit export type FocusableGroup = Focusable & { elements: Set @@ -84,7 +83,7 @@ export type FocusableGroupConfig = Focusable & { arrowDebounce?: boolean } -export type FocusableGroupOptions = Omit +export type FocusableGroupOptions = Omit export type Rect = { x: number @@ -107,6 +106,7 @@ export type Adapter = { isNodeDisabled: (focusable: FocusableElement) => boolean focusNode: (focusable: FocusableElement, opts?: FocusNodeOptions) => void isNodeFocusable: (focusable: FocusableElement) => boolean + getNodeRef: (focusable: Focusable) => unknown } export type ArrowNavigationState = { @@ -151,8 +151,8 @@ export type ArrowNavigationInstance = { getFocusedElement: () => FocusableElement | null setFocusElement: (id: string) => void setInitialFocusElement: (id: string) => void - registerGroup: (el: HTMLElement, options?: FocusableGroupOptions) => void - registerElement: (el: HTMLElement, groupId: string, options?: FocusableElementOptions) => void + registerGroup: (id: string, options?: FocusableGroupOptions) => void + registerElement: (id: string, groupId: string, options?: FocusableElementOptions) => void unregisterElement: (id: string) => void destroy: () => void getCurrentGroups: () => Set 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 index 6088a38..919cc00 100644 --- a/src/utils/webAdapter/index.ts +++ b/src/utils/webAdapter/index.ts @@ -3,13 +3,15 @@ import getNodeRect from './getNodeRect' import isNodeDisabled from './isNodeDisabled' import focusNode from './focusNode' import isNodeFocusable from './isNodeFocusable' +import getNodeRef from './getNodeRef' const webAdapter: Adapter = { type: 'web', getNodeRect, isNodeDisabled, focusNode, - isNodeFocusable + isNodeFocusable, + getNodeRef } export default webAdapter From 749ff31f45eb34798aed1b64a9e570bf8edf1bed Mon Sep 17 00:00:00 2001 From: Boris Belmar Date: Thu, 18 May 2023 13:13:07 -0400 Subject: [PATCH 3/7] Refactor 2.0 --- README.md | 26 +++++++++++- src/__mocks__/getHtmlElement.mock.ts | 8 +++- src/__mocks__/viewNavigationState.mock.ts | 40 +++++++++--------- src/arrowNavigation.test.ts | 42 ++++++++++++++++++- src/arrowNavigation.ts | 27 ++++++++---- src/handlers/directionPressHandler.ts | 9 ++++ src/handlers/registerElementHandler.test.ts | 10 +---- src/handlers/registerElementHandler.ts | 2 +- src/handlers/registerGroupHandler.test.ts | 13 ++---- src/handlers/utils/findClosestGroup.ts | 2 +- .../utils/findNextGroupByDirection.test.ts | 4 ++ src/index.ts | 6 ++- src/types.ts | 5 ++- src/utils/getInitialArrowNavigationState.ts | 7 +++- src/utils/webAdapter/getFocusedNode.test.ts | 14 +++++++ src/utils/webAdapter/getFocusedNode.ts | 3 ++ src/utils/webAdapter/index.ts | 4 +- 17 files changed, 165 insertions(+), 57 deletions(-) create mode 100644 src/utils/webAdapter/getFocusedNode.test.ts create mode 100644 src/utils/webAdapter/getFocusedNode.ts diff --git a/README.md b/README.md index 0543199..e008a75 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 (~14kb) 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). @@ -489,3 +489,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/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 8c9765f..9349d50 100644 --- a/src/__mocks__/viewNavigationState.mock.ts +++ b/src/__mocks__/viewNavigationState.mock.ts @@ -1,16 +1,22 @@ /* eslint-disable no-underscore-dangle */ -import { Adapter, ArrowNavigationState, Focusable, FocusableElement, FocusableGroup, FocusableGroupConfig, Rect } from '../types' +import webAdapter from '@/utils/webAdapter' +import { ArrowNavigationState, FocusableElement, FocusableGroup, FocusableGroupConfig } from '../types' import getHtmlElementMock from './getHtmlElement.mock' -export default function getViewNavigationStateMock ( - adapter?: Adapter -): 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, - _ref: 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) @@ -81,22 +87,18 @@ export default function getViewNavigationStateMock ( group5.elements.add('element-4-0') groups.set(group5.id, group5) groupsConfig.set(group5.id, { _ref: group5._ref, id: group5.id }) - return { - currentElement: 'element-0-0', + + const CURRENT_ELEMENT = 'element-0-0' + + elements.get(CURRENT_ELEMENT)?._ref?.focus() + + const state: ArrowNavigationState = { + currentElement: CURRENT_ELEMENT, elements, groups, groupsConfig, - adapter: { - type: 'web', - getNodeRect: (focusable: Focusable) => focusable?._ref?.getBoundingClientRect() as Rect, - focusNode: (focusable: FocusableElement) => focusable?._ref?.focus(), - isNodeDisabled: (focusable: FocusableElement) => focusable?._ref?.getAttribute('disabled') !== null, - isNodeFocusable: (focusable: FocusableElement) => { - const focusableSelector = 'input, select, textarea, button, a, [tabindex], [contenteditable]' - return focusable._ref?.matches(focusableSelector) || false - }, - getNodeRef: (focusable: Focusable) => focusable?._ref, - ...adapter - } + registerCooldown, + adapter: webAdapter } + return state } diff --git a/src/arrowNavigation.test.ts b/src/arrowNavigation.test.ts index e413baf..70b1257 100644 --- a/src/arrowNavigation.test.ts +++ b/src/arrowNavigation.test.ts @@ -1,3 +1,4 @@ +/* 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' @@ -199,8 +200,9 @@ describe('arrowNavigation', () => { initArrowNavigation({ debug: true, disableWebListeners: false }) const navigationApi = getArrowNavigation() + const state = getViewNavigationStateMock() - navigationApi._setState(getViewNavigationStateMock()) + navigationApi._setState(state) navigationApi.setFocusElement('element-0-2') @@ -229,8 +231,9 @@ describe('arrowNavigation', () => { initArrowNavigation({ debug: true }) const navigationApi = getArrowNavigation() + const state = getViewNavigationStateMock() - navigationApi._setState(getViewNavigationStateMock()) + navigationApi._setState(state) navigationApi.setFocusElement('element-0-2') @@ -321,4 +324,39 @@ describe('arrowNavigation', () => { 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 6b73f47..a68539b 100644 --- a/src/arrowNavigation.ts +++ b/src/arrowNavigation.ts @@ -31,12 +31,14 @@ export function initArrowNavigation ({ preventScroll = true, disableWebListeners = false, adapter, - initialFocusElement + initialFocusElement, + registerCooldown = 500 }: ArrowNavigationOptions = {}) { const state: ArrowNavigationState = getInitialArrowNavigationState({ debug, adapter, - initialFocusElement + initialFocusElement, + registerCooldown }) const emitter = createEventEmitter() @@ -55,17 +57,26 @@ export function initArrowNavigation ({ emitter.on(EVENTS.ELEMENTS_REGISTER_END, () => { const currentElement = getCurrentElement(state) - if (!currentElement && state.elements.size) { + + if ((!currentElement) && state.elements.size) { const initialElement = state.elements.get(state.initialFocusElement || '') if (initialElement) { changeFocusElementHandler(initialElement) - } else { - const firstElement = state.elements.values().next().value - if (firstElement) { - changeFocusElementHandler(firstElement) - } + 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) { diff --git a/src/handlers/directionPressHandler.ts b/src/handlers/directionPressHandler.ts index 9be1fa8..fa68e74 100644 --- a/src/handlers/directionPressHandler.ts +++ b/src/handlers/directionPressHandler.ts @@ -20,6 +20,7 @@ export default function directionPressHandler ({ onChangeCurrentElement }: DirectionPressHandlerProps) { const currentElement = getCurrentElement(state) + if (!currentElement) { const initialElement = state.elements.get(state.initialFocusElement || '') if (initialElement) { @@ -34,6 +35,14 @@ export default function directionPressHandler ({ } 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) { diff --git a/src/handlers/registerElementHandler.test.ts b/src/handlers/registerElementHandler.test.ts index 3345413..fb79f56 100644 --- a/src/handlers/registerElementHandler.test.ts +++ b/src/handlers/registerElementHandler.test.ts @@ -1,22 +1,16 @@ /* eslint-disable no-underscore-dangle */ -import type { Adapter, ArrowNavigationState, FocusableGroup } from '@/types' +import type { ArrowNavigationState, FocusableGroup } from '@/types' import getViewNavigationStateMock from '@/__mocks__/viewNavigationState.mock' import createEventEmitter, { EventEmitter } from '@/utils/createEventEmitter' import EVENTS from '@/config/events' -import webAdapter from '@/utils/webAdapter' import registerElementHandler, { ERROR_MESSAGES, TIMEOUT_TIME_EMIT_ELEMENTS_CHANGED } from './registerElementHandler' describe('registerElementHandler', () => { let state: ArrowNavigationState let emitter: EventEmitter - let adapterMock: Adapter beforeEach(() => { - adapterMock = { - getNodeRef: webAdapter.getNodeRef, - isNodeFocusable: webAdapter.isNodeFocusable - } as unknown as Adapter - state = getViewNavigationStateMock(adapterMock) + state = getViewNavigationStateMock() emitter = createEventEmitter() }) diff --git a/src/handlers/registerElementHandler.ts b/src/handlers/registerElementHandler.ts index f29c447..7977117 100644 --- a/src/handlers/registerElementHandler.ts +++ b/src/handlers/registerElementHandler.ts @@ -91,6 +91,6 @@ export default function registerElementHandler ({ timeout = setTimeout(() => { emit(EVENTS.ELEMENTS_REGISTER_END) - }, TIMEOUT_TIME_EMIT_ELEMENTS_CHANGED) + }, state.registerCooldown || TIMEOUT_TIME_EMIT_ELEMENTS_CHANGED) } } diff --git a/src/handlers/registerGroupHandler.test.ts b/src/handlers/registerGroupHandler.test.ts index 4216276..98e30c3 100644 --- a/src/handlers/registerGroupHandler.test.ts +++ b/src/handlers/registerGroupHandler.test.ts @@ -1,20 +1,15 @@ /* eslint-disable no-underscore-dangle */ import createEventEmitter, { EventEmitter } from '@/utils/createEventEmitter' -import webAdapter from '@/utils/webAdapter' -import { Adapter, ArrowNavigationState, FocusableGroup } from '../types' +import { ArrowNavigationState, FocusableGroup } from '../types' import getViewNavigationStateMock from '../__mocks__/viewNavigationState.mock' import registerGroupHandler, { ERROR_MESSAGES } from './registerGroupHandler' describe('registerGroupHandler', () => { let state: ArrowNavigationState let emitter: EventEmitter - let adapterMock: Adapter beforeEach(() => { - adapterMock = { - getNodeRef: webAdapter.getNodeRef - } as unknown as Adapter - state = getViewNavigationStateMock(adapterMock) + state = getViewNavigationStateMock() emitter = createEventEmitter() }) @@ -44,9 +39,7 @@ describe('registerGroupHandler', () => { }) it('if the group is already registered, just changes the group config and keep the elements', () => { - state = getViewNavigationStateMock({ - getNodeRef: () => state.groups.get('group-0')?._ref - } as unknown as Adapter) + state = getViewNavigationStateMock() const registerGroup = registerGroupHandler({ state, emit: emitter.emit diff --git a/src/handlers/utils/findClosestGroup.ts b/src/handlers/utils/findClosestGroup.ts index d128e57..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, diff --git a/src/handlers/utils/findNextGroupByDirection.test.ts b/src/handlers/utils/findNextGroupByDirection.test.ts index ff0ba5c..7fdb5ff 100644 --- a/src/handlers/utils/findNextGroupByDirection.test.ts +++ b/src/handlers/utils/findNextGroupByDirection.test.ts @@ -11,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 diff --git a/src/index.ts b/src/index.ts index 4557727..859b1de 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,5 +19,9 @@ export type { FocusableWithKind, FocusableByDirection, FocusNodeOptions, - Adapter + Adapter, + Focusable, + Rect, + FocusEventResult, + BlurEventResult } from './types' diff --git a/src/types.ts b/src/types.ts index ee886b8..df650f7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -106,7 +106,8 @@ export type Adapter = { isNodeDisabled: (focusable: FocusableElement) => boolean focusNode: (focusable: FocusableElement, opts?: FocusNodeOptions) => void isNodeFocusable: (focusable: FocusableElement) => boolean - getNodeRef: (focusable: Focusable) => unknown + getNodeRef: (focusable: Focusable) => unknown, + getFocusedNode: () => unknown } export type ArrowNavigationState = { @@ -117,6 +118,7 @@ export type ArrowNavigationState = { debug?: boolean readonly adapter: Adapter initialFocusElement?: string + readonly registerCooldown?: number } export type ArrowNavigationOptions = { @@ -126,6 +128,7 @@ export type ArrowNavigationOptions = { adapter?: Adapter disableWebListeners?: boolean initialFocusElement?: string + registerCooldown?: number } export type GetNextOptions = { diff --git a/src/utils/getInitialArrowNavigationState.ts b/src/utils/getInitialArrowNavigationState.ts index b98a43c..ecbdfc1 100644 --- a/src/utils/getInitialArrowNavigationState.ts +++ b/src/utils/getInitialArrowNavigationState.ts @@ -5,12 +5,14 @@ interface ArrowNavigationStateProps { debug?: boolean adapter?: Adapter initialFocusElement?: string + registerCooldown?: number } export default function getInitialArrowNavigationState ({ debug, adapter = webAdapter, - initialFocusElement + initialFocusElement, + registerCooldown }: ArrowNavigationStateProps): ArrowNavigationState { return { currentElement: null, @@ -19,6 +21,7 @@ export default function getInitialArrowNavigationState ({ elements: new Map(), debug, adapter, - initialFocusElement + initialFocusElement, + registerCooldown } } 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/index.ts b/src/utils/webAdapter/index.ts index 919cc00..76c1c62 100644 --- a/src/utils/webAdapter/index.ts +++ b/src/utils/webAdapter/index.ts @@ -4,6 +4,7 @@ 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', @@ -11,7 +12,8 @@ const webAdapter: Adapter = { isNodeDisabled, focusNode, isNodeFocusable, - getNodeRef + getNodeRef, + getFocusedNode } export default webAdapter From e8321e194aee31450bf16cd9a60c42270bea3437 Mon Sep 17 00:00:00 2001 From: Boris Belmar Date: Thu, 18 May 2023 13:17:00 -0400 Subject: [PATCH 4/7] Update README.md --- README.md | 66 +++++++++++++++++++++++++++---------------------------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index e008a75..13d632a 100644 --- a/README.md +++ b/README.md @@ -101,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. @@ -112,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 @@ -142,7 +142,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. @@ -155,7 +155,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 @@ -181,10 +181,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 @@ -200,8 +200,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() ``` @@ -222,9 +222,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) api.setFocusedElement('element-0-1') @@ -271,8 +271,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' } ``` @@ -293,9 +293,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' } ``` @@ -312,7 +312,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 } ``` @@ -334,9 +334,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' } ``` @@ -358,9 +358,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 @@ -391,12 +391,12 @@ 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 @@ -420,9 +420,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) api.handleDirectionPress('right', false) ``` From de736c3b46906dcd7bd08b983ba0461095f24aca Mon Sep 17 00:00:00 2001 From: Boris Belmar Date: Thu, 18 May 2023 13:21:44 -0400 Subject: [PATCH 5/7] Improve coverage --- src/handlers/globalFocusHandler.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/handlers/globalFocusHandler.test.ts b/src/handlers/globalFocusHandler.test.ts index ffbbf10..e445d06 100644 --- a/src/handlers/globalFocusHandler.test.ts +++ b/src/handlers/globalFocusHandler.test.ts @@ -43,10 +43,10 @@ describe('globalFocusHandler', () => { // For coverage only const event = { target: { id: 'non-existent' } } as unknown as FocusEvent state.currentElement = 'element-0-0' - delete state.elements.get('element-0-0')?._ref + 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(getCurrentElement(state)?._ref).toBe(undefined) + expect((state.adapter.getFocusedNode() as HTMLElement)?.id).not.toBe('element-0-0') }) }) From 08ed7983eec6973849031a2f03e54e8adecea5e3 Mon Sep 17 00:00:00 2001 From: Boris Belmar Date: Thu, 18 May 2023 13:45:35 -0400 Subject: [PATCH 6/7] Add resetGroupState --- README.md | 20 +++++++++ src/arrowNavigation.ts | 5 +++ src/handlers/index.ts | 1 + src/handlers/resetGroupStateHandler.test.ts | 39 +++++++++++++++++ src/handlers/resetGroupStateHandler.ts | 48 +++++++++++++++++++++ src/types.ts | 1 + 6 files changed, 114 insertions(+) create mode 100644 src/handlers/resetGroupStateHandler.test.ts create mode 100644 src/handlers/resetGroupStateHandler.ts diff --git a/README.md b/README.md index 13d632a..782e47c 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,26 @@ api.registerGroup(container.id, { }) ``` +### 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. diff --git a/src/arrowNavigation.ts b/src/arrowNavigation.ts index a68539b..3701d92 100644 --- a/src/arrowNavigation.ts +++ b/src/arrowNavigation.ts @@ -8,6 +8,7 @@ import { globalFocusHandler, registerElementHandler, registerGroupHandler, + resetGroupStateHandler, setFocusHandler, unregisterElementHandler } from './handlers' @@ -121,6 +122,10 @@ export function initArrowNavigation ({ state, emit: emitter.emit }), + resetGroupState: resetGroupStateHandler({ + state, + emit: emitter.emit + }), destroy () { if (!disableWebListeners) { window.removeEventListener('keydown', onKeyPress) diff --git a/src/handlers/index.ts b/src/handlers/index.ts index 0661cfc..5f030a2 100644 --- a/src/handlers/index.ts +++ b/src/handlers/index.ts @@ -7,3 +7,4 @@ 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/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/types.ts b/src/types.ts index df650f7..34945c2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -157,6 +157,7 @@ export type ArrowNavigationInstance = { 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 From 017767bfac1fd9c4b334365ae3bc63803d2e4282 Mon Sep 17 00:00:00 2001 From: Boris Belmar Date: Fri, 19 May 2023 00:03:01 -0400 Subject: [PATCH 7/7] 2.0.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3e5af4f..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",