diff --git a/docs/usage.md b/docs/usage.md index 865c19c..307f049 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -156,6 +156,18 @@ If given, the `onFocus` function will be called when the node gets focussed on. If given, the `onBlur` function will be called when the node if the node had focus and a new node gains focus. +### `onFocusWithin` + +`function` + +If given, the `onFocusWithin` function will be called when any of the nested children of the node gains focus, and none had focus before. I.e. focus has moved into the parent. + +### `onBlurWithin` + +`function` + +If given, the `onBlurWithin` function will be called when the node if one of the nested children of the node had focus, and now none of them do. I.e. focus has move out of the parent. + ### `onSelect` `function` @@ -310,6 +322,28 @@ navigation.on('move', moveEvent => { ``` +The following two events occur when focus moves into/out of a parent node: +* `navigation.on('focusWithin', function)` - Focus was given to a nested child node. +* `navigation.on('blurWithin', function)` - Focus was taken from all nested child nodes. + +The `focusWithin` event callback is called with an event like so: + +```js +{ + node: // the node that focus has moved into + focusNode: // the child node that has gained focus +} +``` + +The `blurWithin` event callback is called with an event like so: + +```js +{ + node: // the node that focus has moved outside of + blurNode: // the child node that has lost focus +} +``` + To unregister an event callback, simply call the .off() method ```js diff --git a/src/index.ts b/src/index.ts index 0f17557..a37145f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -966,11 +966,50 @@ export class Lrud { throw new Error('trying to assign focus to a non focusable node') } + const currentParents = [] + const newParents = [] + if (this.currentFocusNode) { this.emitter.emit('blur', this.currentFocusNode) if (this.currentFocusNode.onBlur) { this.currentFocusNode.onBlur(this.currentFocusNode) } + + let parent = this.currentFocusNode.parent + + // get list of all parents for current focus node + while (parent) { + currentParents.push(parent) + + // if the parent has a parent, bubble up + parent = parent.parent + } + } + + let parent = node.parent + + // get list of all parents for new focus node + while (parent) { + newParents.push(parent) + + // if the parent has a parent, bubble up + parent = parent.parent + } + + // list of parents that will lose focus and not gain it again + const blurParents = currentParents.filter((node) => newParents.indexOf(node) === -1) + + // list of parents that did not have focus and will gain it + const focusParents = newParents.filter((node) => currentParents.indexOf(node) === -1) + + for (const blurParent of blurParents) { + this.emitter.emit('blurWithin', { + node: blurParent, + blurNode: this.currentFocusNode + }) + if (blurParent.onBlurWithin) { + blurParent.onBlurWithin(blurParent, this.currentFocusNode) + } } this.currentFocusNode = node @@ -984,6 +1023,16 @@ export class Lrud { } this.emitter.emit('focus', node) + + for (const focusParent of focusParents) { + this.emitter.emit('focusWithin', { + node: focusParent, + focusNode: node + }) + if (focusParent.onFocusWithin) { + focusParent.onFocusWithin(focusParent, node) + } + } } /** diff --git a/src/interfaces.ts b/src/interfaces.ts index b41e7c9..62ca0de 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -54,6 +54,8 @@ export interface Node extends Tree { onActiveChildChange?: (event: { node: Node, leave: Node, enter: Node }) => void onBlur?: (node: Node) => void onFocus?: (node: Node) => void + onFocusWithin?: (node: Node, focusNode: Node) => void + onBlurWithin?: (node: Node, blurNode: Node) => void onMove?: (event: { node: Node, leave: Node, enter: Node, direction: Direction, offset: -1 | 1 }) => void } diff --git a/src/lrud.test.js b/src/lrud.test.js index feafbbb..f46f34f 100644 --- a/src/lrud.test.js +++ b/src/lrud.test.js @@ -186,6 +186,85 @@ describe('lrud', () => { navigation.assignFocus('root') }).toThrow('trying to assign focus to a non focusable node') }) + + test('should trigger onFocusWithin for parents', () => { + const onFocusSpy = jest.fn() + const focusEventSpy = jest.fn() + + const navigation = new Lrud() + .registerNode('root', { onFocusWithin: onFocusSpy }) + .registerNode('a', { parent: 'root', onFocusWithin: onFocusSpy }) + .registerNode('aa', { parent: 'a', isFocusable: true }) + + navigation.on('focusWithin', focusEventSpy) + + navigation.assignFocus('aa') + + // 1st bubbling to 'a' + expect(onFocusSpy.mock.calls[0][0].id).toEqual('a') + expect(onFocusSpy.mock.calls[0][1].id).toEqual('aa') + // ...then to 'root' + expect(onFocusSpy.mock.calls[1][0].id).toEqual('root') + expect(onFocusSpy.mock.calls[1][1].id).toEqual('aa') + + // 1st bubbling to 'a' + expect(focusEventSpy.mock.calls[0][0].node.id).toEqual('a') + expect(focusEventSpy.mock.calls[0][0].focusNode.id).toEqual('aa') + // ...then to 'root' + expect(focusEventSpy.mock.calls[1][0].node.id).toEqual('root') + expect(focusEventSpy.mock.calls[1][0].focusNode.id).toEqual('aa') + }) + + test('should trigger onBlurWithin for parents of previous focus node', () => { + const onBlurSpy = jest.fn() + const blurEventSpy = jest.fn() + + const navigation = new Lrud() + .registerNode('root') + .registerNode('a', { parent: 'root', onBlurWithin: onBlurSpy }) + .registerNode('aa', { parent: 'a', isFocusable: true }) + .registerNode('b', { parent: 'root' }) + .registerNode('bb', { parent: 'b', isFocusable: true }) + + navigation.on('blurWithin', blurEventSpy) + + navigation.assignFocus('aa') + navigation.assignFocus('bb') + + expect(onBlurSpy.mock.calls[0][0].id).toEqual('a') + expect(onBlurSpy.mock.calls[0][1].id).toEqual('aa') + + expect(blurEventSpy.mock.calls[0][0].node.id).toEqual('a') + expect(blurEventSpy.mock.calls[0][0].blurNode.id).toEqual('aa') + }) + + test('should not trigger onFocusWithin or onBlurWithin if focus remains in parent', () => { + const focusSpy = jest.fn() + const blurSpy = jest.fn() + const focusEventSpy = jest.fn() + const blurEventSpy = jest.fn() + + const navigation = new Lrud() + .registerNode('root', { onFocusWithin: focusSpy, onBlurWithin: blurSpy }) + .registerNode('a', { parent: 'root' }) + .registerNode('aa', { parent: 'a', isFocusable: true }) + .registerNode('b', { parent: 'root' }) + .registerNode('bb', { parent: 'b', isFocusable: true }) + + navigation.on('focusWithin', (event) => event.node.id === 'root' && focusEventSpy(event)) + navigation.on('blurWithin', (event) => event.node.id === 'root' && blurEventSpy(event)) + + navigation.assignFocus('aa') + navigation.assignFocus('bb') + + // should only trigger for the first assignment + expect(focusSpy.mock.calls.length).toEqual(1) + expect(focusEventSpy.mock.calls.length).toEqual(1) + + // should never trigger + expect(blurSpy.mock.calls.length).toEqual(0) + expect(blurEventSpy.mock.calls.length).toEqual(0) + }) }) describe('climbUp()', () => { diff --git a/src/utils.ts b/src/utils.ts index 4aef2f6..0cf2f64 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -267,6 +267,8 @@ export const prepareNode = (nodeId: NodeId, nodeConfig: NodeConfig = {}): Node = onActiveChildChange: nodeConfig.onActiveChildChange, onBlur: nodeConfig.onBlur, onFocus: nodeConfig.onFocus, + onBlurWithin: nodeConfig.onBlurWithin, + onFocusWithin: nodeConfig.onFocusWithin, onMove: nodeConfig.onMove } }