Skip to content

Commit

Permalink
Merge pull request #68 from bamlab/expose-node-ref
Browse files Browse the repository at this point in the history
feat: expose SpatialNavigationNode ref
  • Loading branch information
remilry committed Feb 19, 2024
2 parents 8cbb3c3 + 101c081 commit 43b091b
Show file tree
Hide file tree
Showing 6 changed files with 171 additions and 101 deletions.
6 changes: 6 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ The `SpatialNavigationNode` component receives the following props:
| `indexRange` | `number[]` | `undefined` | Determines the indexes when using long nodes in a grid. If a grid row has one `indexRange`, you should specify each element's `indexRange`. You can check for more details in [`GridWithLongNodesPage`](https://github.com/bamlab/react-tv-space-navigation/blob/31bfe1def4a7e18e9e41f26a520090d1b7a5b149/packages/example/src/pages/GridWithLongNodesPage.tsx) example or in [lrud documentation](https://github.com/bbc/lrud/blob/master/docs/usage.md#indexrange). |
| `children` | `({ isFocused, isActive }: { isFocused: boolean, isActive: boolean }) => ReactNode` or `ReactNode` | `null` | Child elements of the component. It can be a function that returns a React element and accepts a parameter with a `isFocused` property when `isFocusable` is `true`. If `isFocusable` is `false` or not provided, it can be any valid React node. |

The `SpatialNavigationNode` component ref expose the following methods:

| Name | Type | Description |
| ----------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `focus` | `function` | Give the focus to the selected node. |

## Usage

```jsx
Expand Down
42 changes: 28 additions & 14 deletions packages/example/src/modules/program/view/ProgramNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,39 @@ import { SpatialNavigationNode } from 'react-tv-space-navigation';
import { ProgramInfo } from '../domain/programInfo';
import { Program } from './Program';
import { LongProgram } from './LongProgram';
import { forwardRef } from 'react';
import { SpatialNavigationNodeRef } from '../../../../../lib/src/spatial-navigation/types/SpatialNavigationNodeRef';

type Props = {
programInfo: ProgramInfo;
onSelect?: () => void;
indexRange?: [number, number];
};

export const ProgramNode = ({ programInfo, onSelect, indexRange }: Props) => {
return (
<SpatialNavigationNode isFocusable onSelect={onSelect} indexRange={indexRange}>
{({ isFocused }) => <Program isFocused={isFocused} programInfo={programInfo} />}
</SpatialNavigationNode>
);
};
export const ProgramNode = forwardRef<SpatialNavigationNodeRef, Props>(
({ programInfo, onSelect, indexRange }: Props, ref) => {
return (
<SpatialNavigationNode isFocusable onSelect={onSelect} indexRange={indexRange} ref={ref}>
{({ isFocused }) => <Program isFocused={isFocused} programInfo={programInfo} />}
</SpatialNavigationNode>
);
},
);
ProgramNode.displayName = 'ProgramNode';

export const LongProgramNode = ({ programInfo, onSelect, indexRange }: Props) => {
return (
<SpatialNavigationNode isFocusable onSelect={onSelect} alignInGrid indexRange={indexRange}>
{({ isFocused }) => <LongProgram isFocused={isFocused} programInfo={programInfo} />}
</SpatialNavigationNode>
);
};
export const LongProgramNode = forwardRef<SpatialNavigationNodeRef, Props>(
({ programInfo, onSelect, indexRange }: Props, ref) => {
return (
<SpatialNavigationNode
isFocusable
onSelect={onSelect}
alignInGrid
indexRange={indexRange}
ref={ref}
>
{({ isFocused }) => <LongProgram isFocused={isFocused} programInfo={programInfo} />}
</SpatialNavigationNode>
);
},
);
LongProgramNode.displayName = 'LongProgramNode';
49 changes: 40 additions & 9 deletions packages/example/src/pages/GridWithLongNodesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,16 @@ import styled from '@emotion/native';
import { scaledPixels } from '../design-system/helpers/scaledPixels';
import { LongProgramNode, ProgramNode } from '../modules/program/view/ProgramNode';
import { theme } from '../design-system/theme/theme';
import { MutableRefObject, forwardRef, useRef } from 'react';
import { Button } from '../design-system/components/Button';
import { SpatialNavigationNodeRef } from '../../../lib/src/spatial-navigation/types/SpatialNavigationNodeRef';

const HEADER_SIZE = scaledPixels(400);

export const GridWithLongNodesPage = () => {
const firstItemRef = useRef<SpatialNavigationNodeRef>(null);
const lastItemRef = useRef<SpatialNavigationNodeRef>(null);

return (
<Page>
<CenteringView>
Expand All @@ -22,8 +28,9 @@ export const GridWithLongNodesPage = () => {
<SpatialNavigationNode alignInGrid>
<DefaultFocus>
<>
<FirstRow />
<SecondRow />
<FirstRow ref={firstItemRef} />
<SecondRow ref={lastItemRef} />
<ButtonRow firstItemRef={firstItemRef} lastItemRef={lastItemRef} />
</>
</DefaultFocus>
</SpatialNavigationNode>
Expand All @@ -34,32 +41,56 @@ export const GridWithLongNodesPage = () => {
);
};

const FirstRow = () => {
const FirstRow = forwardRef<SpatialNavigationNodeRef>((_, ref) => {
return (
<SpatialNavigationNode orientation="horizontal">
<ListContainer>
<LongProgramNode programInfo={programInfos[0]} indexRange={[0, 1]} />
<LongProgramNode programInfo={programInfos[0]} indexRange={[0, 1]} ref={ref} />
<ProgramNode programInfo={programInfos[1]} indexRange={[2, 2]} />
<ProgramNode programInfo={programInfos[2]} indexRange={[3, 3]} />
<LongProgramNode programInfo={programInfos[3]} indexRange={[4, 5]} />
<ProgramNode programInfo={programInfos[4]} indexRange={[6, 6]} />
</ListContainer>
</SpatialNavigationNode>
);
};
});
FirstRow.displayName = 'FirstRow';

const SecondRow = () => {
const SecondRow = forwardRef<SpatialNavigationNodeRef>((_, ref) => {
const programs = programInfos.slice(6, 13);
return (
<SpatialNavigationNode orientation="horizontal">
<ListContainer>
{/* <LongProgramNode programInfo={programInfos[0]} indexRange={[0, 1]} /> */}
{programs.map((program) => {
return <ProgramNode programInfo={program} key={program.id} />;
{programs.map((program, index) => {
return (
<ProgramNode
programInfo={program}
key={program.id}
ref={index === programs.length - 1 ? ref : null}
/>
);
})}
</ListContainer>
</SpatialNavigationNode>
);
});
SecondRow.displayName = 'SecondRow';

const ButtonRow = ({
firstItemRef,
lastItemRef,
}: {
firstItemRef: MutableRefObject<SpatialNavigationNodeRef>;
lastItemRef: MutableRefObject<SpatialNavigationNodeRef>;
}) => {
return (
<SpatialNavigationNode orientation="horizontal">
<ListContainer>
<Button label="Go to first item" onSelect={() => firstItemRef.current.focus()} />
<Button label="Go to last item" onSelect={() => lastItemRef.current.focus()} />
</ListContainer>
</SpatialNavigationNode>
);
};

const ListContainer = styled.View(({ theme }) => ({
Expand Down
1 change: 1 addition & 0 deletions packages/lib/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export { SpatialNavigationVirtualizedList } from './spatial-navigation/component
export { SpatialNavigationVirtualizedGrid } from './spatial-navigation/components/virtualizedGrid/SpatialNavigationVirtualizedGrid';
export { useSpatialNavigatorFocusableAccessibilityProps } from './spatial-navigation/hooks/useSpatialNavigatorFocusableAccessibilityProps';
export { useLockSpatialNavigation } from './spatial-navigation/context/LockSpatialNavigationContext';
export { SpatialNavigationNodeRef } from './spatial-navigation/types/SpatialNavigationNodeRef';

export const SpatialNavigation = {
configureRemoteControl,
Expand Down
171 changes: 93 additions & 78 deletions packages/lib/src/spatial-navigation/components/Node.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
import { View } from 'react-native';
import { useSpatialNavigatorDefaultFocus } from '../context/DefaultFocusContext';
import { ParentIdContext, useParentId } from '../context/ParentIdContext';
Expand All @@ -8,6 +8,7 @@ import { useBeforeMountEffect } from '../hooks/useBeforeMountEffect';
import { useUniqueId } from '../hooks/useUniqueId';
import { NodeOrientation } from '../types/orientation';
import { NodeIndexRange } from '@bam.tech/lrud';
import { SpatialNavigationNodeRef } from '../types/SpatialNavigationNodeRef';

type FocusableProps = {
isFocusable: true;
Expand Down Expand Up @@ -66,81 +67,95 @@ const useBindRefToChild = () => {
return { bindRefToChild, childRef };
};

export const SpatialNavigationNode = ({
onFocus,
onBlur,
onSelect,
orientation = 'vertical',
isFocusable = false,
alignInGrid = false,
indexRange,
children,
}: Props) => {
const spatialNavigator = useSpatialNavigator();
const parentId = useParentId();
const [isFocused, setIsFocused] = useState(false);
const [isActive, setIsActive] = useState(false);
// If parent changes, we have to re-register the Node + all children -> adding the parentId to the nodeId makes the children re-register.
const id = useUniqueId({ prefix: `${parentId}_node_` });

const { childRef, bindRefToChild } = useBindRefToChild();

const scrollToNodeIfNeeded = useScrollToNodeIfNeeded({ childRef });

/*
* We don't re-register in LRUD on each render, because LRUD does not allow updating the nodes.
* Therefore, the SpatialNavigator Node callbacks are registered at 1st render but can change (ie. if props change) afterwards.
* Since we want the functions to always be up to date, we use a reference to them.
*/

const currentOnSelect = useRef<() => void>();
currentOnSelect.current = onSelect;

const currentOnFocus = useRef<() => void>();
currentOnFocus.current = () => {
onFocus?.();
scrollToNodeIfNeeded();
};

const currentOnBlur = useRef<() => void>();
currentOnBlur.current = onBlur;

const shouldHaveDefaultFocus = useSpatialNavigatorDefaultFocus();

useBeforeMountEffect(() => {
spatialNavigator.registerNode(id, {
parent: parentId,
isFocusable,
onBlur: () => {
currentOnBlur.current?.();
setIsFocused(false);
},
onFocus: () => {
currentOnFocus.current?.();
setIsFocused(true);
},
onSelect: () => currentOnSelect.current?.(),
orientation,
isIndexAlign: alignInGrid,
export const SpatialNavigationNode = forwardRef<SpatialNavigationNodeRef, Props>(
(
{
onFocus,
onBlur,
onSelect,
orientation = 'vertical',
isFocusable = false,
alignInGrid = false,
indexRange,
onActive: () => setIsActive(true),
onInactive: () => setIsActive(false),
});

return () => spatialNavigator.unregisterNode(id);
}, [parentId]);

useEffect(() => {
if (shouldHaveDefaultFocus && isFocusable && !spatialNavigator.hasOneNodeFocused()) {
spatialNavigator.grabFocus(id);
}
}, [id, isFocusable, shouldHaveDefaultFocus, spatialNavigator]);

return (
<ParentIdContext.Provider value={id}>
{typeof children === 'function'
? bindRefToChild(children({ isFocused, isActive }))
: children}
</ParentIdContext.Provider>
);
};
children,
}: Props,
ref,
) => {
const spatialNavigator = useSpatialNavigator();
const parentId = useParentId();
const [isFocused, setIsFocused] = useState(false);
const [isActive, setIsActive] = useState(false);
// If parent changes, we have to re-register the Node + all children -> adding the parentId to the nodeId makes the children re-register.
const id = useUniqueId({ prefix: `${parentId}_node_` });

useImperativeHandle(
ref,
() => ({
focus: () => spatialNavigator.grabFocus(id),
}),
[spatialNavigator, id],
);

const { childRef, bindRefToChild } = useBindRefToChild();

const scrollToNodeIfNeeded = useScrollToNodeIfNeeded({ childRef });

/*
* We don't re-register in LRUD on each render, because LRUD does not allow updating the nodes.
* Therefore, the SpatialNavigator Node callbacks are registered at 1st render but can change (ie. if props change) afterwards.
* Since we want the functions to always be up to date, we use a reference to them.
*/

const currentOnSelect = useRef<() => void>();
currentOnSelect.current = onSelect;

const currentOnFocus = useRef<() => void>();
currentOnFocus.current = () => {
onFocus?.();
scrollToNodeIfNeeded();
};

const currentOnBlur = useRef<() => void>();
currentOnBlur.current = onBlur;

const shouldHaveDefaultFocus = useSpatialNavigatorDefaultFocus();

useBeforeMountEffect(() => {
spatialNavigator.registerNode(id, {
parent: parentId,
isFocusable,
onBlur: () => {
currentOnBlur.current?.();
setIsFocused(false);
},
onFocus: () => {
currentOnFocus.current?.();
setIsFocused(true);
},
onSelect: () => currentOnSelect.current?.(),
orientation,
isIndexAlign: alignInGrid,
indexRange,
onActive: () => setIsActive(true),
onInactive: () => setIsActive(false),
});

return () => spatialNavigator.unregisterNode(id);
}, [parentId]);

useEffect(() => {
if (shouldHaveDefaultFocus && isFocusable && !spatialNavigator.hasOneNodeFocused()) {
spatialNavigator.grabFocus(id);
}
}, [id, isFocusable, shouldHaveDefaultFocus, spatialNavigator]);

return (
<ParentIdContext.Provider value={id}>
{typeof children === 'function'
? bindRefToChild(children({ isFocused, isActive }))
: children}
</ParentIdContext.Provider>
);
},
);
SpatialNavigationNode.displayName = 'SpatialNavigationNode';
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export type SpatialNavigationNodeRef = {
focus: () => void;
};

0 comments on commit 43b091b

Please sign in to comment.