Skip to content

Commit

Permalink
Merge pull request #28 from borisbelmar/develop
Browse files Browse the repository at this point in the history
1.2.8
  • Loading branch information
borisbelmar authored May 8, 2023
2 parents 077dec8 + c03e116 commit 63f6e0d
Show file tree
Hide file tree
Showing 43 changed files with 1,033 additions and 167 deletions.
20 changes: 14 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (~10kb) and zero-dependency module to navigate through elements using the arrow keys written in Typescript.
Light (~13kb) and zero-dependency module to navigate through elements using the arrow keys written in Typescript.

## Installation

Expand All @@ -16,6 +16,10 @@ npm install --save @arrow-navigation/core
yarn add @arrow-navigation/core
```

## What is new?

- **v1.2.8**: Add the `byOrder` option on groups. Now you can navigate by order setted on elements. Can be 'horizontal', 'vertical' or 'grid'. You can also set the number of columns to navigate when the byOrder is 'grid' using the `cols` option.

## Usage

At the top of your application, you need to initialize the module. This will add the event listeners to the document and store the navigation state in a singleton instance.
Expand Down Expand Up @@ -100,11 +104,13 @@ api.registerGroup(container, {
'up': null, // If press up, no groups will be focused
'left': undefined // undefined will keep the default behavior
},
byOrder: 'horizontal', // Navigate by order setted on elements. Can be 'horizontal', 'vertical' or 'grid'. Take care with this option, because this will change the id of the elements, for example, for group-0, the element in order 1 will be group-0-1. Keep this in mind if you are using the id of the elements for firstElement or nextByDirection options.
cols: 2, // The number of columns to navigate when the byOrder is 'grid'. The default value is 1 and you can set a object with the number of columns for each breakpoint. For example: { 0: 1, 768: 2, 1024: 3 }
saveLast: true, // Save the last focused element when the focus leave the group and use it when the focus enter again
viewportSafe: true, // If true, the next element will be the first element that is visible in the viewport. The default value is true
threshold: 2, // The threshold in pixels to consider an element eligible to be focused. The default value is 0
onFocus: ({ current, prev, direction }) => { console.log(`focused ${current.el.id}`) }, // Callback when the group is focused. The prev group is the group that was focused before the current group.
onBlur: ({ current, next, direction }) => { console.log(`blurred ${current.el.id}`) }, // Callback when the group is blurred. The next group is the group that will be focused when the focus leave the current group.
onFocus: ({ current, prev, direction }) => { console.log(`focused ${current.id}`) }, // Callback when the group is focused. The prev group is the group that was focused before the current group.
onBlur: ({ current, next, direction }) => { console.log(`blurred ${current.id}`) }, // Callback when the group is blurred. The next group is the group that will be focused when the focus leave the current group.
keepFocus: true // If true, the focus will not leave the group when the user press the arrow keys. The default value is false. This option is usefull for modals or other elements that need to keep the focus inside.
})
```
Expand Down Expand Up @@ -135,13 +141,15 @@ const element = document.createElement('button')
element.id = 'element-0-0'

api.registerElement(element, 'group-1', {
nextElementByDirection: { // This will set the next element manually
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
'up': null, // If press up, no elements will be focused
'left': undefined // undefined will keep the default behavior
},
onFocus: ({ current, prev, direction }) => console.log(`focused ${current.el.id}`), // Callback when the element is focused. The prev element is the element that was focused before the current element.
onBlur: ({ current, next, direction }) => console.log(`blurred ${current.el.id}`) // Callback when the element is blurred. The next element is the element that will be focused when the focus leave the current element.
order: 0, // The order of the element. No default value. This is needed when the group is setted to navigate byOrder. If no setted, byOrder will be ignored.
onFocus: ({ current, prev, direction }) => console.log(`focused ${current.id}`), // Callback when the element is focused. The prev element is the element that was focused before the current element.
onBlur: ({ current, next, direction }) => console.log(`blurred ${current.id}`) // Callback when the element is blurred. The next element is the element that will be focused when the focus leave the current element.
})
```

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@arrow-navigation/core",
"version": "1.2.7",
"version": "1.2.8",
"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",
Expand Down
85 changes: 48 additions & 37 deletions src/__mocks__/viewNavigationState.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,67 +6,78 @@ export default function getViewNavigationStateMock (): ArrowNavigationState {

const getSquareElement = (id: string, group: string, x: number, y: number) => {
const focusableElement = {
id,
el: getHtmlElementMock({ id, x, y, width: 10, height: 10 }),
group
}
elements.set(focusableElement.el.id, focusableElement)
elements.set(focusableElement.id, focusableElement)
return focusableElement
}

const groups = new Map<string, FocusableGroup>()
const groupsConfig = new Map<string, FocusableGroupConfig>()

const group1: FocusableGroup = {
id: 'group-0',
el: getHtmlElementMock({ id: 'group-0', x: 0, y: 0, width: 16, height: 52 }),
elements: new Map()
}
group1.elements.set('element-0-0', getSquareElement('element-0-0', 'group-0', 3, 3))
group1.elements.set('element-0-1', getSquareElement('element-0-1', 'group-0', 3, 15))
group1.elements.set('element-0-2', getSquareElement('element-0-2', 'group-0', 3, 27))
group1.elements.set('element-0-3', getSquareElement('element-0-3', 'group-0', 3, 39))
groups.set(group1.el.id, group1)
groupsConfig.set(group1.el.id, { el: group1.el })
elements: new Set()
};
[0, 1, 2, 3].forEach(i => {
const id = `element-0-${i}`
getSquareElement(id, 'group-0', 3, 3 + (12 * i))
group1.elements.add(id)
})
groups.set(group1.id, group1)
groupsConfig.set(group1.id, { el: group1.el, id: group1.id })

const group2: FocusableGroup = {
id: 'group-1',
el: getHtmlElementMock({ id: 'group-1', x: 16, y: 0, width: 52, height: 16 }),
elements: new Map()
}
group2.elements.set('element-1-0', getSquareElement('element-1-0', 'group-1', 19, 3))
group2.elements.set('element-1-1', getSquareElement('element-1-1', 'group-1', 31, 3))
group2.elements.set('element-1-2', getSquareElement('element-1-2', 'group-1', 43, 3))
group2.elements.set('element-1-3', getSquareElement('element-1-3', 'group-1', 55, 3))
groups.set(group2.el.id, group2)
groupsConfig.set(group2.el.id, { el: group2.el })
elements: new Set()
};
[0, 1, 2, 3].forEach(i => {
const id = `element-1-${i}`
getSquareElement(id, 'group-1', 19 + (12 * i), 3)
group2.elements.add(id)
})
groups.set(group2.id, group2)
groupsConfig.set(group2.id, { el: group2.el, id: group2.id })

const group3: FocusableGroup = {
id: 'group-2',
el: getHtmlElementMock({ id: 'group-2', x: 16, y: 18, width: 52, height: 16 }),
elements: new Map()
}
group3.elements.set('element-2-0', getSquareElement('element-2-0', 'group-2', 19, 21))
group3.elements.set('element-2-1', getSquareElement('element-2-1', 'group-2', 31, 21))
group3.elements.set('element-2-2', getSquareElement('element-2-2', 'group-2', 43, 21))
group3.elements.set('element-2-3', getSquareElement('element-2-3', 'group-2', 55, 21))
groups.set(group3.el.id, group3)
groupsConfig.set(group3.el.id, { el: group3.el })
elements: new Set()
};
[0, 1, 2, 3].forEach(i => {
const id = `element-2-${i}`
getSquareElement(id, 'group-2', 19 + (12 * i), 21)
group3.elements.add(id)
})
groups.set(group3.id, group3)
groupsConfig.set(group3.id, { el: group3.el, id: group3.id })

const group4: FocusableGroup = {
id: 'group-3',
el: getHtmlElementMock({ id: 'group-3', x: 16, y: 36, width: 52, height: 16 }),
elements: new Map()
}
group4.elements.set('element-3-0', getSquareElement('element-3-0', 'group-3', 19, 39))
group4.elements.set('element-3-1', getSquareElement('element-3-1', 'group-3', 31, 39))
group4.elements.set('element-3-2', getSquareElement('element-3-2', 'group-3', 43, 39))
group4.elements.set('element-3-3', getSquareElement('element-3-3', 'group-3', 55, 39))
groups.set(group4.el.id, group4)
groupsConfig.set(group4.el.id, { el: group4.el })
elements: new Set()
};
[0, 1, 2, 3].forEach(i => {
const id = `element-3-${i}`
getSquareElement(id, 'group-3', 19 + (12 * i), 39)
group4.elements.add(id)
})
groups.set(group4.id, group4)
groupsConfig.set(group4.id, { el: group4.el, id: group4.id })

const group5: FocusableGroup = {
id: 'group-4',
el: getHtmlElementMock({ id: 'group-4', x: 0, y: 52, width: 16, height: 16 }),
elements: new Map()
elements: new Set()
}
group5.elements.set('element-4-0', getSquareElement('element-4-0', 'group-4', 3, 55))
groups.set(group5.el.id, group5)
groupsConfig.set(group5.el.id, { el: group5.el })
getSquareElement('element-4-0', 'group-4', 3, 55)
group5.elements.add('element-4-0')
groups.set(group5.id, group5)
groupsConfig.set(group5.id, { el: group5.el, id: group5.id })
return {
currentElement: 'element-0-0',
elements,
Expand Down
8 changes: 4 additions & 4 deletions src/arrowNavigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,11 @@ describe('arrowNavigation', () => {

navigationApi._forceNavigate('ArrowDown')

expect(state.groups.get('group-0')?.elements.get('element-0-1')?.el.focus).toHaveBeenCalled()
expect(state.elements.get('element-0-1')?.el.focus).toHaveBeenCalled()

navigationApi._forceNavigate('ArrowDown')

expect(state.groups.get('group-0')?.elements.get('element-0-2')?.el.focus).toHaveBeenCalled()
expect(state.elements.get('element-0-2')?.el.focus).toHaveBeenCalled()
})

it('should not forceNavigate if debug is disabled', () => {
Expand All @@ -74,7 +74,7 @@ describe('arrowNavigation', () => {

navigationApi._forceNavigate('ArrowDown')

expect(state.groups.get('group-0')?.elements.get('element-0-1')?.el.focus).not.toHaveBeenCalled()
expect(state.elements.get('element-0-1')?.el.focus).not.toHaveBeenCalled()
})

it('should return the focused element', () => {
Expand Down Expand Up @@ -217,6 +217,6 @@ describe('arrowNavigation', () => {
navigationApi._forceNavigate('ArrowDown')

expect(listener).toHaveBeenCalledWith('last')
expect(navigationApi.getFocusedElement()?.el.id).toBe('element-0-3')
expect(navigationApi.getFocusedElement()?.id).toBe('element-0-3')
})
})
4 changes: 2 additions & 2 deletions src/arrowNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export function initArrowNavigation ({

const changeFocusElementHandler = (nextElement: FocusableElement, direction?: Direction) => {
const prevElement = getCurrentElement(state) as FocusableElement
state.currentElement = nextElement.el.id
state.currentElement = nextElement.id
nextElement.el.focus()
changeFocusEventHandler({
nextElement,
Expand Down Expand Up @@ -80,7 +80,7 @@ export function initArrowNavigation ({
return new Set(state.groups.keys())
},
getGroupElements (group: string) {
return new Set(state.groups.get(group)?.elements.keys() || [])
return new Set(state.groups.get(group)?.elements.keys())
},
getGroupConfig (group: string) {
return state.groupsConfig.get(group)
Expand Down
18 changes: 8 additions & 10 deletions src/handlers/changeFocusEventHandler.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import EVENTS from '@/config/events'
import createEventEmitter, { EventEmitter } from '@/utils/createEventEmitter'
import getCurrentElement from '@/utils/getCurrentElement'
import { ArrowNavigationState, FocusableElement, FocusableGroup, FocusableGroupConfig } from '../types'
import { ArrowNavigationState, FocusableElement, FocusableGroupConfig } from '../types'
import getViewNavigationStateMock from '../__mocks__/viewNavigationState.mock'
import changeFocusEventHandler from './changeFocusEventHandler'

Expand Down Expand Up @@ -32,8 +32,8 @@ describe('changeFocusEventHandler', () => {
})

it('should call onElementFocus, onElementBlur, onGroupBlur and onGroupFocus correctly', () => {
const prevElement = state.groups.get('group-0')?.elements.get('element-0-0') as FocusableElement
const nextElement = state.groups.get('group-1')?.elements.get('element-1-0') as FocusableElement
const prevElement = state.elements.get('element-0-0') as FocusableElement
const nextElement = state.elements.get('element-1-0') as FocusableElement

const events = {
onElementFocus: jest.fn(),
Expand Down Expand Up @@ -62,17 +62,15 @@ describe('changeFocusEventHandler', () => {
})

it('should call onFocus and onBlur on group and element', () => {
const currentGroup = state.groups.get('group-0') as FocusableGroup
const currentGroupConfig = state.groupsConfig.get('group-0') as FocusableGroupConfig
const prevElement = currentGroup.elements.get('element-0-0') as FocusableElement
const prevElement = state.elements.get('element-0-0') as FocusableElement
currentGroupConfig.onFocus = jest.fn()
currentGroupConfig.onBlur = jest.fn()
prevElement.onFocus = jest.fn()
prevElement.onBlur = jest.fn()

const nextGroup = state.groups.get('group-1') as FocusableGroup
const nextGroupConfig = state.groupsConfig.get('group-1') as FocusableGroupConfig
const nextElement = nextGroup.elements.get('element-1-0') as FocusableElement
const nextElement = state.elements.get('element-1-0') as FocusableElement
nextGroupConfig.onFocus = jest.fn()
nextGroupConfig.onBlur = jest.fn()
nextElement.onFocus = jest.fn()
Expand Down Expand Up @@ -115,7 +113,7 @@ describe('changeFocusEventHandler', () => {

it('should not call onBlur if no prevGroup', () => {
const prevElement = null
const nextElement = state.groups.get('group-1')?.elements.get('element-1-0') as FocusableElement
const nextElement = state.elements.get('element-1-0') as FocusableElement

const events = {
onElementFocus: jest.fn(),
Expand Down Expand Up @@ -149,8 +147,8 @@ describe('changeFocusEventHandler', () => {

it('should save the last element of the group if saveLast is true', () => {
const currentGroupConfig = state.groupsConfig.get('group-0') as FocusableGroupConfig
const nextElement = state.groups.get('group-1')?.elements.get('element-1-0') as FocusableElement
const prevElement = state.groups.get('group-0')?.elements.get('element-0-0') as FocusableElement
const nextElement = state.elements.get('element-1-0') as FocusableElement
const prevElement = state.elements.get('element-0-0') as FocusableElement
currentGroupConfig.saveLast = true
currentGroupConfig.lastElement = undefined

Expand Down
4 changes: 2 additions & 2 deletions src/handlers/changeFocusEventHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@ export default function changeFocusEventHandler ({

if (prevGroup) {
if (prevGroup.saveLast) {
state.groupsConfig.set(prevGroup.el.id, {
state.groupsConfig.set(prevGroup.id, {
...prevGroup,
lastElement: (prevElement as FocusableElement).el.id
lastElement: (prevElement as FocusableElement).id
})
}
prevGroup.onBlur?.({
Expand Down
2 changes: 1 addition & 1 deletion src/handlers/getNextElementHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@ export default function getNextElementHandler (state: ArrowNavigationState) {
direction,
state,
inGroup
})?.el.id ?? null
})?.id ?? null
}
2 changes: 1 addition & 1 deletion src/handlers/getNextGroupHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,5 @@ export default function getNextGroupHandler (state: ArrowNavigationState) {
fromElement: state.elements.get(elementId || '') as FocusableElement,
direction,
state
})?.group.el.id ?? null
})?.group.id ?? null
}
2 changes: 1 addition & 1 deletion src/handlers/globalFocusHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ const globalFocusHandler = (state: ArrowNavigationState, event: FocusEvent) => {
const target = event.target as HTMLElement
const currentElement = getCurrentElement(state)
if (!currentElement) return
if (target && target.id !== currentElement.el.id) {
if (target && target.id !== currentElement.id) {
currentElement.el.focus()
}
}
Expand Down
36 changes: 34 additions & 2 deletions src/handlers/registerElementHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ describe('registerElementHandler', () => {
registerElement(element, groupId)

expect(state.elements.has(element.id)).toBe(true)
expect(state.groups.get(groupId)?.elements.get(element.id)?.el).toBe(element)
expect(state.elements.get(element.id)?.el).toBe(element)
expect(state.groups.get('group-0')?.elements.has(element.id)).toBe(true)
})

it('should throw an error if the element id is not defined', () => {
Expand Down Expand Up @@ -76,7 +77,7 @@ describe('registerElementHandler', () => {
element.id = 'element-5-0'
registerElement(element, 'group-5')

expect(onChangeElement).toHaveBeenCalledWith({ el: element, group: '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', () => {
Expand All @@ -94,6 +95,7 @@ describe('registerElementHandler', () => {
group.id = 'group-10'

state.groupsConfig.set(group.id, {
id: group.id,
el: group
})

Expand All @@ -103,4 +105,34 @@ describe('registerElementHandler', () => {

expect(state.groups.get('group-10')?.el).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,
byOrder: 'horizontal'
})
const registerElement = registerElementHandler(state, onChangeElement, emitter.emit)

const element = document.createElement('button')
registerElement(element, '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)?.id).toBe('group-6-0')
expect(state.groups.get('group-6')?.elements.has('group-6-0')).toBe(true)
})

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,
byOrder: 'horizontal'
})
const registerElement = registerElementHandler(state, onChangeElement, emitter.emit)

const element = document.createElement('button')
expect(() => registerElement(element, 'group-6')).toThrowError(ERROR_MESSAGES.ELEMENT_ID_REQUIRED)
})
})
Loading

0 comments on commit 63f6e0d

Please sign in to comment.