@arrow-navigation/core
TypeScript icon, indicating that this package has built-in type declarations

2.2.0 • Public • Published

Arrow Navigation Core

codecov License: MIT install size

Light (~16kb) and zero-dependency module to navigate through elements using the arrow keys written in Typescript.

For live demo, visit this url. For ReactJS implementation, check @arrow-navigation/react.

Installation

npm install --save @arrow-navigation/core

# or

yarn add @arrow-navigation/core

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.

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,
  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
})

Then, you can use the getArrowNavigation to access the API.

import { getArrowNavigation } from '@arrow-navigation/core'

const navigationApi = getArrowNavigation()

// Create a container element to be able to navigate between elements inside it
const mainElement = document.createElement('main')
document.body.appendChild(mainElement)

// Is important to keep a unique id for each group element
mainElement.id = 'group-0'

// Register a group to be able to navigate between elements inside the container
navigationApi.registerGroup(mainElement)

// We create our first button element
const buttonElement = document.createElement('button')
buttonElement.textContent = 'Click me'
mainElement.appendChild(buttonElement)

// Is important to keep a unique id for each element
buttonElement.id = 'element-0-0'

// We register the element on the group to be able to navigate to it
navigationApi.registerElement(buttonElement)

// We create our second button element
const buttonElement2 = document.createElement('button')
buttonElement2.textContent = 'Click me too'
mainElement.appendChild(buttonElement2)

navigationApi.registerElement(buttonElement2)

API

initArrowNavigation

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.

const api = getArrowNavigation()

// Set the focus to the first element of the group-0
const id = getElementIdByOrder('group-0', 0) // 'group-0-0'
api.setFocusElement(id)

getArrowNavigation

Get the navigation API. This will return an object with the following methods:

registerGroup

Register a group to be able to navigate between elements inside the container.

const container = document.createElement('div')

// Is important to keep a unique id for each group and his elements
container.id = 'group-0'

registerGroup(container.id): void

You can also pass a options object as the second parameter to customize the navigation behavior.

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.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
    'up': null, // If press up, no groups will be focused
    'left': undefined // undefined will keep the default behavior
  },
  byOrder: ArrowNavigationOrder.HORIZONTAL, // Navigate by order setted on elements. Can be 'horizontal', 'vertical' or 'grid', this enum comes with ArrowNavigationOrder constant object. 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. It includes a utility function getElementIdByOrder(groupId, order): string. 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.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.
})

updateGroup

Update a registered group. This is useful when you need to update the group options.

// ...

api.updateGroup('group-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
    'up': null, // If press up, no groups will be focused
    'left': undefined // undefined will keep the default behavior
  },
  byOrder: ArrowNavigationOrder.HORIZONTAL, // Navigate by order setted on elements. Can be 'horizontal', 'vertical' or 'grid', this enum comes with ArrowNavigationOrder constant object. 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. It includes a utility function getElementIdByOrder(groupId, order): string. 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.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.
})

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.

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.

const api = getArrowNavigation()

const element = document.createElement('button')

// Is important to keep a unique id for each element
element.id = 'element-0-0'

api.registerElement(element.id, 'group-1')

You can also pass a options object as the third parameter to customize the navigation behavior.

const api = getArrowNavigation()

const element = document.createElement('button')

// Is important to keep a unique id for each element
element.id = 'element-0-0'

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
    'up': null, // If press up, no elements will be focused
    'left': undefined // undefined will keep the default behavior
  },
  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.
})

updateElement

Update a registered element. This is useful when you need to update the element options.

api.updateElement(element.id, {
  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
  },
  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.
})

unregisterElement

Unregister an element from the navigation. Is important to call this method when the element is removed from the DOM.

const api = getArrowNavigation()api.
const element = document.createElement('button')
const container = document.createElement('div')

// Is important to keep a unique id for each group and his elements
container.id = 'group-0'
element.id = 'element-0-0'

api.registerGroup(container.id)
api.registerElement(element.id, 'group-0')

api.unregisterElement(element.id)

getFocusedElement

Get the focused element.

const api = getArrowNavigation()
const element = document.createElement('button')
const container = document.createElement('div')

// Is important to keep a unique id for each group and his elements
container.id = 'group-0'
element.id = 'element-0-0'

api.registerGroup(container.id)
api.registerElement(element.id, 'group-0')

const focusedElement = api.getFocusedElement()

setFocusElement

Set the focused element.

const api = getArrowNavigation()

const container = document.createElement('div')
const element = document.createElement('button')
const element2 = document.createElement('button')

// Is important to keep a unique id for each group and his elements
container.id = 'group-0'
element.id = 'element-0-0'
element2.id = 'element-0-1'

api.registerGroup(container.id)
api.registerElement(element.id, container.id)
api.registerElement(element2.id, container.id)

api.setFocusElement('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.

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.

const api = getArrowNavigation()

api.destroy()

getCurrentGroups

Get the current groups ids (focusables).

const api = getArrowNavigation()

const container = document.createElement('div')
const container2 = document.createElement('div')

// Is important to keep a unique id for each group and his elements
container.id = 'group-0'
container2.id = 'group-1'

api.registerGroup(container.id)
api.registerGroup(container2.id)

const currentGroups = api.getCurrentGroups() // Set { 'group-0', 'group-1' }

getGroupElements

Get a Set of elements ids of a group.

const api = getArrowNavigation()

const container = document.createElement('div')
const element = document.createElement('button')
const element2 = document.createElement('button')

// Is important to keep a unique id for each group and his elements
container.id = 'group-0'
element.id = 'element-0-0'
element2.id = 'element-0-1'

api.registerGroup(container.id)
api.registerElement(element.id, container.id)
api.registerElement(element2.id, container.id)

const groupElements = api.getGroupElements('group-0') // Set { 'element-0-0', 'element-0-1' }

getGroupConfig

Get the configuration of a group.

const api = getArrowNavigation()

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.id)

const groupConfig = api.getGroupConfig('group-0') // { viewportSafe: true, threshold: 0, keepFocus: false }

getRegisteredElements

Get a Set with all registered elements ids.

const api = getArrowNavigation()

const container = document.createElement('div')
const element = document.createElement('button')
const element2 = document.createElement('button')

// Is important to keep a unique id for each group and his elements

container.id = 'group-0'
element.id = 'element-0-0'
element2.id = 'element-0-1'

api.registerGroup(container.id)
api.registerElement(element.id, 'group-0')
api.registerElement(element2.id, 'group-0')

const registeredElements = api.getRegisteredElements() // Set { 'element-0-0', 'element-0-1' }

getNextElement

Get the next best candidate element id by direction. You can pass an optional boolean option called inGroup to specify if the element should be in the same group or not. You can pass an optional elementId to specify the element where the navigation should be considered.

const api = getArrowNavigation()

const container = document.createElement('div')
const element = document.createElement('button')
const element2 = document.createElement('button')

// Is important to keep a unique id for each group and his elements

container.id = 'group-0'
element.id = 'element-0-0'
element2.id = 'element-0-1'

api.registerGroup(container.id)
api.registerElement(element, container.id)
api.registerElement(element2, container.id)

const registeredElements = api.getNextElement({ direction: 'right', inGroup: true }) // 'element-0-1'
// or
const registeredElements = api.getNextElement({ elementId: 'element-0-0', direction: 'right', inGroup: false }) // 'element-0-1'

getNextGroup

Get the next best candidate group id by direction. You can pass an optional elementId to specify the element where the navigation should be considered.

const api = getArrowNavigation()

const container = document.createElement('div')
const container2 = document.createElement('div')
const element = document.createElement('button')
const element2 = document.createElement('button')
const element3 = document.createElement('button')
const element4 = 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'
container2.id = 'group-1'
element3.id = 'element-1-0'
element4.id = 'element-1-1'

api.registerGroup(container.id)
api.registerGroup(container2.id)
api.registerElement(element.id, container.id)
api.registerElement(element2.id, container.id)
api.registerElement(element3.id, container2.id)
api.registerElement(element4.id, container2.id)

const nextGroup = api.getNextGroup({ direction: 'down' }) // 'group-1'
// or
const nextGroup = api.getNextGroup({ groupId: 'group-0', direction: 'down' }) // 'group-1'

handleDirectionPress

Handle the arrow key press. This is useful if you want to handle the arrow key press manually or React Native. The first parameter is the direction and the second parameter is a boolean to specify is a repeated key press, for example, when the user keep the key pressed. The default value is false.

const api = getArrowNavigation()

const container = document.createElement('div')
const element = document.createElement('button')
const element2 = document.createElement('button')

// Is important to keep a unique id for each group and his elements

container.id = 'group-0'
element.id = 'element-0-0'
element2.id = 'element-0-1'

api.registerGroup(container.id)
api.registerElement(element.id, container.id)
api.registerElement(element2.id, container.id)

api.handleDirectionPress('right', false)

Events

The API implements an Event Emitter to listen to events. The events are accessible through the on and off methods. All the events can be accesed through the ArrowNavigationEvents enum.

current-element:change

This event is triggered when the current element is changed. The event will receive the new element as first parameter and direction as second parameter.

current-group:change

This event is triggered when the current group is changed. The event will receive the new group as first parameter and direction as second parameter.

element:focus

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 ({ current, direction, next }).

group:focus

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 ({ current, direction, next }).

groups:change

This event is triggered when the groups are changed. The event will receive the groups as a parameter.

elements:change

This event is triggered when the elements are changed. The event will receive the elements as a parameter.

groups-config:change

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:

https://cdn.jsdelivr.net/npm/@arrow-navigation/core@<version>/dist/dist.js

<script src="https://cdn.jsdelivr.net/npm/@arrow-navigation/core/dist/dist.js"></script>
<script>
  window.arrowNavigation.init()

  const arrowNavigationApi = window.arrowNavigation.get()

  // Now you can use the arrowNavigationApi
  // ...
</script>

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:

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

Package Sidebar

Install

npm i @arrow-navigation/core

Weekly Downloads

70

Version

2.2.0

License

MIT

Unpacked Size

82.8 kB

Total Files

10

Last publish

Collaborators

  • borisbelmar