Norigin Spatial Navigation is an open-source library that enables navigating between focusable elements built with ReactJS based application software. To be used while developing applications that require key navigation (directional navigation) on Web-browser Apps and other Browser based Smart TVs and Connected TVs. Our goal is to make navigation on websites & apps easy, using React Javascript Framework and React Hooks. Navigation can be controlled by your keyboard (browsers) or Remote Controls (Smart TV or Connected TV). Software developers only need to initialise the service, add the Hook to components that are meant to be focusable and set the initial focus. The Spatial Navigation library will automatically determine which components to focus next while navigating with the directional keys. We keep the library light, simple, and with minimal third-party dependencies.
Norigin Spatial Navigation can be used while working with Key Navigation and React JS. This library allows you to navigate across or focus on all navigable components while browsing. For example: hyperlinks, buttons, menu items or any interactible part of the User Interface according to the spatial location on the screen.
The Norigin Spatial Navigation library is theoretically intended to work on any web-based platform such as Browsers and Smart TVs. For as long as the UI/UX is built with the React Framework, it works on the Samsung Tizen TVs, LG WebOS TVs, Hisense Vidaa TVs and a range of other Connected TVs. It can also be used in React Native apps on Android TV and Apple TV, however functionality will be limited. This library is actively used and continuously tested on many devices and updated periodically in the table below:
Platform | Name |
---|---|
Web Browsers | Chrome, Firefox, etc. |
Smart TVs | Samsung Tizen, LG WebOS, Hisense |
Other Connected TV devices | Browser Based settop boxes with Chromium, Ekioh or Webkit browsers |
AndroidTV, AppleTV | Only React Native apps, limited functionality |
A list of changes for all the versions for the Norigin Spatial Navigation: CHANGELOG.md
npm i @noriginmedia/norigin-spatial-navigation --save
// Called once somewhere in the root of the app
import { init } from '@noriginmedia/norigin-spatial-navigation';
init({
// options
});
Starting from version 2.2.0
, you can configure the method used for distance calculations between focusable components. This can be set during initialization using the distanceCalculationMethod
option.
edges
: Calculates distances using the closest edges of the components.center
: Calculates distances using the center points of the components for size-agnostic comparisons. Ideal for non-uniform elements between siblings.corners
: Calculates distances using the corners of the components, between the nearest corners. This is the default value.import { init } from '@noriginmedia/norigin-spatial-navigation';
init({
// options
distanceCalculationMethod: 'center', // or 'edges' or 'corners' (default)
});
In addition to the predefined distance calculation methods, you can define your own custom distance calculation function. This will override the getSecondaryAxisDistance
method.
You can pass your custom distance calculation function during initialization using the customDistanceCalculationFunction option. This function will override the built-in methods.
The custom distance calculation function should follow the DistanceCalculationFunction type signature:
type DistanceCalculationFunction = (
refCorners: Corners,
siblingCorners: Corners,
isVerticalDirection: boolean,
distanceCalculationMethod: DistanceCalculationMethod
) => number;
import { init } from '@noriginmedia/norigin-spatial-navigation';
// Define a custom distance calculation function
const myCustomDistanceCalculationFunction = (refCorners, siblingCorners, isVerticalDirection, distanceCalculationMethod) => {
// Custom logic for distance calculation
const { a: refA, b: refB } = refCorners;
const { a: siblingA, b: siblingB } = siblingCorners;
const coordinate = isVerticalDirection ? 'x' : 'y';
const refCoordinateCenter = (refA[coordinate] + refB[coordinate]) / 2;
const siblingCoordinateCenter = (siblingA[coordinate] + siblingB[coordinate]) / 2;
return Math.abs(refCoordinateCenter - siblingCoordinateCenter);
};
// Initialize with custom distance calculation function
init({
// options
customDistanceCalculationFunction: myCustomDistanceCalculationFunction,
});
Most commonly you will have Leaf Focusable components. (See Tree Hierarchy)
Leaf component is the one that doesn't have focusable children.
ref
is required to link the DOM element with the hook. (to measure its coordinates, size etc.)
import { useFocusable } from '@noriginmedia/norigin-spatial-navigation';
function Button() {
const { ref, focused } = useFocusable();
return (<div ref={ref} className={focused ? 'button-focused' : 'button'}>
Press me
</div>);
}
Focusable Container is the one that has other focusable children. (i.e. a scrollable list) (See Tree Hierarchy)
ref
is required to link the DOM element with the hook. (to measure its coordinates, size etc.)
FocusContext.Provider
is required in order to provide all children components with the focusKey
of the Container,
which serves as a Parent Focus Key for them. This way your focusable children components can be deep in the DOM tree
while still being able to know who is their Focusable Parent.
Focusable Container cannot have focused
state, but instead propagates focus down to appropriate Child component.
You can nest multiple Focusable Containers. When focusing the top level Container, it will propagate focus down until it encounters the first Leaf component.
I.e. if you set focus to the Page
, the focus could propagate as following: Page
-> ContentWrapper
-> ContentList
-> ListItem
.
import { useFocusable, FocusContext } from '@noriginmedia/norigin-spatial-navigation';
import ListItem from './ListItem';
function ContentList() {
const { ref, focusKey } = useFocusable();
return (<FocusContext.Provider value={focusKey}>
<div ref={ref}>
<ListItem />
<ListItem />
<ListItem />
</div>
</FocusContext.Provider>);
}
You can manually set the focus either to the current component (focusSelf
), or to any other component providing its focusKey
to setFocus
.
It is useful when you first open the page, or i.e. when your modal Popup gets mounted.
import React, { useEffect } from 'react';
import { useFocusable, FocusContext, setFocus } from '@noriginmedia/norigin-spatial-navigation';
function Popup() {
const { ref, focusKey, focusSelf } = useFocusable();
// Focusing self will focus the Popup, which will pass the focus down to the first Child (ButtonPrimary)
// Alternatively you can manually focus any other component by its 'focusKey'
useEffect(() => {
focusSelf();
// alternatively
// setFocus('BUTTON_PRIMARY');
}, [focusSelf]);
return (<FocusContext.Provider value={focusKey}>
<div ref={ref}>
<ButtonPrimary focusKey={'BUTTON_PRIMARY'} />
<ButtonSecondary />
</div>
</FocusContext.Provider>);
}
Any Focusable Container can track whether it has any Child focused or not. This feature is disabled by default,
but it can be controlled by the trackChildren
flag passed to the useFocusable
hook. When enabled, the hook will return
a hasFocusedChild
flag indicating when a Container component is having focused Child down in the focusable Tree.
It is useful for example when you want to style a container differently based on whether it has focused Child or not.
import { useFocusable, FocusContext } from '@noriginmedia/norigin-spatial-navigation';
import MenuItem from './MenuItem';
function Menu() {
const { ref, focusKey, hasFocusedChild } = useFocusable({trackChildren: true});
return (<FocusContext.Provider value={focusKey}>
<div ref={ref} className={hasFocusedChild ? 'menu-expanded' : 'menu-collapsed'}>
<MenuItem />
<MenuItem />
<MenuItem />
</div>
</FocusContext.Provider>);
}
Sometimes you don't want the focus to leave your component, for example when displaying a Popup, you don't want the focus to go to
a component underneath the Popup. This can be enabled with isFocusBoundary
flag passed to the useFocusable
hook.
Additionally focusBoundaryDirections
array can be provided to restrict focus movement only in specific directions.
That might be useful when defining focus container for menu bar. Please note, that focusBoundaryDirections
requires
isFocusBoundary
to be set to true
.
import React, { useEffect } from 'react';
import { useFocusable, FocusContext } from '@noriginmedia/norigin-spatial-navigation';
function Popup() {
const { ref, focusKey, focusSelf } = useFocusable({isFocusBoundary: true, focusBoundaryDirections: ['up', 'down']});
useEffect(() => {
focusSelf();
}, [focusSelf]);
return (<FocusContext.Provider value={focusKey}>
<div ref={ref}>
<ButtonPrimary />
<ButtonSecondary />
</div>
</FocusContext.Provider>);
}
In React Native environment the navigation between focusable (Touchable) components is happening under the hood by the
native focusable engine. This library is NOT doing any coordinates measurements or navigation decisions in the native environment.
But it can still be used to keep the currently focused element node reference and its focused state, which can be used to
highlight components based on the focused
or hasFocusedChild
flags.
import { TouchableOpacity, Text } from 'react-native';
import { useFocusable } from '@noriginmedia/norigin-spatial-navigation';
function Button() {
const { ref, focused, focusSelf } = useFocusable();
return (<TouchableOpacity
ref={ref}
onFocus={focusSelf}
style={focused ? styles.buttonFocused : styles.button}
>
<Text>Press me</Text>
</TouchableOpacity>);
}
IMPORTANT TO NOTE:
onFocus
callback needs to be linked with the focusSelf
method. This way the hook will know that the component became focused and will set the focused
flag accordingly.init
debug
: boolean (default: false)Enables console debugging.
visualDebug
: boolean (default: false)Enables visual debugging (all layouts, reference points and siblings reference points are printed on canvases).
nativeMode
: boolean (default: false)Enables Native mode. It will disable certain web-only functionality:
onFocus
and onBlur
callbacks don't return coordinates, but still return node ref which can be used to measure layout if neededsmartNavigate
in SpatialNavigation.ts
)navigateByDirection
In other words, in the Native mode this library DOES NOT set the native focus anywhere via the native focus engine.
Native mode should be only used to keep the Tree of focusable components and to set the focused
and hasFocusedChild
flags to enable styling for focused components and containers.
In Native mode you can only call focusSelf
in the component that gets native focus (via onFocus
callback of the Touchable
components) to flag it as focused
.
Manual setFocus
method is blocked because it will not propagate to the native focus engine and won't do anything.
throttle
: integer (default: 0)Enables throttling of the key event listener.
throttleKeypresses
: boolean (default: false)Works only in combination with throttle
> 0. By default, throttle
only throttles key down events (i.e. when you press and hold the button).
When this feature is enabled, it will also throttle rapidly fired key presses (rapid "key down + key up" events).
useGetBoundingClientRect
: boolean (default: false)This flag enables using getBoundingClientRect
for measuring element sizes and positions. Default behavior is using DOM offset
values.
The difference is that getBoundingClientRect
will measure coordinates and sizes post CSS transforms, while offset
measurement will measure them pre CSS transforms.
For example if you have an element with translateX(50)
, the X position with the default measurement will still be 0, while getBoundingClientRect
measurement will result in X being 50.
The choice depends on how often you are using transforms, and whether you want it to result into coordinates shift or not.
Sometimes you would want element to be visually translated, but its coordinates to be calculated as it was before the translation.
shouldFocusDOMNode
: boolean (default: false)This flag makes the underlying accessible DOM node to become focused as well. This is useful for accessible web applications.
Note that it is the developer's responsibility to make the elements accessible! There are many resources online on the subject. HTML Semantics and Accessibility Cheat Sheet is perhaps a good start,
as it dives directly into the various html tags and how it complies with accessibility. Non-accessible tags like <div>
needs to have the tabindex attribute set.
Also consider role
and aria-label
attributes. But that depends on the application.
FocusOptions can be set using the domNodeFocusOptions
option documented below.
This flag is ignored if nativeMode
is set to true
.
domNodeFocusOptions
: FocusOptions (default: {}
)This object is passed to the focus
method of the underlying accessible DOM node when shouldFocusDOMNode
is enabled.
shouldUseNativeEvents
: boolean (default: false)This flag, when set to true, enables the use of native events for triggering actions, such as clicks or key presses. For instance, the onClick method will be triggered while pressing the enterKey, as well as cliicking the element itself. It is particularly beneficial for enhancing the accessibility of web applications. When shouldUseNativeEvents is active, the underlying accessible DOM node becomes the focus of the event.
rtl
: boolean (default: false)This flag changes focus behavior for layouts in right-to-left (RTL) languages such as Arabic and Hebrew.
setKeyMap
Method to set custom key codes (numbers) or key event names (strings) MDN Docs. I.e. when the device key codes differ from a standard browser arrow key codes.
setKeyMap({
left: 9001, // or 'ArrowLeft'
up: 9002, // or 'ArrowUp'
right: 9003, // or 'ArrowRight'
down: 9004, // or 'ArrowDown'
enter: 9005 // or 'Enter'
});
There is also support for mapping multiple key codes to a single direction. This can be useful when working with gamepads that utilize a joystick and a directional pad and you want to make use of both.
setKeyMap({
left: [205, 214],
up: [203, 211],
right: [206, 213],
down: [204, 212],
enter: [195]
});
setThrottle
A method for dynamically updating throttle
and throttleKeypresses
values. This might be useful if you want to throttle listeners under specific sections or pages.
setThrottle({
throttle: 500,
throttleKeypresses: true
});
destroy
Resets all the settings and the storage of focusable components. Disables the navigation service.
doesFocusableExist
(function) (focusKey: string) => boolean
Returns true
if Focusable Container with given focusKey
is created, false
otherwise.
setFocus
(function) (focusKey: string) => void
Method to manually set the focus to a component providing its focusKey
. If focusKey
is not provided or
is equal to ROOT_FOCUS_KEY
, an attempt of focusing one of the force-focusable components is made.
See useFocusable
hook forceFocus
parameter for more details.
getCurrentFocusKey
(function) () => string
Returns the currently focused component's focus key.
navigateByDirection
(function) (direction: string, focusDetails: FocusDetails) => void
Method to manually navigation to a certain direction. I.e. you can assign a mouse-wheel to navigate Up and Down. Also useful when you have some "Arrow-like" UI in the app that is meant to navigate in certain direction when pressed with the mouse or a "magic remote" on some TVs.
pause
(function)Pauses all the key event handlers.
resume
(function)Resumes all the key event handlers.
updateAllLayouts
(function)Manually recalculate all the layouts. Rarely used.
useFocusable
hookThis hook is the main link between the React component (its DOM element) and the navigation service.
It is used to register the component in the service, get its focusKey
, focused
state etc.
const {/* hook output */ } = useFocusable({/* hook params */ });
focusable
(default: true)This flag indicates that the component can be focused via directional navigation.
Even if the component is not focusable
, it still can be focused with the manual setFocus
.
This flag is useful when i.e. you have a Disabled Button that should not be focusable in the disabled state.
saveLastFocusedChild
(default: true)By default, when the focus leaves a Focusable Container, the last focused child of that container is saved. So the next time when you go back to that Container, the last focused child will get the focus. If this feature is disabled, the focus will be always on the first available child of the Container.
trackChildren
(default: false)This flag controls the feature of updating the hasFocusedChild
flag returned to the hook output.
Since you don't always need hasFocusedChild
value, this feature is disabled by default for optimization purposes.
autoRestoreFocus
(default: true)By default, when the currently focused component is unmounted (deleted), navigation service will try to restore the focus
on the nearest available sibling of that component. If this behavior is undesirable, you can disable it by setting this
flag to false
.
forceFocus
(default: false)This flag makes the Focusable Container force-focusable. When there's more than one force-focusable component,
the closest to the top left viewport corner (0,0) is force-focused. Such containers can be force-focused when there's
no currently focused component (or focusKey
points to not existing component) when navigating with arrows.
Also, when focusKey
provided to setFocus
is not defined or equal to ROOT_FOCUS_KEY
.
In other words, if focus is lost, it can be restored to one of force-focusable components by navigating with arrows
or by focusing ROOT_FOCUS_KEY
.
isFocusBoundary
(default: false)This flag makes the Focusable Container keep the focus inside its boundaries. It will only block the focus from leaving
the Container via directional navigation. You can still set the focus manually anywhere via setFocus
.
Useful when i.e. you have a modal Popup and you don't want the focus to leave it.
focusBoundaryDirections
(optional)This flag sets in which directions focus is blocked from leaving Focusable Container via directional navigation.
It accepts an array containing left
, right
, up
and/or down
values. If not specified, all directions are blocked.
It requires isFocusBoundary
to be enabled to take effect.
focusKey
(optional)If you want your component to have a persistent focus key, it can be set via this property. Otherwise, it will be auto generated.
Useful when you want to manually set the focus to this component via setFocus
.
preferredChildFocusKey
(optional)Useful when you have a Focusable Container and you want it to propagate the focus to a specific child component. I.e. when you have a Popup and you want some specific button to be focused instead of the first available.
onEnterPress
(function)Callback that is called when the component is focused and Enter key is pressed.
Receives extraProps
(see below) and KeyPressDetails
as arguments.
onEnterRelease
(function)Callback that is called when the component is focused and Enter key is released.
Receives extraProps
(see below) as argument.
onArrowPress
(function)Callback that is called when component is focused and any Arrow key is pressed.
Receives direction
(left
, right
, up
, down
), extraProps
(see below) and KeyPressDetails
as arguments.
This callback HAS to return true
if you want to proceed with the default directional navigation behavior, or false
if you want to block the navigation in the specified direction.
onFocus
(function)Callback that is called when component gets focus.
Receives FocusableComponentLayout
, extraProps
and FocusDetails
as arguments.
onBlur
(function)Callback that is called when component loses focus.
Receives FocusableComponentLayout
, extraProps
and FocusDetails
as arguments.
extraProps
(optional)An object that can be passed to the hook in order to be passed back to certain callbacks (see above).
I.e. you can pass all the props
of the component here, and get them all back in those callbacks.
ref
(required)Reference object created by the useRef
inside the hook. Should be assigned to the DOM element representing a focused
area for this component. Usually it's a root DOM element of the component.
function Button() {
const { ref } = useFocusable();
return (<div ref={ref}>
Press me
</div>);
}
focusSelf
(function)Method to set the focus on the current component. I.e. to set the focus to the Page (Container) when it is mounted, or the Popup component when it is displayed.
focused
(boolean)Flag that indicates that the current component is focused.
hasFocusedChild
(boolean)Flag that indicates that the current component has a focused child somewhere down the Focusable Tree.
Only works when trackChildren
is enabled!
focusKey
(string)String that contains the focus key for the component. It is either the same as focusKey
passed to the hook params,
or an automatically generated one.
FocusContext
(required for Focusable Containers)Used to provide the focusKey
of the current Focusable Container down the Tree to the next child level. See Example
FocusableComponentLayout
interface FocusableComponentLayout {
left: number; // absolute coordinate on the screen
top: number; // absolute coordinate on the screen
readonly right: number; // absolute coordinate on the screen
readonly bottom: number; // absolute coordinate on the screen
width: number;
height: number;
x: number; // relative to the parent DOM element
y: number; // relative to the parent DOM element
node: HTMLElement; // or the reference to the native component in React Native
}
KeyPressDetails
interface KeyPressDetails {
pressedKeys: PressedKeys;
}
PressedKeys
type PressedKeys = { [index: string]: number };
FocusDetails
interface FocusDetails {
event?: KeyboardEvent;
}
These types are exported, but not necessarily needed for development.
KeyMap
Interface for the keyMap
sent to the setKeyMap
method.
UseFocusableConfig
Interface for the useFocusable
params object.
UseFocusableResult
Interface for the useFocusable
result object.
As mentioned in the Usage section, all focusable components are organized in a Tree structure. Much like a DOM
tree, the Focusable Tree represents a focusable components' organization in your application. Tree Structure helps to
organize all the focusable areas in the application, measure them and determine the best paths of navigation between
these focusable areas. Without the Tree Structure (assuming all components would be simple Leaf focusable components) it
would be extremely hard to measure relative and absolute coordinates of the elements inside the scrolling lists, as well
as to restrict the focus from jumping outside certain areas. Technically the Focusable Tree structure is achieved by
passing a focus key of the parent component down via the FocusContext
. Since React Context can be nested, you can have
multiple layers of focusable Containers, each passing their own focusKey
down the Tree via FocusContext.Provider
as
shown in this example.
Navigation Service is a "brain" of the library. It is responsible for registering each focusable component in its internal database, storing the node references to measure their coordinates and sizes, and listening to the key press events in order to perform the navigation between these components. The calculation is performed according to the proprietary algorithm, which measures the coordinate of the current component and all components in the direction of the navigation, and determines the best path to pass the focus to the next component.
The main reason to finally migrate to Hooks is the deprecation of the recompose
library that was a backbone for the old
HOC implementation. As well as the deprecation of the findDOMNode
API. It's been quite a while since Hooks were first
introduced in React, but we were hesitating of migrating to Hooks since it would make the library usage a bit more verbose.
However, recently there has been even more security reasons to migrate away from recompose
, so we decided that it is time
to say goodbye to HOC and accept certain drawbacks of the Hook implementation.
Here are some of the challenges encountered during the migration process:
HOC implementation used a findDOMNode
API to find a reference to a current DOM element wrapped with the HOC:
const node = SpatialNavigation.isNativeMode() ? this : findDOMNode(this);
Note that this
was pointing to an actual component instance even when it was called inside lifecycle
HOC from recompose
allowing to always find the top-level DOM element, without any additional code required to point to a specific DOM node.
It was a nice "magic" side effect of the HOC implementation, which is now getting deprecated.
In the new Hook implementation we are using the recommended ref
API. It makes a usage of the library a bit more verbose
since now you always have to specify which DOM element is considered a "focusable" area, because this reference is used
by the library to calculate the node's coordinates and size. Example above
parentFocusKey
down the treeAnother big challenge was to find a good way of passing the parentFocusKey
down the Focusable Tree, so every focusable
child component would always know its parent component key, in order to enable certain "tree-based" features described here.
In the old HOC implementation it was achieved via a combination of getContext
and withContext
HOCs. Former one was
receiving the parentFocusKey
from its parent no matter how deep it was in the component tree, and the latter one was
providing its own focusKey
as parentFocusKey
for its children components.
In modern React, the only recommended Context API is using Context Providers and Consumers (or useContext
hook).
While you can easily receive the Context value via useContext
, the only way to provide the Context down the tree is via
a JSX component Context.Provider
. This requires some additional code in case you have a Focusable Container component.
In order to provide the parentFocusKey
down the tree, you have to wrap your children components with a FocusContext.Provider
and provide a current focusKey
as the context value. Example here
import {withFocusable} from '@noriginmedia/norigin-spatial-navigation';
// Component ...
const FocusableComponent = withFocusable({
trackChildren: true,
forgetLastFocusedChild: true
})(Component);
const ParentComponent = (props) => (<View>
...
<FocusableComponent
trackChildren
forgetLastFocusedChild
focusKey={'FOCUSABLE_COMPONENT'}
onEnterPress={props.onItemPress}
autoRestoreFocus={false}
/>
...
</View>);
Please note that most of the features/props could have been passed as either direct JSX props
to the Focusable Component
or as an config object passed to the withFocusable
HOC. It provided certain level of flexibility, while also adding some
confusion as to what takes priority if you pass the same option to both the prop
and a HOC config.
In the new Hook implementation options can only be passed as a Hook Params:
const {/* hook output */ } = useFocusable({
trackChildren: true,
saveLastFocusedChild: false,
onEnterPress: () => {},
focusKey: 'FOCUSABLE_COMPONENT'
});
HOC was enhancing the wrapped component with certain new props
such as focused
etc.:
import {withFocusable} from '@noriginmedia/norigin-spatial-navigation';
const Component = ({focused}) => (<View>
<View style={focused ? styles.focusedStyle : styles.defaultStyle} />
</View>);
const FocusableComponent = withFocusable()(Component);
Hook will provide all these values as the return object of the hook:
const { focused, focusSelf, ref, ...etc } = useFocusable({/* hook params */ });
The only additional step when migrating from HOC to Hook (apart from changing withFocusable
to useFocusable
implementation)
is to link the DOM element with the ref
from the Hook as seen in this example.
While it requires a bit of extra code compared to the HOC version, it also provides a certain level of flexibility if
you want to make only a certain part of your UI component to act as a "focusable" area.
Please also note that some params and output values has been renamed. CHANGELOG
In the old HOC implementation there was no additional requirements for the Focusable Container to provide its own focusKey
down the Tree as a parentFocusKey
for its children components.
In the Hook implementation it is required to wrap your children components with a FocusContext.Provider
as seen in
this example.
npm i
npm start
Please follow the Contribution Guide
MIT Licensed