Skip to content
This repository has been archived by the owner on Feb 2, 2023. It is now read-only.

Commit

Permalink
Trigger callbacks and events on focus/blur within
Browse files Browse the repository at this point in the history
Adds the onFocusWithin and onBlurWithin functions, and the focusWithin
and blurWithin events. These are triggered when focus moved into/out of
a parent node.

See: bbc#96
  • Loading branch information
EwanRoycroft committed Jun 1, 2022
1 parent 60707e4 commit fd61824
Show file tree
Hide file tree
Showing 5 changed files with 166 additions and 0 deletions.
34 changes: 34 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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: <node> // the node that focus has moved into
focusNode: <node> // the child node that has gained focus
}
```

The `blurWithin` event callback is called with an event like so:

```js
{
node: <node> // the node that focus has moved outside of
blurNode: <node> // the child node that has lost focus
}
```

To unregister an event callback, simply call the .off() method

```js
Expand Down
49 changes: 49 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
}
}

/**
Expand Down
2 changes: 2 additions & 0 deletions src/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ export interface Node extends Tree<Node> {
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
}

Expand Down
79 changes: 79 additions & 0 deletions src/lrud.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()', () => {
Expand Down
2 changes: 2 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down

0 comments on commit fd61824

Please sign in to comment.