├── .editorconfig
├── .eslintrc.js
├── .gitattributes
├── .github
├── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── feature_request.md
└── workflows
│ ├── deploy-web-example.yml
│ └── node.js.yml
├── .gitignore
├── .vscode
├── settings.json
└── tasks.json
├── .yarn
├── plugins
│ └── @yarnpkg
│ │ └── plugin-workspace-tools.cjs
└── releases
│ └── yarn-3.5.1.cjs
├── .yarnrc.yml
├── CHANGELOG.md
├── LICENSE
├── README.md
├── docs
├── accessibility.md
├── api.md
├── banner.png
├── conditional-rendering-problem.png
├── conditional-rendering-solution.png
├── demo.gif
├── menu-handling.png
├── pitfalls.md
├── talkback.gif
└── tutorial.md
├── package.json
├── packages
├── example
│ ├── .gitignore
│ ├── App.tsx
│ ├── README.md
│ ├── __tests__
│ │ └── App-test.tsx
│ ├── app.json
│ ├── assets
│ │ ├── adaptive-icon.png
│ │ ├── bunny_favicon.png
│ │ ├── favicon.ico
│ │ ├── favicon.png
│ │ ├── fonts
│ │ │ ├── Montserrat-Bold.ttf
│ │ │ ├── Montserrat-Medium.ttf
│ │ │ ├── Montserrat-Regular.ttf
│ │ │ └── Montserrat-SemiBold.ttf
│ │ ├── icon.png
│ │ └── splash.png
│ ├── babel.config.js
│ ├── babel.jest.config.js
│ ├── index.js
│ ├── jest.config.js
│ ├── metro.config.js
│ ├── package.json
│ ├── public
│ │ └── .nojekyll
│ ├── src
│ │ ├── components
│ │ │ ├── GoBackConfiguration.tsx
│ │ │ ├── Menu
│ │ │ │ ├── Menu.tsx
│ │ │ │ ├── MenuButton.tsx
│ │ │ │ └── MenuContext.tsx
│ │ │ ├── Page.tsx
│ │ │ ├── PanEvent
│ │ │ │ ├── PanEvent.constants.ts
│ │ │ │ ├── PanEvent.ts
│ │ │ │ ├── PanEvent.utils.ts
│ │ │ │ ├── panEventHandler.ts
│ │ │ │ ├── useTVPanEvent.ios.ts
│ │ │ │ └── useTVPanEvent.ts
│ │ │ ├── VirtualizedSpatialGrid.tsx
│ │ │ ├── configureRemoteControl.ts
│ │ │ ├── modals
│ │ │ │ ├── Modal.tsx
│ │ │ │ ├── SpatialNavigationOverlay
│ │ │ │ │ ├── SpatialNavigationOverlay.tsx
│ │ │ │ │ └── useLockOverlay.tsx
│ │ │ │ └── SubtitlesModal.tsx
│ │ │ ├── remote-control
│ │ │ │ ├── CustomEventEmitter.ts
│ │ │ │ ├── RemoteControlManager.android.ts
│ │ │ │ ├── RemoteControlManager.interface.ts
│ │ │ │ ├── RemoteControlManager.ios.ts
│ │ │ │ ├── RemoteControlManager.ts
│ │ │ │ └── SupportedKeys.ts
│ │ │ └── tests
│ │ │ │ ├── fixtures
│ │ │ │ └── programInfos.ts
│ │ │ │ └── helpers
│ │ │ │ ├── configureTestRemoteControl.ts
│ │ │ │ └── testRemoteControlManager.ts
│ │ ├── design-system
│ │ │ ├── assets
│ │ │ │ └── arrow-left.png
│ │ │ ├── components
│ │ │ │ ├── Arrows.tsx
│ │ │ │ ├── Box.tsx
│ │ │ │ ├── Button.tsx
│ │ │ │ ├── Spacer.tsx
│ │ │ │ ├── TextInput.tsx
│ │ │ │ └── Typography.tsx
│ │ │ ├── helpers
│ │ │ │ ├── Icons.tsx
│ │ │ │ ├── IconsCatalog.ts
│ │ │ │ ├── scaledPixels.ts
│ │ │ │ ├── useFocusAnimation.ts
│ │ │ │ └── useFocusAnimation.web.ts
│ │ │ ├── theme
│ │ │ │ ├── colors.ts
│ │ │ │ ├── sizes.ts
│ │ │ │ ├── spacings.ts
│ │ │ │ ├── theme.ts
│ │ │ │ ├── theme.types.ts
│ │ │ │ └── typography.ts
│ │ │ └── typings
│ │ │ │ └── emotion.d.ts
│ │ ├── hooks
│ │ │ ├── useFonts.tsx
│ │ │ └── useKey.ts
│ │ ├── modules
│ │ │ ├── header
│ │ │ │ ├── assets
│ │ │ │ │ ├── rabbitLarge0.png
│ │ │ │ │ ├── rabbitLarge1.png
│ │ │ │ │ ├── rabbitLarge2.png
│ │ │ │ │ ├── rabbitLarge3.png
│ │ │ │ │ ├── rabbitLarge4.png
│ │ │ │ │ ├── rabbitLarge5.png
│ │ │ │ │ ├── rabbitLarge6.png
│ │ │ │ │ ├── rabbitLarge7.png
│ │ │ │ │ └── rabbitLarge8.png
│ │ │ │ └── view
│ │ │ │ │ └── Header.tsx
│ │ │ └── program
│ │ │ │ ├── assets
│ │ │ │ ├── rabbit1.png
│ │ │ │ ├── rabbit10.png
│ │ │ │ ├── rabbit11.png
│ │ │ │ ├── rabbit12.png
│ │ │ │ ├── rabbit13.png
│ │ │ │ ├── rabbit14.png
│ │ │ │ ├── rabbit15.png
│ │ │ │ ├── rabbit16.png
│ │ │ │ ├── rabbit17.png
│ │ │ │ ├── rabbit18.png
│ │ │ │ ├── rabbit19.png
│ │ │ │ ├── rabbit2.png
│ │ │ │ ├── rabbit20.png
│ │ │ │ ├── rabbit21.png
│ │ │ │ ├── rabbit22.png
│ │ │ │ ├── rabbit23.png
│ │ │ │ ├── rabbit24.png
│ │ │ │ ├── rabbit25.png
│ │ │ │ ├── rabbit3.png
│ │ │ │ ├── rabbit4.png
│ │ │ │ ├── rabbit5.png
│ │ │ │ ├── rabbit6.png
│ │ │ │ ├── rabbit7.png
│ │ │ │ ├── rabbit8.png
│ │ │ │ └── rabbit9.png
│ │ │ │ ├── domain
│ │ │ │ └── programInfo.ts
│ │ │ │ ├── infra
│ │ │ │ └── programInfos.ts
│ │ │ │ └── view
│ │ │ │ ├── Program.tsx
│ │ │ │ ├── ProgramList.test.tsx
│ │ │ │ ├── ProgramList.tsx
│ │ │ │ ├── ProgramListWithTitle.tsx
│ │ │ │ ├── ProgramNode.tsx
│ │ │ │ └── useRotateAnimation.ts
│ │ ├── pages
│ │ │ ├── AsynchronousContent.tsx
│ │ │ ├── GridWithLongNodesPage.tsx
│ │ │ ├── Home.tsx
│ │ │ ├── ListWithVariableSize.test.tsx
│ │ │ ├── ListWithVariableSize.tsx
│ │ │ ├── NonVirtualizedGridPage.tsx
│ │ │ ├── ProgramDetail.tsx
│ │ │ └── ProgramGridPage.tsx
│ │ ├── testing
│ │ │ ├── constants.ts
│ │ │ ├── jest-setup.ts
│ │ │ └── jest-setupAfterEnv.ts
│ │ ├── typings
│ │ │ └── assets.d.ts
│ │ └── utils
│ │ │ ├── repeat.ts
│ │ │ └── throttle.ts
│ └── tsconfig.json
└── lib
│ ├── .npmignore
│ ├── babel.jest.config.js
│ ├── jest.config.js
│ ├── jestSnapshotResolver.js
│ ├── package.json
│ ├── src
│ ├── index.ts
│ ├── spatial-navigation
│ │ ├── SpatialNavigator.ts
│ │ ├── components
│ │ │ ├── FocusableView.tsx
│ │ │ ├── Node.tsx
│ │ │ ├── Root.tsx
│ │ │ ├── ScrollView
│ │ │ │ ├── AnyScrollView.tsx
│ │ │ │ ├── CustomScrollView
│ │ │ │ │ ├── CustomScrollView.hooks.ts
│ │ │ │ │ ├── CustomScrollView.test.tsx
│ │ │ │ │ ├── CustomScrollView.test.tsx.snap
│ │ │ │ │ └── CustomScrollView.tsx
│ │ │ │ ├── ScrollView.tsx
│ │ │ │ ├── pointer
│ │ │ │ │ ├── PointerScrollArrows.tsx
│ │ │ │ │ └── useRemotePointerScrollviewScrollProps.ts
│ │ │ │ └── types.ts
│ │ │ ├── View.tsx
│ │ │ ├── tests
│ │ │ │ ├── SpatialNavigation.test.tsx
│ │ │ │ ├── TestButton.tsx
│ │ │ │ └── helpers
│ │ │ │ │ ├── configureTestRemoteControl.ts
│ │ │ │ │ └── testRemoteControlManager.ts
│ │ │ ├── virtualizedGrid
│ │ │ │ ├── SpatialNavigationVirtualizedGrid.test.tsx
│ │ │ │ ├── SpatialNavigationVirtualizedGrid.test.tsx.snap
│ │ │ │ ├── SpatialNavigationVirtualizedGrid.tsx
│ │ │ │ └── helpers
│ │ │ │ │ └── convertToGrid.ts
│ │ │ └── virtualizedList
│ │ │ │ ├── SpatialNavigationVirtualizedList.test.tsx
│ │ │ │ ├── SpatialNavigationVirtualizedList.test.tsx.snap
│ │ │ │ ├── SpatialNavigationVirtualizedList.tsx
│ │ │ │ ├── SpatialNavigationVirtualizedListWithScroll.tsx
│ │ │ │ ├── SpatialNavigationVirtualizedListWithVirtualNodes.tsx
│ │ │ │ ├── VirtualizedList.tsx
│ │ │ │ ├── VirtualizedListWithSize.tsx
│ │ │ │ ├── helpers
│ │ │ │ ├── computeTranslation.test.ts
│ │ │ │ ├── computeTranslation.ts
│ │ │ │ ├── createScrollOffsetArray.ts
│ │ │ │ ├── getAdditionalNumberOfItemsRendered.ts
│ │ │ │ ├── getLastItemIndex.ts
│ │ │ │ ├── getNumberOfItemsVisibleOnScreen.ts
│ │ │ │ ├── getRange.test.ts
│ │ │ │ ├── getRange.ts
│ │ │ │ ├── getSizeInPxFromOneItemToAnother.ts
│ │ │ │ ├── updateVirtualNodeRegistration.test.ts
│ │ │ │ └── updateVirtualNodeRegistration.ts
│ │ │ │ └── hooks
│ │ │ │ ├── useCachedValues.ts
│ │ │ │ └── useVirtualizedListAnimation.ts
│ │ ├── configureRemoteControl.ts
│ │ ├── context
│ │ │ ├── DefaultFocusContext.tsx
│ │ │ ├── DeviceContext.tsx
│ │ │ ├── IsRootActiveContext.ts
│ │ │ ├── LockSpatialNavigationContext.ts
│ │ │ ├── ParentIdContext.ts
│ │ │ ├── ParentScrollContext.ts
│ │ │ └── SpatialNavigatorContext.ts
│ │ ├── helpers
│ │ │ ├── TypedForwardRef.tsx
│ │ │ ├── TypedMemo.tsx
│ │ │ ├── isError.ts
│ │ │ ├── mergeRefs.ts
│ │ │ └── scrollToNewlyfocusedElement.ts
│ │ ├── hooks
│ │ │ ├── useCreateSpatialNavigator.ts
│ │ │ ├── useRemoteControl.ts
│ │ │ ├── useSpatialNavigatorFocusableAccessibilityProps.ts
│ │ │ └── useUniqueId.ts
│ │ └── types
│ │ │ ├── SpatialNavigationNodeRef.ts
│ │ │ ├── SpatialNavigationVirtualizedListRef.ts
│ │ │ ├── TypeVirtualizedListAnimation.ts
│ │ │ └── orientation.ts
│ └── testing
│ │ ├── constants.ts
│ │ ├── jest-setup.ts
│ │ ├── jest-setupAfterEnv.ts
│ │ └── setComponentLayoutSize.ts
│ ├── tsconfig.json
│ └── webpack.config.js
├── prettier.config.js
├── tsconfig.base.json
└── yarn.lock
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | end_of_line = lf
5 | insert_final_newline = true
6 |
7 | [*.{js,json,yml}]
8 | charset = utf-8
9 | indent_style = space
10 | indent_size = 2
11 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line @typescript-eslint/no-var-requires
2 | const { defineConfig } = require('eslint-define-config');
3 |
4 | module.exports = defineConfig({
5 | ignorePatterns: [
6 | 'node_modules',
7 | '.yarn', // yarn 3
8 | 'android', // react-native
9 | 'ios', // react-native
10 | '.cache', // tsc/eslint/metro cache
11 | 'coverage', // jest
12 | 'dist', // expo updates
13 | '.expo-shared',
14 | '.expo',
15 | ],
16 | plugins: ['react-native'],
17 | parser: '@typescript-eslint/parser', // Specifies the ESLint parser
18 | extends: [
19 | 'eslint:recommended',
20 | 'plugin:react/recommended', // Uses the recommended rules from @eslint-plugin-react
21 | 'plugin:react/jsx-runtime', // Disables the rules that require importing react when using JSX
22 | 'plugin:react-native/all', // Enables all rules from react-native
23 | 'plugin:@typescript-eslint/recommended', // Uses the recommended rules from @typescript-eslint/eslint-plugin
24 | 'plugin:react-hooks/recommended',
25 | 'plugin:prettier/recommended', // Enables eslint-plugin-prettier and displays prettier errors as ESLint errors. Make sure this is always the last configuration in the extends array.
26 | ],
27 | parserOptions: {
28 | ecmaVersion: 2018, // Allows for the parsing of modern ECMAScript features
29 | sourceType: 'module', // Allows for the use of imports
30 | ecmaFeatures: {
31 | jsx: true, // Allows for the parsing of JSX
32 | },
33 | },
34 | rules: {
35 | // Place to specify ESLint rules. Can be used to overwrite rules specified from the extended configs
36 | // e.g. "@typescript-eslint/explicit-function-return-type": "off",
37 | '@typescript-eslint/no-unused-vars': 'error',
38 | '@typescript-eslint/no-explicit-any': 'error',
39 | '@typescript-eslint/ban-ts-comment': 'warn',
40 | '@typescript-eslint/indent': 'off',
41 | '@typescript-eslint/explicit-function-return-type': 'off',
42 | 'no-return-await': 'error',
43 | 'react/no-unstable-nested-components': 'error',
44 | 'react/prop-types': 'off',
45 | 'react-native/sort-styles': 'off',
46 | 'no-console': ['error', { allow: ['warn', 'error'] }],
47 | 'react-native/no-raw-text': ['off'],
48 | 'react-hooks/exhaustive-deps': 'error',
49 | 'react-native/no-color-literals': 'off',
50 | '@typescript-eslint/no-empty-function': 'off',
51 | },
52 | settings: {
53 | react: {
54 | version: 'detect', // Tells eslint-plugin-react to automatically detect the version of React to use
55 | },
56 | },
57 | env: {
58 | 'react-native/react-native': true,
59 | },
60 | // Glob based definitions
61 | overrides: [
62 | {
63 | files: ['**/*.test.ts', '**/*.test.tsx'],
64 | env: {
65 | jest: true,
66 | },
67 | rules: {
68 | '@typescript-eslint/explicit-function-return-type': 'off',
69 | },
70 | },
71 | ],
72 | });
73 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | /.yarn/** linguist-vendored
2 | /.yarn/releases/* binary
3 | /.yarn/plugins/**/* binary
4 | /.pnp.* binary linguist-generated
5 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: bug
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | ```tsx
15 | // some code
16 | ```
17 |
18 | **Expected behavior**
19 | A clear and concise description of what you expected to happen.
20 |
21 | **Screenshots**
22 | If applicable, add screenshots to help explain your problem.
23 |
24 | **Version and OS**
25 | - Library version: [e.g. 1.0.0]
26 | - React Native version: [e.g. 0.71.2]
27 | - OS [e.g. Android, web]
28 |
29 | **Additional context**
30 | Add any other context about the problem here.
31 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: feature request
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | Diagrams and screenshots welcome!
17 |
18 | **Describe alternatives you've considered**
19 | A clear and concise description of any alternative solutions or features you've considered.
20 |
21 | **Additional context**
22 | Add any other context or screenshots about the feature request here.
23 |
--------------------------------------------------------------------------------
/.github/workflows/deploy-web-example.yml:
--------------------------------------------------------------------------------
1 | name: Deploy web example
2 | on:
3 | push:
4 | branches:
5 | - main
6 | permissions:
7 | contents: write
8 | jobs:
9 | build-and-deploy-web-example:
10 | concurrency: ci-${{ github.ref }} # Recommended if you intend to make multiple deployments in quick succession.
11 | runs-on: ubuntu-latest
12 | steps:
13 | - name: Checkout 🛎️
14 | uses: actions/checkout@v3
15 |
16 | - name: Install and Build 🔧 # This example project is built using npm and outputs the result to the 'build' folder. Replace with the commands required to build your project, or remove this step entirely if your site is pre-built.
17 | run: |
18 | yarn
19 | yarn build:example:web
20 |
21 | - name: Deploy 🚀
22 | uses: JamesIves/github-pages-deploy-action@v4
23 | with:
24 | folder: packages/example/dist
25 |
--------------------------------------------------------------------------------
/.github/workflows/node.js.yml:
--------------------------------------------------------------------------------
1 | name: Node.js CI
2 |
3 | on:
4 | push:
5 | branches: [ "main" ]
6 | pull_request:
7 | branches: [ "main" ]
8 |
9 | jobs:
10 | build:
11 |
12 | runs-on: ubuntu-latest
13 |
14 | strategy:
15 | matrix:
16 | node-version: [20.x]
17 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/
18 |
19 | steps:
20 | - uses: actions/checkout@v3
21 | - name: Use Node.js ${{ matrix.node-version }}
22 | uses: actions/setup-node@v3
23 | with:
24 | node-version: ${{ matrix.node-version }}
25 | cache: 'yarn'
26 | - run: yarn
27 | - run: yarn test
28 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .yarn/*
2 | !.yarn/patches
3 | !.yarn/plugins
4 | !.yarn/releases
5 | !.yarn/sdks
6 | !.yarn/versions
7 |
8 | # Swap the comments on the following lines if you don't wish to use zero-installs
9 | # Documentation here: https://yarnpkg.com/features/zero-installs
10 | # !.yarn/cache
11 | .pnp.*
12 |
13 | node_modules
14 | dist
15 |
16 | .cache
17 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "explorer.fileNesting.enabled": true,
3 | "explorer.fileNesting.patterns": {
4 | "*.ts": "${capture}.test.ts, ${capture}.test.ts.snap",
5 | "*.tsx": "${capture}.test.tsx, ${capture}.test.tsx.snap",
6 | ".eslintrc.js": "*.eslintrc.js"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/.vscode/tasks.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "2.0.0",
3 | "tasks": [
4 | {
5 | "type": "shell",
6 | "command": "yarn test:lint",
7 | "problemMatcher": ["$eslint-stylish"],
8 | "group": "test",
9 | "label": "Lint"
10 | },
11 | {
12 | "type": "typescript",
13 | "label": "Typescript example",
14 | "tsconfig": "packages/example/tsconfig.json",
15 | "problemMatcher": ["$tsc"],
16 | "group": {
17 | "kind": "build",
18 | "isDefault": true
19 | }
20 | },
21 | {
22 | "type": "typescript",
23 | "label": "Typescript lib",
24 | "tsconfig": "packages/lib/tsconfig.json",
25 | "problemMatcher": ["$tsc"],
26 | "group": {
27 | "kind": "build",
28 | "isDefault": true
29 | }
30 | }
31 | ]
32 | }
33 |
--------------------------------------------------------------------------------
/.yarnrc.yml:
--------------------------------------------------------------------------------
1 | nodeLinker: node-modules
2 |
3 | plugins:
4 | - path: .yarn/plugins/@yarnpkg/plugin-workspace-tools.cjs
5 | spec: "@yarnpkg/plugin-workspace-tools"
6 |
7 | yarnPath: .yarn/releases/yarn-3.5.1.cjs
8 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Pierre Poupin
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 |
3 | # react-tv-space-navigation
4 |
5 | - [Why?](#why)
6 | - [What you can achieve](#what-you-can-achieve)
7 | - [How to use](#how-to-use)
8 | - [How to run the example](#how-to-run-the-example)
9 | - [API documentation](#api-documentation)
10 | - [Pitfalls](#pitfalls--troubleshooting)
11 | - [Accessibility support](#accessibility-support)
12 | - [Contributing](#contributing)
13 |
14 | # Why?
15 |
16 | Spatial navigation is a hard problem on a TV app. Many solutions exist. React Native TV even has a core solution for it.
17 | But most existing solutions are not 100% cross-platform.
18 |
19 | If you’re looking to develop a TV app for AndroidTV, tvOS, and web-based TV devices, this package can be a valuable tool.
20 | However, if you don’t require web support, using the native react-native-tvos solution might be a better fit.
21 | The primary objective of this package is to provide consistent support across all platforms, though this comes with some trade-offs (see the pitfalls below).
22 |
23 | The library is based on LRUD, which is a UI-agnostic lib that represents spatial navigation. The library is a React wrapper around
24 | the core logic of LRUD.
25 |
26 | # What you can achieve
27 |
28 | 
29 |
30 | [Check out the live web demo!](https://bamlab.github.io/react-tv-space-navigation/)
31 |
32 | One of the goals of the lib is to have a simple and declarative API.
33 | No need for hooks or dark shenanigans. You just simply declare components.
34 |
35 | Here's the kind of code you'll be able to achieve:
36 |
37 | ```tsx
38 | /**
39 | * A simple component that shows a rabbit program
40 | * We plug it to the Spatial Navigation easily using a FocusableView
41 | */
42 | const Rabbit = ({ onSelect }) => (
43 |
44 | {({ isFocused }) => }
45 |
46 | );
47 |
48 | /**
49 | * We can have as many nodes as we want. We group our rabbits in a horizontal spatial navigation view
50 | * to spatially describe a row layout
51 | * (it includes a spatial navigation node AND the horizontal styling for it)
52 | *
53 | * We also want to scroll horizontally, so we add a horizontal scrollview.
54 | */
55 | const RabbitRow = () => (
56 |
57 |
58 | {/* assuming you have rabbits data */}
59 | {rabbits.map((_, index) => (
60 | console.log('selected rabbit ', index)} />
61 | ))}
62 |
63 |
64 | );
65 |
66 | /**
67 | * Now I simply add a page with a Root node and a vertical scroll view to scroll through my rows.
68 | */
69 | const Page = () => (
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 | );
81 | ```
82 |
83 | # How to use
84 |
85 | You should [follow the tutorial](./docs/tutorial.md).
86 |
87 | # How to run the example
88 |
89 | If you want to run the example app in `packages/example`, take a look at [the README](./packages/example/README.md)
90 |
91 | # API documentation
92 |
93 | You can have a look at [the documentation](./docs/api.md).
94 |
95 | # Pitfalls & troubleshooting
96 |
97 | You should have a look at [the pitfalls and troubleshooting](./docs/pitfalls.md).
98 |
99 | # Accessibility support
100 |
101 | Read the [state of accessibility](./docs/accessibility.md).
102 |
103 | # Contributing
104 |
105 | ## Publishing the package
106 |
107 | - Increment the package.json in `./packages/lib/package.json`.
108 | - Commit the change `git commit -m "chore: bump version"`
109 | - Add a tag matching the version `git tag vx.x.x && git push --tags`
110 | - Generate the changelog and commit it `yarn changelog && git add CHANGELOG.md && git commit "chore: update changelog"`
111 | - Then publish the package:
112 |
113 | ```
114 | cd packages/lib
115 | yarn publish:package
116 | ```
117 |
--------------------------------------------------------------------------------
/docs/accessibility.md:
--------------------------------------------------------------------------------
1 | # Accessibility
2 |
3 | For now, accessibility support is experimental with the library.
4 | Here's a video of what we could achieve.
5 |
6 | 
7 |
8 | Since we bypass the native focus, and the screen readers rely on the native elements, it's a difficult topic.
9 |
10 | The `SpatialNavigatioNFocusableView` that you can use integrate basic accessibility props relevant to make the library work with TalkBack (Android only).
11 |
12 | The two main caveats are :
13 |
14 | - Your elements will still be focusable, but the user will need to press
15 | enter to grab focus on an element, which is not standard at all.
16 | - You might need to add the props `accessible` on `SpatialNavigationFocusableView` children. For example, focusable images won't work without it. You can check it out in the example, in the `Program.tsx` component.
17 |
18 | We could not find a way to properly intercept the accessibility focus event, even with a React Native patch.
19 |
20 | Help is welcome 🙂
21 |
--------------------------------------------------------------------------------
/docs/banner.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bamlab/react-tv-space-navigation/3ae894aa4bb593a25ea2c2b1ae40fe8061b4b4c7/docs/banner.png
--------------------------------------------------------------------------------
/docs/conditional-rendering-problem.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bamlab/react-tv-space-navigation/3ae894aa4bb593a25ea2c2b1ae40fe8061b4b4c7/docs/conditional-rendering-problem.png
--------------------------------------------------------------------------------
/docs/conditional-rendering-solution.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bamlab/react-tv-space-navigation/3ae894aa4bb593a25ea2c2b1ae40fe8061b4b4c7/docs/conditional-rendering-solution.png
--------------------------------------------------------------------------------
/docs/demo.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bamlab/react-tv-space-navigation/3ae894aa4bb593a25ea2c2b1ae40fe8061b4b4c7/docs/demo.gif
--------------------------------------------------------------------------------
/docs/menu-handling.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bamlab/react-tv-space-navigation/3ae894aa4bb593a25ea2c2b1ae40fe8061b4b4c7/docs/menu-handling.png
--------------------------------------------------------------------------------
/docs/pitfalls.md:
--------------------------------------------------------------------------------
1 | # Pitfalls
2 |
3 |
4 | - [Conditional rendering](#conditional-rendering)
5 | - [TLDR](#tldr)
6 | - [Explanations](#explanations)
7 | - [Accessibility](#accessibility)
8 |
9 | ## Conditional rendering & dynamic ordering
10 |
11 | ### TLDR
12 |
13 | If navigation elements are conditionnally visible, it is necessary to wrap them with a node that will always be present. Otherwise the registering of the elements might change.
14 |
15 | ```tsx
16 | // DON'T ❌
17 |
18 | Title
19 | {isVisible && }
20 |
21 |
22 | // DO ✅
23 |
24 | Title
25 | {isVisible && }
26 |
27 | ```
28 |
29 | The same goes for mapping over a list of elements that can change. The trick in that case is to wrap the elements with a SpatialNavigationNode that is keyed by the list index.
30 | Yes, this is bad practice in general, but it is justified here because we really want the SpatialNavigationNode components to never change even if the list moves.
31 | It will force the children to re-render but we have no better recommendation yet!
32 |
33 | ```tsx
34 | // DON'T ❌
35 |
36 | {elements.map((element) => )}
37 |
38 |
39 | // DO ✅
40 |
41 | {elements.map((element, index) =>
42 |
43 | )
44 |
45 | }
46 |
47 | ```
48 |
49 | ### Explanations
50 |
51 | If you try to hide and then show a button again naively using the library, you’ll encounter an issue.
52 | Once re-rendered, your button will not be at the proper place! Well, for the remote control only.
53 | Your button will still be at the top of your page, but it will be as if it was at the bottom for the remote control.
54 |
55 | 
56 |
57 | Why?
58 |
59 | When we declare elements in LRUD, we declare them in the same order as the React rendering.
60 | It’s our only way to know that Button 1 is above Button 2.
61 | The problem with that, is that if you remove Button 1 from your React tree and put it back, it’s still Button1 + Button2 for React,
62 | but the order of the LRUD declaration will change: Button1 will land AFTER Button2 for LRUD.
63 |
64 | 
65 |
66 | Solution is simple, but you need to know about it.
67 | You simply need to wrap your conditionally rendered elements with a `` that you never remove from the tree.
68 | You remove its children only.
69 | Once you do that, there is always an LRUD node living at the place of your button, and there won’t be any confusion order once your button comes back!
70 |
71 | ## Accessibility
72 |
73 | As mentioned in [accessibility](./accessibility.md), support is experimental. And help is welcome 🙂
74 |
75 | ## React Strict Mode
76 |
77 | It can't work 😢 Long story short: we're relying on React's rendering order
78 | to register our elements, and the strict mode is messing with it.
79 |
80 | You can [check this talk out](https://www.youtube.com/watch?v=Asn1TmCH2b0) to understand why.
81 |
--------------------------------------------------------------------------------
/docs/talkback.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bamlab/react-tv-space-navigation/3ae894aa4bb593a25ea2c2b1ae40fe8061b4b4c7/docs/talkback.gif
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-tv-space-navigation-monorepo",
3 | "workspaces": [
4 | "packages/*"
5 | ],
6 | "private": true,
7 | "packageManager": "yarn@3.5.1",
8 | "devDependencies": {
9 | "@babel/core": "^7.22.5",
10 | "@babel/preset-env": "^7.22.5",
11 | "@babel/preset-react": "^7.22.5",
12 | "@babel/preset-typescript": "^7.22.5",
13 | "@react-native-community/eslint-config": "^3.2.0",
14 | "@testing-library/react-native": "^12.4.3",
15 | "@types/react": "~18.2.79",
16 | "@types/react-dom": "~18.2.25",
17 | "@typescript-eslint/eslint-plugin": "^5.60.1",
18 | "@typescript-eslint/parser": "^5.60.1",
19 | "@typescript-eslint/typescript-estree": "^5.61.0",
20 | "babel-loader": "^9.1.2",
21 | "babel-plugin-transform-class-properties": "6.24.1",
22 | "conventional-changelog-cli": "^4.1.0",
23 | "css-loader": "^6.8.1",
24 | "eslint": "^8.44.0",
25 | "eslint-config-prettier": "^8.8.0",
26 | "eslint-define-config": "^1.21.0",
27 | "eslint-plugin-prettier": "^4.2.1",
28 | "eslint-plugin-react": "^7.32.2",
29 | "eslint-plugin-react-hooks": "^4.6.0",
30 | "eslint-plugin-react-native": "^4.0.0",
31 | "html-webpack-plugin": "^5.5.3",
32 | "metro-react-native-babel-preset": "^0.76.7",
33 | "patch-package": "^8.0.0",
34 | "prettier": "^2.8.8",
35 | "style-loader": "^3.3.3",
36 | "ts-loader": "^9.4.4",
37 | "typescript": "~5.3.3",
38 | "webpack": "^5.88.1",
39 | "webpack-cli": "^5.1.4",
40 | "webpack-dev-server": "^4.15.1"
41 | },
42 | "dependencies": {
43 | "@react-native-tvos/config-tv": "^0.0.4",
44 | "@react-navigation/bottom-tabs": "^6.5.11",
45 | "react": "18.2.0",
46 | "react-dom": "18.2.0",
47 | "react-native-modal": "^13.0.1",
48 | "react-native-web": "^0.19.6"
49 | },
50 | "resolutions": {
51 | "@typescript-eslint/typescript-estree": "5.61.0"
52 | },
53 | "scripts": {
54 | "changelog": "conventional-changelog -p conventionalcommits -r 0 -o CHANGELOG.md",
55 | "start:example": "yarn workspace hoppixtv start",
56 | "build:example:web": "yarn workspace hoppixtv build:web",
57 | "test:core": "yarn workspace react-tv-space-navigation jest",
58 | "test:example": "yarn workspace hoppixtv jest",
59 | "test:types": "yarn workspaces foreach run test:types",
60 | "test:lint": "eslint . --ext .js,.jsx,.ts,.tsx --report-unused-disable-directives --max-warnings 0 --cache --cache-strategy content --cache-location .cache/eslint.json",
61 | "test": "yarn test:lint && yarn test:types && yarn test:core && yarn test:example",
62 | "build:core": "yarn workspace react-tv-space-navigation build",
63 | "postinstall": "patch-package"
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/packages/example/.gitignore:
--------------------------------------------------------------------------------
1 | # Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
2 |
3 | # dependencies
4 | node_modules/
5 |
6 | # Expo
7 | .expo/
8 | dist/
9 | web-build/
10 |
11 | # Native
12 | *.orig.*
13 | *.jks
14 | *.p8
15 | *.p12
16 | *.key
17 | *.mobileprovision
18 |
19 | # Metro
20 | .metro-health-check*
21 |
22 | # debug
23 | npm-debug.*
24 | yarn-debug.*
25 | yarn-error.*
26 |
27 | # macOS
28 | .DS_Store
29 | *.pem
30 |
31 | # local env files
32 | .env*.local
33 |
34 | # typescript
35 | *.tsbuildinfo
36 |
37 | android
38 | ios
--------------------------------------------------------------------------------
/packages/example/App.tsx:
--------------------------------------------------------------------------------
1 | import './src/components/configureRemoteControl';
2 | import { ThemeProvider } from '@emotion/react';
3 | import { NavigationContainer } from '@react-navigation/native';
4 | import { useWindowDimensions } from 'react-native';
5 | import { theme } from './src/design-system/theme/theme';
6 | import { Home } from './src/pages/Home';
7 | import { ProgramGridPage } from './src/pages/ProgramGridPage';
8 | import { Menu } from './src/components/Menu/Menu';
9 | import { MenuProvider } from './src/components/Menu/MenuContext';
10 | import styled from '@emotion/native';
11 | import { useFonts } from './src/hooks/useFonts';
12 | import { BottomTabBarProps, createBottomTabNavigator } from '@react-navigation/bottom-tabs';
13 | import { ProgramInfo } from './src/modules/program/domain/programInfo';
14 | import { createNativeStackNavigator } from '@react-navigation/native-stack';
15 | import { ProgramDetail } from './src/pages/ProgramDetail';
16 | import { NonVirtualizedGridPage } from './src/pages/NonVirtualizedGridPage';
17 | import { GridWithLongNodesPage } from './src/pages/GridWithLongNodesPage';
18 | import { useTVPanEvent } from './src/components/PanEvent/useTVPanEvent';
19 | import { SpatialNavigationDeviceTypeProvider } from '../lib/src/spatial-navigation/context/DeviceContext';
20 | import { ListWithVariableSize } from './src/pages/ListWithVariableSize';
21 | import { AsynchronousContent } from './src/pages/AsynchronousContent';
22 |
23 | const Stack = createNativeStackNavigator();
24 |
25 | const Tab = createBottomTabNavigator();
26 |
27 | export type RootTabParamList = {
28 | Home: undefined;
29 | ProgramGridPage: undefined;
30 | NonVirtualizedGridPage: undefined;
31 | GridWithLongNodesPage: undefined;
32 | ListWithVariableSize: undefined;
33 | AsynchronousContent: undefined;
34 | };
35 |
36 | export type RootStackParamList = {
37 | TabNavigator: undefined;
38 | ProgramDetail: { programInfo: ProgramInfo };
39 | };
40 |
41 | const RenderMenu = (props: BottomTabBarProps) => ;
42 |
43 | const TabNavigator = () => {
44 | return (
45 |
46 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 | );
66 | };
67 |
68 | function App() {
69 | useTVPanEvent();
70 | const { height, width } = useWindowDimensions();
71 | const areFontsLoaded = useFonts();
72 |
73 | if (!areFontsLoaded) {
74 | return null;
75 | }
76 |
77 | return (
78 |
79 |
80 |
81 |
82 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 | );
99 | }
100 |
101 | export default App;
102 |
103 | const Container = styled.View<{ width: number; height: number }>(({ width, height }) => ({
104 | width,
105 | height,
106 | flexDirection: 'row-reverse',
107 | backgroundColor: theme.colors.background.main,
108 | }));
109 |
--------------------------------------------------------------------------------
/packages/example/README.md:
--------------------------------------------------------------------------------
1 | # Hoppix
2 |
3 | ## Description
4 |
5 | Hoppix is a project that aims to provide an example TVOS application built using React Native. It includes features such as navigation, state management, and integration with web development.
6 |
7 | ## Installation
8 |
9 | To install the project, follow these steps:
10 |
11 | 1. Install the required dependencies:
12 |
13 | ```
14 | yarn install
15 | ```
16 |
17 | 2. Prebuild the app with expo before running:
18 | ```
19 | yarn prebuild
20 | ```
21 |
22 | ## Usage
23 |
24 | ### Running the TVOS Application on Apple TV or Android TV
25 |
26 | You can run this demo application on AppleTV or AndroidTV
27 | To start the TV application, use one of the following commands:
28 |
29 | ```
30 | yarn start
31 | yarn ios
32 | yarn android
33 | ```
34 |
35 | This will initiate the TV application using React Native's Metro bundler.
36 |
37 | Make sure you have set up the necessary emulator/device configurations on XCode or Android Studio to run the project on AppleTV or Android TV.
38 |
39 | ### tvOS troubleshooting
40 |
41 | If you get the error
42 |
43 | ```
44 | CommandError: Failed to build iOS project. "xcodebuild" exited with error code 65.
45 | To view more error logs, try building the app with Xcode directly, by opening /Users/thomasrenaud/Desktop/SpaceNavigation/react-tv-space-navigation/packages/example/ios/hoppixTv.xcworkspace.
46 |
47 | Command line invocation:
48 | /Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild -workspace /Users/thomasrenaud/Desktop/SpaceNavigation/react-tv-space-navigation/packages/example/ios/hoppixTv.xcworkspace -configuration Debug -scheme hoppixTv -destination id=B14D0E77-99C4-486F-8096-6584A23C9476
49 |
50 | User defaults from command line:
51 | IDEPackageSupportUseBuiltinSCM = YES
52 | ```
53 |
54 | please delete the .xcode.env.local in your ios directory, and run the `yarn ios` command again.
55 |
56 | ### Running the Web Application
57 |
58 | Hoppix also supports running as a web application. To run the web version of the project, use the following command:
59 |
60 | ```
61 | yarn web
62 | ```
63 |
64 | This will start a development server using Webpack and serve the application in your default web browser.
65 |
66 | ## Handling Remote Control
67 |
68 | In order to use Spatial Navigation in the Web Application or TV Application, you must configure the remoteControlManager to map your keyboard or remote keys to LRUD Directions.
69 |
70 | See [Remote Control](./src/components/remote-control/) for how to manage Platform Specific remote controls.
71 |
72 | ## Contributing
73 |
74 | Contributions to Hoppix are welcome! If you find any issues or want to enhance the project, please submit a pull request or open an issue on the repository.
75 |
--------------------------------------------------------------------------------
/packages/example/__tests__/App-test.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @format
3 | */
4 |
5 | import 'react-native';
6 | import App from '../App';
7 |
8 | // Note: test renderer must be required after react-native.
9 | import renderer from 'react-test-renderer';
10 |
11 | it('renders correctly', () => {
12 | renderer.create();
13 | });
14 |
--------------------------------------------------------------------------------
/packages/example/app.json:
--------------------------------------------------------------------------------
1 | {
2 | "expo": {
3 | "name": "hoppixTv",
4 | "slug": "hoppixTv",
5 | "version": "1.0.0",
6 | "orientation": "portrait",
7 | "icon": "./assets/icon.png",
8 | "userInterfaceStyle": "light",
9 | "splash": {
10 | "image": "./assets/splash.png",
11 | "resizeMode": "contain",
12 | "backgroundColor": "#ffffff"
13 | },
14 | "assetBundlePatterns": ["**/*"],
15 | "ios": {
16 | "supportsTablet": true,
17 | "bundleIdentifier": "com.reactTvSpaceNavigation.hoppixTv"
18 | },
19 | "android": {
20 | "adaptiveIcon": {
21 | "foregroundImage": "./assets/adaptive-icon.png",
22 | "backgroundColor": "#ffffff"
23 | },
24 | "package": "com.reactTvSpaceNavigation.hoppixTv"
25 | },
26 | "web": {
27 | "favicon": "./assets/favicon.png"
28 | },
29 | "experiments": {
30 | "baseUrl": "/react-tv-space-navigation"
31 | },
32 | "plugins": [
33 | "@bam.tech/react-native-keyevent-expo-config-plugin",
34 | [
35 | "@react-native-tvos/config-tv",
36 | {
37 | "showVerboseWarnings": true
38 | }
39 | ],
40 | [
41 | "expo-font",
42 | {
43 | "fonts": [
44 | "./assets/fonts/Montserrat-Bold.ttf",
45 | "./assets/fonts/Montserrat-Regular.ttf",
46 | "./assets/fonts/Montserrat-SemiBold.ttf",
47 | "./assets/fonts/Montserrat-Medium.ttf"
48 | ]
49 | }
50 | ]
51 | ]
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/packages/example/assets/adaptive-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bamlab/react-tv-space-navigation/3ae894aa4bb593a25ea2c2b1ae40fe8061b4b4c7/packages/example/assets/adaptive-icon.png
--------------------------------------------------------------------------------
/packages/example/assets/bunny_favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bamlab/react-tv-space-navigation/3ae894aa4bb593a25ea2c2b1ae40fe8061b4b4c7/packages/example/assets/bunny_favicon.png
--------------------------------------------------------------------------------
/packages/example/assets/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bamlab/react-tv-space-navigation/3ae894aa4bb593a25ea2c2b1ae40fe8061b4b4c7/packages/example/assets/favicon.ico
--------------------------------------------------------------------------------
/packages/example/assets/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bamlab/react-tv-space-navigation/3ae894aa4bb593a25ea2c2b1ae40fe8061b4b4c7/packages/example/assets/favicon.png
--------------------------------------------------------------------------------
/packages/example/assets/fonts/Montserrat-Bold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bamlab/react-tv-space-navigation/3ae894aa4bb593a25ea2c2b1ae40fe8061b4b4c7/packages/example/assets/fonts/Montserrat-Bold.ttf
--------------------------------------------------------------------------------
/packages/example/assets/fonts/Montserrat-Medium.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bamlab/react-tv-space-navigation/3ae894aa4bb593a25ea2c2b1ae40fe8061b4b4c7/packages/example/assets/fonts/Montserrat-Medium.ttf
--------------------------------------------------------------------------------
/packages/example/assets/fonts/Montserrat-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bamlab/react-tv-space-navigation/3ae894aa4bb593a25ea2c2b1ae40fe8061b4b4c7/packages/example/assets/fonts/Montserrat-Regular.ttf
--------------------------------------------------------------------------------
/packages/example/assets/fonts/Montserrat-SemiBold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bamlab/react-tv-space-navigation/3ae894aa4bb593a25ea2c2b1ae40fe8061b4b4c7/packages/example/assets/fonts/Montserrat-SemiBold.ttf
--------------------------------------------------------------------------------
/packages/example/assets/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bamlab/react-tv-space-navigation/3ae894aa4bb593a25ea2c2b1ae40fe8061b4b4c7/packages/example/assets/icon.png
--------------------------------------------------------------------------------
/packages/example/assets/splash.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bamlab/react-tv-space-navigation/3ae894aa4bb593a25ea2c2b1ae40fe8061b4b4c7/packages/example/assets/splash.png
--------------------------------------------------------------------------------
/packages/example/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = function (api) {
2 | api.cache(true);
3 | return {
4 | presets: ['babel-preset-expo'],
5 | };
6 | };
7 |
--------------------------------------------------------------------------------
/packages/example/babel.jest.config.js:
--------------------------------------------------------------------------------
1 | // This is only used by jest
2 | module.exports = {
3 | sourceMaps: 'inline',
4 | presets: [
5 | 'module:metro-react-native-babel-preset',
6 | '@babel/preset-env',
7 | [
8 | '@babel/preset-react',
9 | {
10 | runtime: 'automatic',
11 | },
12 | ],
13 | '@babel/preset-typescript',
14 | ],
15 | plugins: [
16 | [
17 | 'module-resolver',
18 | {
19 | alias: {
20 | 'react-tv-space-navigation': '../lib/src/index.ts',
21 | },
22 | },
23 | ],
24 | ['@babel/plugin-proposal-class-properties', { loose: false }],
25 | ],
26 | };
27 |
--------------------------------------------------------------------------------
/packages/example/index.js:
--------------------------------------------------------------------------------
1 | import { registerRootComponent } from 'expo';
2 |
3 | import App from './App';
4 |
5 | // registerRootComponent calls AppRegistry.registerComponent('main', () => App);
6 | // It also ensures that whether you load the app in Expo Go or in a native build,
7 | // the environment is set up appropriately
8 | registerRootComponent(App);
9 |
--------------------------------------------------------------------------------
/packages/example/jest.config.js:
--------------------------------------------------------------------------------
1 | // @ts-check
2 |
3 | /**
4 | * Code in the react-native ecosystem if often shipped untransformed, with flow or typescript in files
5 | * App code also needs to be transformed (it's TypeScript), but the rest of node_modules doesn't need to.
6 | * Transforming the minimum amount of code makes tests run much faster
7 | *
8 | * If encountering a syntax error during tests with a new package, add it to this list
9 | */
10 |
11 | // eslint-disable-next-line @typescript-eslint/no-var-requires
12 | const path = require('path');
13 |
14 | const packagesToTransform = [
15 | 'react-native',
16 | 'react-native-(.*)',
17 | '@react-native',
18 | '@react-native-community',
19 | '@react-native-tvos',
20 | '@react-navigation',
21 | ];
22 |
23 | /** @type {import('@jest/types').Config.InitialOptions} */
24 | const config = {
25 | preset: '@testing-library/react-native',
26 | /*
27 | * What the preset provides:
28 | * - a transformer to handle media assets (png, video)
29 | */
30 | // test environment setup
31 | setupFiles: ['./src/testing/jest-setup.ts'],
32 | setupFilesAfterEnv: ['./src/testing/jest-setupAfterEnv.ts'],
33 | clearMocks: true,
34 | // module resolution
35 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
36 | testRegex: '\\.test\\.[jt]sx?$',
37 | transform: {
38 | '\\.[jt]sx?$': [
39 | 'babel-jest',
40 | { configFile: path.resolve(__dirname, './babel.jest.config.js') },
41 | ],
42 | },
43 | transformIgnorePatterns: [`node_modules/(?!(${packagesToTransform.join('|')})/)`],
44 | cacheDirectory: '.cache/jest',
45 | // coverage
46 | collectCoverageFrom: ['src/**/*.{js,jsx,ts,tsx}'],
47 | coveragePathIgnorePatterns: ['/node_modules/'],
48 | // tools
49 | watchPlugins: ['jest-watch-typeahead/filename', 'jest-watch-typeahead/testname'],
50 | reporters: ['default', 'github-actions'], // Remove this line if your CI is not on Github actions
51 | };
52 |
53 | module.exports = config;
54 |
--------------------------------------------------------------------------------
/packages/example/metro.config.js:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line @typescript-eslint/no-var-requires
2 | const { getDefaultConfig } = require('expo/metro-config');
3 | // eslint-disable-next-line @typescript-eslint/no-var-requires
4 | const path = require('path');
5 |
6 | // Find the project and workspace directories
7 | const projectRoot = __dirname;
8 | // This can be replaced with `find-yarn-workspace-root`
9 | const workspaceRoot = path.resolve(projectRoot, '../..');
10 |
11 | const config = getDefaultConfig(projectRoot);
12 |
13 | // 1. Watch all files within the monorepo
14 | config.watchFolders = [workspaceRoot];
15 | // 2. Let Metro know where to resolve packages and in what order
16 | config.resolver.nodeModulesPaths = [
17 | path.resolve(projectRoot, 'node_modules'),
18 | path.resolve(workspaceRoot, 'node_modules'),
19 | ];
20 |
21 | // 3. Force Metro to resolve (sub)dependencies only from the `nodeModulesPaths`
22 | // config.resolver.disableHierarchicalLookup = true;
23 |
24 | module.exports = config;
25 |
--------------------------------------------------------------------------------
/packages/example/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "hoppixtv",
3 | "version": "1.0.0",
4 | "main": "index.js",
5 | "scripts": {
6 | "start": "EXPO_NO_CLIENT_ENV_VARS=1 EXPO_TV=1 expo start",
7 | "android": "EXPO_NO_CLIENT_ENV_VARS=1 EXPO_TV=1 expo run:android",
8 | "ios": "EXPO_NO_CLIENT_ENV_VARS=1 EXPO_TV=1 expo run:ios",
9 | "web": "EXPO_NO_CLIENT_ENV_VARS=1 expo start --web",
10 | "build:web": "expo export -p web",
11 | "test:types": "tsc",
12 | "prebuild": "EXPO_TV=1 expo prebuild --clean"
13 | },
14 | "dependencies": {
15 | "@bam.tech/react-native-keyevent-expo-config-plugin": "^1.0.52",
16 | "@emotion/native": "^11.11.0",
17 | "@emotion/react": "^11.11.3",
18 | "@expo/metro-runtime": "~3.2.1",
19 | "@react-navigation/native": "^6.1.9",
20 | "@react-navigation/native-stack": "^6.9.17",
21 | "@types/jest": "^29.5.12",
22 | "@types/react-test-renderer": "^18.0.7",
23 | "babel-jest": "^29.7.0",
24 | "expo": "~51.0.9",
25 | "expo-status-bar": "~1.12.1",
26 | "jest": "^29.7.0",
27 | "jest-environment-jsdom": "^29.7.0",
28 | "jest-watch-typeahead": "^2.2.2",
29 | "lucide-react-native": "^0.335.0",
30 | "react": "18.2.0",
31 | "react-native": "npm:react-native-tvos@0.74.1-0",
32 | "react-native-keyevent": "^0.3.2",
33 | "react-native-safe-area-context": "4.10.1",
34 | "react-native-screens": "3.31.1",
35 | "react-native-svg": "15.2.0",
36 | "react-test-renderer": "^18.2.0",
37 | "typescript": "^5.6.2"
38 | },
39 | "devDependencies": {
40 | "@babel/core": "^7.24.0",
41 | "babel-plugin-module-resolver": "^5.0.0"
42 | },
43 | "private": true,
44 | "expo": {
45 | "install": {
46 | "exclude": [
47 | "react-native"
48 | ]
49 | }
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/packages/example/public/.nojekyll:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bamlab/react-tv-space-navigation/3ae894aa4bb593a25ea2c2b1ae40fe8061b4b4c7/packages/example/public/.nojekyll
--------------------------------------------------------------------------------
/packages/example/src/components/GoBackConfiguration.tsx:
--------------------------------------------------------------------------------
1 | import { useNavigation } from '@react-navigation/native';
2 | import { SupportedKeys } from './remote-control/SupportedKeys';
3 | import { useKey } from '../hooks/useKey';
4 | import { useCallback, useEffect } from 'react';
5 | import { BackHandler } from 'react-native';
6 |
7 | export const GoBackConfiguration = () => {
8 | const navigation = useNavigation();
9 |
10 | useEffect(() => {
11 | const event = BackHandler.addEventListener('hardwareBackPress', () => {
12 | return true;
13 | });
14 |
15 | return () => {
16 | event.remove();
17 | };
18 | }, []);
19 |
20 | const goBackOnBackPress = useCallback(
21 | (pressedKey: SupportedKeys) => {
22 | if (!navigation.isFocused) {
23 | return false;
24 | }
25 | if (pressedKey !== SupportedKeys.Back) return false;
26 | if (navigation.canGoBack()) {
27 | navigation.goBack();
28 | return true;
29 | }
30 | return false;
31 | },
32 | [navigation],
33 | );
34 |
35 | useKey(SupportedKeys.Back, goBackOnBackPress);
36 |
37 | return <>>;
38 | };
39 |
--------------------------------------------------------------------------------
/packages/example/src/components/Menu/MenuButton.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/native';
2 | import { forwardRef } from 'react';
3 | import { Animated, View } from 'react-native';
4 | import { SpatialNavigationFocusableView } from 'react-tv-space-navigation';
5 | import { scaledPixels } from '../../design-system/helpers/scaledPixels';
6 | import { useFocusAnimation } from '../../design-system/helpers/useFocusAnimation';
7 | import { theme } from '../../design-system/theme/theme';
8 | import { Icon } from '../../design-system/helpers/Icons';
9 | import { IconName } from '../../design-system/helpers/IconsCatalog';
10 |
11 | type ButtonProps = {
12 | icon: IconName;
13 | isMenuOpen: boolean;
14 | onSelect?: () => void;
15 | };
16 |
17 | const ButtonContent = forwardRef(
18 | (props, ref) => {
19 | const { isFocused, icon, isMenuOpen } = props;
20 | const anim = useFocusAnimation(isFocused && isMenuOpen);
21 | return (
22 |
23 |
32 |
33 | );
34 | },
35 | );
36 |
37 | ButtonContent.displayName = 'ButtonContent';
38 |
39 | export const MenuButton = ({ icon, isMenuOpen, onSelect }: ButtonProps) => {
40 | return (
41 |
42 | {({ isFocused }) => (
43 |
44 | )}
45 |
46 | );
47 | };
48 |
49 | const Container = styled(Animated.View)<{ isFocused: boolean; isMenuOpen: boolean }>(
50 | ({ isFocused, isMenuOpen, theme }) => ({
51 | alignSelf: 'baseline',
52 | backgroundColor: isFocused && isMenuOpen ? 'white' : 'black',
53 | padding: theme.spacings.$4,
54 | borderRadius: scaledPixels(12),
55 | cursor: 'pointer',
56 | }),
57 | );
58 |
--------------------------------------------------------------------------------
/packages/example/src/components/Menu/MenuContext.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, useContext, useMemo, useState } from 'react';
2 |
3 | const MenuContext = createContext<{ isOpen: boolean; toggleMenu: (isOpen: boolean) => void }>({
4 | isOpen: false,
5 | toggleMenu: () => {},
6 | });
7 |
8 | export const MenuProvider = ({ children }: { children: React.ReactNode }) => {
9 | const [isOpen, setIsOpen] = useState(false);
10 |
11 | const value = useMemo(() => {
12 | return { isOpen, toggleMenu: setIsOpen };
13 | }, [isOpen, setIsOpen]);
14 |
15 | return {children};
16 | };
17 |
18 | export const useMenuContext = () => useContext(MenuContext);
19 |
--------------------------------------------------------------------------------
/packages/example/src/components/Page.tsx:
--------------------------------------------------------------------------------
1 | import { Direction } from '@bam.tech/lrud';
2 | import { useIsFocused } from '@react-navigation/native';
3 | import { ReactNode, useCallback, useEffect } from 'react';
4 | import { SpatialNavigationRoot, useLockSpatialNavigation } from 'react-tv-space-navigation';
5 | import { useMenuContext } from './Menu/MenuContext';
6 | import { Keyboard } from 'react-native';
7 | import { GoBackConfiguration } from './GoBackConfiguration';
8 |
9 | type Props = { children: ReactNode };
10 |
11 | /**
12 | * Locks/unlocks the navigator when the native keyboard is shown/hidden.
13 | * Allows for the native focus to take over when the keyboard is open,
14 | * and to go back to our own system when the keyboard is closed.
15 | */
16 | const SpatialNavigationKeyboardLocker = () => {
17 | const lockActions = useLockSpatialNavigation();
18 | useEffect(() => {
19 | const showSubscription = Keyboard.addListener('keyboardDidShow', () => {
20 | lockActions.lock();
21 | });
22 | const hideSubscription = Keyboard.addListener('keyboardDidHide', () => {
23 | lockActions.unlock();
24 | });
25 |
26 | return () => {
27 | showSubscription.remove();
28 | hideSubscription.remove();
29 | };
30 | }, [lockActions]);
31 |
32 | return null;
33 | };
34 |
35 | export const Page = ({ children }: Props) => {
36 | const isFocused = useIsFocused();
37 | const { isOpen: isMenuOpen, toggleMenu } = useMenuContext();
38 |
39 | const isActive = isFocused && !isMenuOpen;
40 |
41 | const onDirectionHandledWithoutMovement = useCallback(
42 | (movement: Direction) => {
43 | if (movement === 'left') {
44 | toggleMenu(true);
45 | }
46 | },
47 | [toggleMenu],
48 | );
49 |
50 | return (
51 |
55 |
56 |
57 | {children}
58 |
59 | );
60 | };
61 |
--------------------------------------------------------------------------------
/packages/example/src/components/PanEvent/PanEvent.constants.ts:
--------------------------------------------------------------------------------
1 | export const GRID_SIZE = 1920;
2 | export const NUMBER_OF_COLUMNS = 5;
3 | export const EMIT_KEY_DOWN_INTERVAL = 30;
4 | export const THROTTLE_DELAY_MS = 30;
5 |
--------------------------------------------------------------------------------
/packages/example/src/components/PanEvent/PanEvent.ts:
--------------------------------------------------------------------------------
1 | import { getGridCoordinates, moveFocus } from './PanEvent.utils';
2 |
3 | class PanEvent {
4 | private orientation: string | undefined = undefined;
5 | private lastIndex = 0;
6 |
7 | reset = () => {
8 | this.orientation = undefined;
9 | this.lastIndex = 0;
10 | };
11 | handlePanEvent = ({ x, y }: { x: number; y: number }) => {
12 | const newIndex = getGridCoordinates(x, y, this);
13 | if (!newIndex) return;
14 | moveFocus(newIndex, this);
15 | };
16 |
17 | getOrientation = () => {
18 | return this.orientation;
19 | };
20 | setOrientation = (orientation: string) => {
21 | this.orientation = orientation;
22 | };
23 | getLastIndex = () => {
24 | return this.lastIndex;
25 | };
26 | setLastIndex = (lastIndex: number) => {
27 | this.lastIndex = lastIndex;
28 | };
29 | }
30 |
31 | export default PanEvent;
32 |
--------------------------------------------------------------------------------
/packages/example/src/components/PanEvent/PanEvent.utils.ts:
--------------------------------------------------------------------------------
1 | import { repeat } from '../../utils/repeat';
2 | import RemoteControlManager from '../remote-control/RemoteControlManager';
3 | import { SupportedKeys } from '../remote-control/SupportedKeys';
4 | import PanEvent from './PanEvent';
5 | import { EMIT_KEY_DOWN_INTERVAL, GRID_SIZE, NUMBER_OF_COLUMNS } from './PanEvent.constants';
6 |
7 | export const getGridCoordinates = (
8 | x: number,
9 | y: number,
10 | panEvent: PanEvent,
11 | ): number | undefined => {
12 | const gridElementSize = GRID_SIZE / NUMBER_OF_COLUMNS;
13 |
14 | const xIndex = Math.floor((x + gridElementSize / 2) / gridElementSize);
15 | const yIndex = Math.floor((y + gridElementSize / 2) / gridElementSize);
16 |
17 | if (!panEvent.getOrientation()) {
18 | // Lock orientation after significant movement to avoid sliding in two directions
19 | if (xIndex !== panEvent.getLastIndex()) {
20 | panEvent.setOrientation('x');
21 | return xIndex;
22 | }
23 | if (yIndex !== panEvent.getLastIndex()) {
24 | panEvent.setOrientation('y');
25 | return yIndex;
26 | }
27 | return;
28 | }
29 |
30 | if (panEvent.getOrientation() === 'x' && xIndex !== panEvent.getLastIndex()) {
31 | return xIndex;
32 | }
33 |
34 | if (panEvent.getOrientation() === 'y' && yIndex !== panEvent.getLastIndex()) {
35 | return yIndex;
36 | }
37 | };
38 |
39 | export const moveFocus = (index: number, panEvent: PanEvent) => {
40 | const indexDif = index - panEvent.getLastIndex();
41 | panEvent.setLastIndex(index);
42 |
43 | if (panEvent.getOrientation() === 'x') {
44 | repeat(
45 | () =>
46 | RemoteControlManager.emitKeyDown(indexDif > 0 ? SupportedKeys.Right : SupportedKeys.Left),
47 | EMIT_KEY_DOWN_INTERVAL,
48 | Math.abs(indexDif),
49 | );
50 | }
51 | if (panEvent.getOrientation() === 'y') {
52 | repeat(
53 | () => RemoteControlManager.emitKeyDown(indexDif > 0 ? SupportedKeys.Down : SupportedKeys.Up),
54 | EMIT_KEY_DOWN_INTERVAL,
55 | Math.abs(indexDif),
56 | );
57 | }
58 | };
59 |
--------------------------------------------------------------------------------
/packages/example/src/components/PanEvent/panEventHandler.ts:
--------------------------------------------------------------------------------
1 | import { HWEvent } from 'react-native';
2 | import { throttle } from '../../utils/throttle';
3 |
4 | import PanEvent from './PanEvent';
5 | import { THROTTLE_DELAY_MS } from './PanEvent.constants';
6 |
7 | const myPanEvent = new PanEvent();
8 |
9 | export const panEventHandler = (event: HWEvent) => {
10 | throttle(() => {
11 | if (event.eventType === 'pan') {
12 | if (!event.body) return;
13 | if (event.body.state === 'Began') {
14 | myPanEvent.reset();
15 | }
16 | if (event.body.state === 'Changed') {
17 | myPanEvent.handlePanEvent({ x: event.body.x, y: event.body.y });
18 | }
19 | }
20 | }, THROTTLE_DELAY_MS)();
21 | };
22 |
--------------------------------------------------------------------------------
/packages/example/src/components/PanEvent/useTVPanEvent.ios.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 | import { TVEventControl, useTVEventHandler } from 'react-native';
3 | import { panEventHandler } from './panEventHandler';
4 |
5 | export const useTVPanEvent = () => {
6 | useEffect(() => {
7 | TVEventControl.enableTVPanGesture();
8 | return () => {
9 | TVEventControl.disableTVPanGesture();
10 | };
11 | }, []);
12 | useTVEventHandler(panEventHandler);
13 | };
14 |
--------------------------------------------------------------------------------
/packages/example/src/components/PanEvent/useTVPanEvent.ts:
--------------------------------------------------------------------------------
1 | export const useTVPanEvent = () => {
2 | return null;
3 | };
4 |
--------------------------------------------------------------------------------
/packages/example/src/components/VirtualizedSpatialGrid.tsx:
--------------------------------------------------------------------------------
1 | import { useTheme } from '@emotion/react';
2 | import { useCallback, useMemo } from 'react';
3 | import { StyleSheet, View, ViewStyle } from 'react-native';
4 | import { SpatialNavigationVirtualizedGrid } from 'react-tv-space-navigation';
5 | import { getPrograms } from '../modules/program/infra/programInfos';
6 | import { ProgramNode } from '../modules/program/view/ProgramNode';
7 | import { scaledPixels } from '../design-system/helpers/scaledPixels';
8 | import { theme } from '../design-system/theme/theme';
9 | import { Header } from '../modules/header/view/Header';
10 | import { BottomArrow, TopArrow } from '../design-system/components/Arrows';
11 | import { ProgramInfo } from '../modules/program/domain/programInfo';
12 |
13 | const NUMBER_OF_COLUMNS = 7;
14 | const INFINITE_SCROLL_ROW_THRESHOLD = 2;
15 |
16 | export const VirtualizedSpatialGrid = ({ containerStyle }: { containerStyle?: ViewStyle }) => {
17 | const renderItem = useCallback(
18 | ({ item, index }: { item: ProgramInfo; index: number }) => (
19 |
20 | ),
21 | [],
22 | );
23 |
24 | const hardcodedRabbitsArray = useMemo(
25 | () => getPrograms(500).map((element, index) => ({ ...element, index })),
26 | [],
27 | );
28 | const theme = useTheme();
29 |
30 | return (
31 |
32 |
40 | }
41 | headerSize={scaledPixels(500)}
42 | renderItem={renderItem}
43 | itemHeight={theme.sizes.program.portrait.height * 1.1}
44 | numberOfColumns={NUMBER_OF_COLUMNS}
45 | onEndReachedThresholdRowsNumber={INFINITE_SCROLL_ROW_THRESHOLD}
46 | rowContainerStyle={styles.rowStyle}
47 | ascendingArrow={}
48 | ascendingArrowContainerStyle={styles.bottomArrowContainer}
49 | descendingArrow={}
50 | descendingArrowContainerStyle={styles.topArrowContainer}
51 | scrollInterval={150}
52 | />
53 |
54 | );
55 | };
56 |
57 | const styles = StyleSheet.create({
58 | container: {
59 | height: scaledPixels(1000),
60 | backgroundColor: theme.colors.background.mainHover,
61 | padding: scaledPixels(30),
62 | paddingLeft: scaledPixels(75),
63 | borderRadius: scaledPixels(20),
64 | overflow: 'hidden',
65 | },
66 | rowStyle: { gap: scaledPixels(30) },
67 | topArrowContainer: {
68 | width: '100%',
69 | height: 100,
70 | position: 'absolute',
71 | alignItems: 'center',
72 | justifyContent: 'center',
73 | top: 0,
74 | left: 0,
75 | },
76 | bottomArrowContainer: {
77 | width: '100%',
78 | height: 100,
79 | position: 'absolute',
80 | alignItems: 'center',
81 | justifyContent: 'center',
82 | bottom: -15,
83 | left: 0,
84 | },
85 | });
86 |
--------------------------------------------------------------------------------
/packages/example/src/components/configureRemoteControl.ts:
--------------------------------------------------------------------------------
1 | import { Directions, SpatialNavigation } from 'react-tv-space-navigation';
2 | import { SupportedKeys } from './remote-control/SupportedKeys';
3 | import RemoteControlManager from './remote-control/RemoteControlManager';
4 |
5 | SpatialNavigation.configureRemoteControl({
6 | remoteControlSubscriber: (callback) => {
7 | const mapping: { [key in SupportedKeys]: Directions | null } = {
8 | [SupportedKeys.Right]: Directions.RIGHT,
9 | [SupportedKeys.Left]: Directions.LEFT,
10 | [SupportedKeys.Up]: Directions.UP,
11 | [SupportedKeys.Down]: Directions.DOWN,
12 | [SupportedKeys.Enter]: Directions.ENTER,
13 | [SupportedKeys.LongEnter]: Directions.LONG_ENTER,
14 | [SupportedKeys.Back]: null,
15 | };
16 |
17 | const remoteControlListener = (keyEvent: SupportedKeys) => {
18 | callback(mapping[keyEvent]);
19 | return false;
20 | };
21 |
22 | return RemoteControlManager.addKeydownListener(remoteControlListener);
23 | },
24 |
25 | remoteControlUnsubscriber: (remoteControlListener) => {
26 | RemoteControlManager.removeKeydownListener(remoteControlListener);
27 | },
28 | });
29 |
--------------------------------------------------------------------------------
/packages/example/src/components/modals/Modal.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/native';
2 | import React from 'react';
3 | import { View, ModalProps } from 'react-native';
4 | import { Typography } from '../../design-system/components/Typography';
5 | import { Spacer } from '../../design-system/components/Spacer';
6 | import { colors } from '../../design-system/theme/colors';
7 | import { SpatialNavigationOverlay } from './SpatialNavigationOverlay/SpatialNavigationOverlay';
8 |
9 | type CustomModalProps = ModalProps & {
10 | isModalVisible: boolean;
11 | hideModal: () => void;
12 | children: React.ReactNode;
13 | title: string;
14 | };
15 |
16 | export const Modal = ({ isModalVisible, hideModal, children, title }: CustomModalProps) => {
17 | if (!isModalVisible) return null;
18 |
19 | return (
20 |
21 |
22 |
23 | {title}
24 |
25 |
26 |
27 | {children}
28 |
29 |
30 |
31 | );
32 | };
33 |
34 | const StyledModal = styled(View)({
35 | position: 'absolute',
36 | top: 0,
37 | left: 0,
38 | right: 0,
39 | bottom: 0,
40 | justifyContent: 'center',
41 | alignItems: 'center',
42 | });
43 |
44 | const ModalContentContainer = styled(View)({
45 | minHeight: 200,
46 | minWidth: 200,
47 | backgroundColor: colors.background.main,
48 | borderWidth: 2,
49 | borderColor: colors.primary.light,
50 | padding: 32,
51 | margin: 16,
52 | borderRadius: 16,
53 | justifyContent: 'center',
54 | });
55 |
--------------------------------------------------------------------------------
/packages/example/src/components/modals/SpatialNavigationOverlay/SpatialNavigationOverlay.tsx:
--------------------------------------------------------------------------------
1 | import { SpatialNavigationRoot } from '../../../../../lib/src/spatial-navigation/components/Root';
2 | import { useLockOverlay } from './useLockOverlay';
3 |
4 | type SpatialNavigationOverlayProps = {
5 | isModalVisible: boolean;
6 | hideModal: () => void;
7 | children: React.ReactNode;
8 | };
9 |
10 | export const SpatialNavigationOverlay = ({
11 | isModalVisible,
12 | hideModal,
13 | children,
14 | }: SpatialNavigationOverlayProps) => {
15 | useLockOverlay({ isModalVisible, hideModal });
16 |
17 | return {children};
18 | };
19 |
--------------------------------------------------------------------------------
/packages/example/src/components/modals/SpatialNavigationOverlay/useLockOverlay.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect } from 'react';
2 | import { useLockSpatialNavigation } from '../../../../../lib/src/spatial-navigation/context/LockSpatialNavigationContext';
3 | import { useKey } from '../../../hooks/useKey';
4 | import { SupportedKeys } from '../../remote-control/SupportedKeys';
5 |
6 | interface UseLockProps {
7 | isModalVisible: boolean;
8 | hideModal: () => void;
9 | }
10 |
11 | // This hook is used to lock the spatial navigation of parent navigator when a modal is open
12 | // and to prevent the user from closing the modal by pressing the back button
13 | export const useLockOverlay = ({ isModalVisible, hideModal }: UseLockProps) => {
14 | useLockParentSpatialNavigator(isModalVisible);
15 | usePreventNavigationGoBack(isModalVisible, hideModal);
16 | };
17 |
18 | const useLockParentSpatialNavigator = (isModalVisible: boolean) => {
19 | const { lock, unlock } = useLockSpatialNavigation();
20 | useEffect(() => {
21 | if (isModalVisible) {
22 | lock();
23 | return () => {
24 | unlock();
25 | };
26 | }
27 | }, [isModalVisible, lock, unlock]);
28 | };
29 |
30 | const usePreventNavigationGoBack = (isModalVisible: boolean, hideModal: () => void) => {
31 | const hideModalListener = useCallback(() => {
32 | if (isModalVisible) {
33 | hideModal();
34 | return true;
35 | }
36 | return false;
37 | }, [isModalVisible, hideModal]);
38 | useKey(SupportedKeys.Back, hideModalListener);
39 | };
40 |
--------------------------------------------------------------------------------
/packages/example/src/components/modals/SubtitlesModal.tsx:
--------------------------------------------------------------------------------
1 | import { DefaultFocus } from '../../../../lib/src/spatial-navigation/context/DefaultFocusContext';
2 | import { Button } from '../../design-system/components/Button';
3 | import { Spacer } from '../../design-system/components/Spacer';
4 | import { Modal } from './Modal';
5 |
6 | interface SubtitlesModalProps {
7 | isModalVisible: boolean;
8 | setIsModalVisible: (isVisible: boolean) => void;
9 | setSubtitles: (subtitles: string) => void;
10 | }
11 |
12 | export const SubtitlesModal = ({
13 | isModalVisible,
14 | setIsModalVisible,
15 | setSubtitles,
16 | }: SubtitlesModalProps) => {
17 | return (
18 | setIsModalVisible(false)}
21 | title={'Choose subtitles'}
22 | >
23 |
24 |
32 |
33 |
57 | );
58 | };
59 |
--------------------------------------------------------------------------------
/packages/example/src/components/remote-control/CustomEventEmitter.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * This event emitter is a minimal reimplementation of `mitt` with the support of stoppable event propagation
3 | */
4 |
5 | export type EventType = string | symbol;
6 |
7 | // An event handler can take an optional event argument
8 | // and should return a boolean indicating whether or not to stop event propagation
9 | export type Handler = (event: T) => boolean;
10 |
11 | // An array of all currently registered event handlers for a type
12 | export type EventHandlerList = Array>;
13 |
14 | // A map of event types and their corresponding event handlers.
15 | export type EventHandlerMap> = Map<
16 | keyof Events,
17 | EventHandlerList
18 | >;
19 |
20 | export default class CustomEventEmitter> {
21 | private handlers: EventHandlerMap = new Map();
22 |
23 | on = (eventType: Key, handler: Handler) => {
24 | const eventTypeHandlers = this.handlers.get(eventType);
25 | if (!Array.isArray(eventTypeHandlers)) this.handlers.set(eventType, [handler]);
26 | else eventTypeHandlers.push(handler);
27 | };
28 |
29 | off = (eventType: Key, handler?: Handler) => {
30 | this.handlers.set(
31 | eventType,
32 | // @ts-expect-error TODO fix the type error
33 | this.handlers.get(eventType).filter((h) => h !== handler),
34 | );
35 | };
36 |
37 | emit = (eventType: Key, evt?: Events[Key]) => {
38 | const eventTypeHandlers = this.handlers.get(eventType);
39 | // @ts-expect-error TODO fix the type error
40 | for (let index = eventTypeHandlers.length - 1; index >= 0; index--) {
41 | // @ts-expect-error TODO fix the type error
42 | const handler = eventTypeHandlers[index];
43 | // @ts-expect-error TODO fix the type error
44 | if (handler(evt)) {
45 | return;
46 | }
47 | }
48 | };
49 | }
50 |
--------------------------------------------------------------------------------
/packages/example/src/components/remote-control/RemoteControlManager.android.ts:
--------------------------------------------------------------------------------
1 | import { SupportedKeys } from './SupportedKeys';
2 | import KeyEvent from 'react-native-keyevent';
3 | import { RemoteControlManagerInterface } from './RemoteControlManager.interface';
4 | import CustomEventEmitter from './CustomEventEmitter';
5 |
6 | const LONG_PRESS_DURATION = 500;
7 |
8 | class RemoteControlManager implements RemoteControlManagerInterface {
9 | constructor() {
10 | KeyEvent.onKeyDownListener(this.handleKeyDown);
11 | KeyEvent.onKeyUpListener(this.handleKeyUp);
12 | }
13 |
14 | private eventEmitter = new CustomEventEmitter<{ keyDown: SupportedKeys }>();
15 |
16 | private isEnterKeyDownPressed = false;
17 | private longEnterTimeout: NodeJS.Timeout | null = null;
18 |
19 | private handleLongEnter = () => {
20 | this.longEnterTimeout = setTimeout(() => {
21 | this.eventEmitter.emit('keyDown', SupportedKeys.LongEnter);
22 | this.longEnterTimeout = null;
23 | }, LONG_PRESS_DURATION);
24 | };
25 |
26 | private handleKeyDown = (keyEvent: { keyCode: number }) => {
27 | const mappedKey = {
28 | 21: SupportedKeys.Left,
29 | 22: SupportedKeys.Right,
30 | 20: SupportedKeys.Down,
31 | 19: SupportedKeys.Up,
32 | 66: SupportedKeys.Enter,
33 | 23: SupportedKeys.Enter,
34 | 67: SupportedKeys.Back,
35 | 4: SupportedKeys.Back,
36 | }[keyEvent.keyCode];
37 |
38 | if (!mappedKey) {
39 | return;
40 | }
41 |
42 | if (mappedKey === SupportedKeys.Enter) {
43 | if (!this.isEnterKeyDownPressed) {
44 | this.isEnterKeyDownPressed = true;
45 | this.handleLongEnter();
46 | }
47 | return;
48 | }
49 |
50 | this.eventEmitter.emit('keyDown', mappedKey);
51 | };
52 |
53 | private handleKeyUp = (keyEvent: { keyCode: number }) => {
54 | const mappedKey = {
55 | 66: SupportedKeys.Enter,
56 | 23: SupportedKeys.Enter,
57 | }[keyEvent.keyCode];
58 |
59 | if (!mappedKey) {
60 | return;
61 | }
62 |
63 | if (mappedKey === SupportedKeys.Enter) {
64 | this.isEnterKeyDownPressed = false;
65 | if (this.longEnterTimeout) {
66 | clearTimeout(this.longEnterTimeout);
67 | this.eventEmitter.emit('keyDown', mappedKey);
68 | }
69 | }
70 | };
71 |
72 | addKeydownListener = (listener: (event: SupportedKeys) => boolean) => {
73 | this.eventEmitter.on('keyDown', listener);
74 | return listener;
75 | };
76 |
77 | removeKeydownListener = (listener: (event: SupportedKeys) => boolean) => {
78 | this.eventEmitter.off('keyDown', listener);
79 | };
80 |
81 | emitKeyDown = (key: SupportedKeys) => {
82 | this.eventEmitter.emit('keyDown', key);
83 | };
84 | }
85 |
86 | export default new RemoteControlManager();
87 |
--------------------------------------------------------------------------------
/packages/example/src/components/remote-control/RemoteControlManager.interface.ts:
--------------------------------------------------------------------------------
1 | import { SupportedKeys } from './SupportedKeys';
2 |
3 | export interface RemoteControlManagerInterface {
4 | addKeydownListener: (listener: (event: SupportedKeys) => boolean) => void;
5 | removeKeydownListener: (listener: (event: SupportedKeys) => boolean) => void;
6 | emitKeyDown: (key: SupportedKeys) => void;
7 | }
8 |
--------------------------------------------------------------------------------
/packages/example/src/components/remote-control/RemoteControlManager.ios.ts:
--------------------------------------------------------------------------------
1 | import { SupportedKeys } from './SupportedKeys';
2 | import { HWEvent, TVEventHandler } from 'react-native';
3 | import { RemoteControlManagerInterface } from './RemoteControlManager.interface';
4 | import CustomEventEmitter from './CustomEventEmitter';
5 |
6 | class RemoteControlManager implements RemoteControlManagerInterface {
7 | constructor() {
8 | TVEventHandler.addListener(this.handleKeyDown);
9 | }
10 |
11 | private eventEmitter = new CustomEventEmitter<{ keyDown: SupportedKeys }>();
12 |
13 | private handleKeyDown = (evt: HWEvent) => {
14 | if (!evt) return;
15 |
16 | const mappedKey = {
17 | right: SupportedKeys.Right,
18 | left: SupportedKeys.Left,
19 | up: SupportedKeys.Up,
20 | down: SupportedKeys.Down,
21 | select: SupportedKeys.Enter,
22 | longSelect: SupportedKeys.LongEnter,
23 | }[evt.eventType];
24 |
25 | if (!mappedKey) {
26 | return;
27 | }
28 |
29 | // We only want to handle keydown for long select to avoid triggering the event twice
30 | if (mappedKey === SupportedKeys.LongEnter && evt.eventKeyAction === 1) {
31 | return;
32 | }
33 |
34 | this.eventEmitter.emit('keyDown', mappedKey);
35 | };
36 |
37 | addKeydownListener = (listener: (event: SupportedKeys) => boolean) => {
38 | this.eventEmitter.on('keyDown', listener);
39 | return listener;
40 | };
41 |
42 | removeKeydownListener = (listener: (event: SupportedKeys) => boolean) => {
43 | this.eventEmitter.off('keyDown', listener);
44 | };
45 |
46 | emitKeyDown = (key: SupportedKeys) => {
47 | this.eventEmitter.emit('keyDown', key);
48 | };
49 | }
50 |
51 | export default new RemoteControlManager();
52 |
--------------------------------------------------------------------------------
/packages/example/src/components/remote-control/RemoteControlManager.ts:
--------------------------------------------------------------------------------
1 | import { SupportedKeys } from './SupportedKeys';
2 | import { RemoteControlManagerInterface } from './RemoteControlManager.interface';
3 | import CustomEventEmitter from './CustomEventEmitter';
4 |
5 | const LONG_PRESS_DURATION = 500;
6 |
7 | class RemoteControlManager implements RemoteControlManagerInterface {
8 | constructor() {
9 | window.addEventListener('keydown', this.handleKeyDown);
10 | window.addEventListener('keyup', this.handleKeyUp);
11 | }
12 |
13 | private eventEmitter = new CustomEventEmitter<{ keyDown: SupportedKeys }>();
14 |
15 | private isEnterKeyDown = false;
16 | private longEnterTimeout: NodeJS.Timeout | null = null;
17 |
18 | private handleLongEnter = () => {
19 | this.longEnterTimeout = setTimeout(() => {
20 | this.eventEmitter.emit('keyDown', SupportedKeys.LongEnter);
21 | this.longEnterTimeout = null;
22 | }, LONG_PRESS_DURATION);
23 | };
24 |
25 | private handleKeyDown = (event: KeyboardEvent) => {
26 | const mappedKey = {
27 | ArrowRight: SupportedKeys.Right,
28 | ArrowLeft: SupportedKeys.Left,
29 | ArrowUp: SupportedKeys.Up,
30 | ArrowDown: SupportedKeys.Down,
31 | Enter: SupportedKeys.Enter,
32 | Backspace: SupportedKeys.Back,
33 | }[event.code];
34 |
35 | if (!mappedKey) {
36 | return;
37 | }
38 |
39 | if (mappedKey === SupportedKeys.Enter) {
40 | if (!this.isEnterKeyDown) {
41 | this.isEnterKeyDown = true;
42 | this.handleLongEnter();
43 | }
44 | return;
45 | }
46 |
47 | this.eventEmitter.emit('keyDown', mappedKey);
48 | };
49 |
50 | private handleKeyUp = (event: KeyboardEvent) => {
51 | const mappedKey = {
52 | Enter: SupportedKeys.Enter,
53 | }[event.code];
54 |
55 | if (!mappedKey) {
56 | return;
57 | }
58 |
59 | if (mappedKey === SupportedKeys.Enter) {
60 | this.isEnterKeyDown = false;
61 | if (this.longEnterTimeout) {
62 | clearTimeout(this.longEnterTimeout);
63 | this.eventEmitter.emit('keyDown', mappedKey);
64 | }
65 | }
66 | };
67 |
68 | addKeydownListener = (listener: (event: SupportedKeys) => boolean) => {
69 | this.eventEmitter.on('keyDown', listener);
70 | return listener;
71 | };
72 |
73 | removeKeydownListener = (listener: (event: SupportedKeys) => boolean) => {
74 | this.eventEmitter.off('keyDown', listener);
75 | };
76 |
77 | emitKeyDown = (key: SupportedKeys) => {
78 | this.eventEmitter.emit('keyDown', key);
79 | };
80 | }
81 |
82 | export default new RemoteControlManager();
83 |
--------------------------------------------------------------------------------
/packages/example/src/components/remote-control/SupportedKeys.ts:
--------------------------------------------------------------------------------
1 | export enum SupportedKeys {
2 | Up = 'Up',
3 | Down = 'Down',
4 | Left = 'Left',
5 | Right = 'Right',
6 | Enter = 'Enter',
7 | LongEnter = 'LongEnter',
8 | Back = 'Back',
9 | }
10 |
--------------------------------------------------------------------------------
/packages/example/src/components/tests/fixtures/programInfos.ts:
--------------------------------------------------------------------------------
1 | import Rabbit1 from '../../../modules/program/assets/rabbit1.png';
2 |
3 | export const programsFixture = [
4 | {
5 | id: '1',
6 | title: 'Program 1',
7 | image: Rabbit1,
8 | description: 'Program 1 description',
9 | },
10 | {
11 | id: '2',
12 | title: 'Program 2',
13 | image: Rabbit1,
14 | description: 'Program 2 description',
15 | },
16 | ];
17 |
--------------------------------------------------------------------------------
/packages/example/src/components/tests/helpers/configureTestRemoteControl.ts:
--------------------------------------------------------------------------------
1 | import { Directions, SpatialNavigation } from '../../../../../lib/src';
2 | import TestRemoteControlManager, { SupportedKeys } from './testRemoteControlManager';
3 |
4 | SpatialNavigation.configureRemoteControl({
5 | remoteControlSubscriber: (callback) => {
6 | const mapping: { [key in SupportedKeys]: Directions | null } = {
7 | [SupportedKeys.Right]: Directions.RIGHT,
8 | [SupportedKeys.Left]: Directions.LEFT,
9 | [SupportedKeys.Up]: Directions.UP,
10 | [SupportedKeys.Down]: Directions.DOWN,
11 | [SupportedKeys.Enter]: Directions.ENTER,
12 | [SupportedKeys.Back]: null,
13 | };
14 |
15 | const remoteControlListener = (keyEvent: SupportedKeys) => {
16 | callback(mapping[keyEvent]);
17 | return false;
18 | };
19 |
20 | return TestRemoteControlManager.addKeydownListener(remoteControlListener);
21 | },
22 |
23 | remoteControlUnsubscriber: (remoteControlListener) => {
24 | TestRemoteControlManager.removeKeydownListener(remoteControlListener);
25 | },
26 | });
27 |
--------------------------------------------------------------------------------
/packages/example/src/components/tests/helpers/testRemoteControlManager.ts:
--------------------------------------------------------------------------------
1 | import { act } from '@testing-library/react-native';
2 | import mitt from 'mitt';
3 |
4 | export enum SupportedKeys {
5 | Up = 'Up',
6 | Down = 'Down',
7 | Left = 'Left',
8 | Right = 'Right',
9 | Enter = 'Enter',
10 | Back = 'Back',
11 | }
12 |
13 | class TestRemoteControlManager {
14 | private eventEmitter = mitt<{ keyDown: SupportedKeys }>();
15 |
16 | public handleUp = () => {
17 | act(() => {
18 | this.eventEmitter.emit('keyDown', SupportedKeys.Up);
19 | jest.runAllTimers();
20 | });
21 | act(() => jest.runAllTimers());
22 | };
23 |
24 | public handleDown = () => {
25 | act(() => {
26 | this.eventEmitter.emit('keyDown', SupportedKeys.Down);
27 | });
28 | act(() => jest.runAllTimers());
29 | };
30 |
31 | public handleLeft = () => {
32 | act(() => {
33 | this.eventEmitter.emit('keyDown', SupportedKeys.Left);
34 | });
35 | act(() => jest.runAllTimers());
36 | };
37 |
38 | public handleRight = () => {
39 | act(() => {
40 | this.eventEmitter.emit('keyDown', SupportedKeys.Right);
41 | });
42 | act(() => jest.runAllTimers());
43 | };
44 |
45 | public handleEnter = () => {
46 | act(() => {
47 | this.eventEmitter.emit('keyDown', SupportedKeys.Enter);
48 | });
49 | act(() => jest.runAllTimers());
50 | };
51 |
52 | public handleBackSpace = () => {
53 | act(() => {
54 | this.eventEmitter.emit('keyDown', SupportedKeys.Back);
55 | });
56 | act(() => jest.runAllTimers());
57 | };
58 |
59 | addKeydownListener = (listener: (event: SupportedKeys) => boolean) => {
60 | this.eventEmitter.on('keyDown', listener);
61 | return listener;
62 | };
63 |
64 | removeKeydownListener = (listener: (event: SupportedKeys) => boolean) => {
65 | this.eventEmitter.off('keyDown', listener);
66 | };
67 | }
68 |
69 | export default new TestRemoteControlManager();
70 |
--------------------------------------------------------------------------------
/packages/example/src/design-system/assets/arrow-left.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bamlab/react-tv-space-navigation/3ae894aa4bb593a25ea2c2b1ae40fe8061b4b4c7/packages/example/src/design-system/assets/arrow-left.png
--------------------------------------------------------------------------------
/packages/example/src/design-system/components/Arrows.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/native';
2 | import { Image } from 'react-native';
3 | import arrowSource from '../assets/arrow-left.png';
4 | import React from 'react';
5 |
6 | const LeftArrowImage = styled(Image)({
7 | height: 70,
8 | width: 50,
9 | transform: [{ rotate: '180deg' }],
10 | });
11 |
12 | const RightArrowImage = styled(Image)({
13 | height: 70,
14 | width: 50,
15 | });
16 |
17 | export const LeftArrow = React.memo(() => {
18 | return ;
19 | });
20 | LeftArrow.displayName = 'LeftArrow';
21 |
22 | export const RightArrow = React.memo(() => {
23 | return ;
24 | });
25 | RightArrow.displayName = 'RightArrow';
26 |
27 | const BottomArrowImage = styled(Image)({
28 | height: 70,
29 | width: 50,
30 | transform: [{ rotate: '90deg' }],
31 | });
32 |
33 | const TopArrowImage = styled(Image)({
34 | height: 70,
35 | width: 50,
36 | transform: [{ rotate: '270deg' }],
37 | });
38 |
39 | export const BottomArrow = React.memo(() => {
40 | return ;
41 | });
42 | BottomArrow.displayName = 'BottomArrow';
43 |
44 | export const TopArrow = React.memo(() => {
45 | return ;
46 | });
47 | TopArrow.displayName = 'TopArrow';
48 |
--------------------------------------------------------------------------------
/packages/example/src/design-system/components/Box.tsx:
--------------------------------------------------------------------------------
1 | import type { ViewStyle } from 'react-native';
2 |
3 | import styled from '@emotion/native';
4 | import { type ReactNode } from 'react';
5 | import { View } from 'react-native';
6 |
7 | import { type Theme } from '../theme/theme.types';
8 |
9 | type BoxDirection = 'vertical' | 'horizontal';
10 |
11 | interface Props {
12 | direction?: BoxDirection;
13 | flex?: ViewStyle['flex'];
14 | flexWrap?: ViewStyle['flexWrap'];
15 | alignItems?: ViewStyle['alignItems'];
16 | justifyContent?: ViewStyle['justifyContent'];
17 | paddingHorizontal?: keyof Theme['spacings'];
18 | paddingVertical?: keyof Theme['spacings'];
19 | paddingBottom?: keyof Theme['spacings'];
20 | paddingRight?: keyof Theme['spacings'];
21 | paddingLeft?: keyof Theme['spacings'];
22 | paddingTop?: keyof Theme['spacings'];
23 | padding?: keyof Theme['spacings'];
24 | testID?: string;
25 | style?: ViewStyle;
26 | children: ReactNode;
27 | }
28 |
29 | export const Box = ({ direction = 'vertical', children, ...otherProps }: Props) => {
30 | return (
31 |
32 | {children}
33 |
34 | );
35 | };
36 |
37 | const StyledView = styled(View, {
38 | // direction prop is a reserved prop in React Native and should therefore not be forwarded !
39 | shouldForwardProp: (propName) => propName !== 'direction',
40 | })(
41 | ({
42 | direction,
43 | flex,
44 | flexWrap,
45 | alignItems,
46 | justifyContent,
47 | paddingHorizontal,
48 | paddingVertical,
49 | paddingBottom,
50 | paddingRight,
51 | paddingLeft,
52 | paddingTop,
53 | padding,
54 | theme,
55 | }) => ({
56 | flexDirection: direction === 'vertical' ? 'column' : 'row',
57 | ...(flex && { flex }),
58 | ...(flexWrap && { flexWrap }),
59 | ...(alignItems && { alignItems }),
60 | ...(justifyContent && { justifyContent }),
61 | paddingHorizontal: paddingHorizontal && theme.spacings[paddingHorizontal],
62 | paddingVertical: paddingVertical && theme.spacings[paddingVertical],
63 | paddingBottom: paddingBottom && theme.spacings[paddingBottom],
64 | paddingRight: paddingRight && theme.spacings[paddingRight],
65 | paddingLeft: paddingLeft && theme.spacings[paddingLeft],
66 | paddingTop: paddingTop && theme.spacings[paddingTop],
67 | padding: padding && theme.spacings[padding],
68 | }),
69 | );
70 |
--------------------------------------------------------------------------------
/packages/example/src/design-system/components/Button.tsx:
--------------------------------------------------------------------------------
1 | import { forwardRef } from 'react';
2 | import { Animated, View } from 'react-native';
3 | import { SpatialNavigationFocusableView } from 'react-tv-space-navigation';
4 | import { Typography } from './Typography';
5 | import styled from '@emotion/native';
6 | import { useFocusAnimation } from '../helpers/useFocusAnimation';
7 | import { scaledPixels } from '../helpers/scaledPixels';
8 |
9 | type ButtonProps = {
10 | label: string;
11 | onSelect?: () => void;
12 | };
13 |
14 | const ButtonContent = forwardRef((props, ref) => {
15 | const { isFocused, label } = props;
16 | const anim = useFocusAnimation(isFocused);
17 | return (
18 |
19 | {label}
20 |
21 | );
22 | });
23 |
24 | ButtonContent.displayName = 'ButtonContent';
25 |
26 | export const Button = ({ label, onSelect }: ButtonProps) => {
27 | return (
28 |
29 | {({ isFocused, isRootActive }) => (
30 |
31 | )}
32 |
33 | );
34 | };
35 |
36 | const Container = styled(Animated.View)<{ isFocused: boolean }>(({ isFocused, theme }) => ({
37 | alignSelf: 'baseline',
38 | backgroundColor: isFocused ? 'white' : 'black',
39 | padding: theme.spacings.$4,
40 | borderRadius: scaledPixels(12),
41 | cursor: 'pointer',
42 | }));
43 |
44 | const ColoredTypography = styled(Typography)<{ isFocused: boolean }>(({ isFocused }) => ({
45 | color: isFocused ? 'black' : 'white',
46 | }));
47 |
--------------------------------------------------------------------------------
/packages/example/src/design-system/components/Spacer.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/native';
2 | import React from 'react';
3 | import { View } from 'react-native';
4 |
5 | import { type Theme } from '../theme/theme.types';
6 |
7 | type GridSpacerDirection = 'vertical' | 'horizontal';
8 |
9 | type FlexSpacerProps = {
10 | direction?: never;
11 | gap?: never;
12 | flex: number;
13 | };
14 |
15 | type GridSpacerProps = {
16 | direction?: GridSpacerDirection;
17 | gap: keyof Theme['spacings'];
18 | flex?: never;
19 | };
20 |
21 | type Props = FlexSpacerProps | GridSpacerProps;
22 |
23 | const SpacerToMemoize = ({ direction = 'vertical', gap, flex }: Props) => {
24 | if (typeof flex === 'number') {
25 | return ;
26 | }
27 |
28 | return ;
29 | };
30 |
31 | const FlexSpacer = styled.View<{ flex: number }>(({ flex }) => ({
32 | flex,
33 | }));
34 |
35 | const GridSpacer = styled(View, {
36 | // flex and direction props are reserved props in React Native and should therefore not be forwarded !
37 | shouldForwardProp: (propName) => propName !== 'direction' && propName !== 'gap',
38 | })<{
39 | direction: GridSpacerDirection;
40 | gap: keyof Theme['spacings'];
41 | }>(({ direction, gap, theme }) => ({
42 | ...(direction === 'vertical' ? { height: theme.spacings[gap] } : { width: theme.spacings[gap] }),
43 | }));
44 |
45 | export const Spacer = React.memo(SpacerToMemoize);
46 |
--------------------------------------------------------------------------------
/packages/example/src/design-system/components/TextInput.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/native';
2 | import { SpatialNavigationNode } from 'react-tv-space-navigation';
3 | import { TextInput as RNTextInput } from 'react-native';
4 | import { useRef } from 'react';
5 | import { Typography } from './Typography';
6 | import { Box } from './Box';
7 |
8 | /**
9 | * It works, but it's not perfect.
10 | * If you press the back button on Android to dismiss the keyboard,
11 | * focus is in a weird state where we keep listening to remote control arrow movements.
12 | * Ideally, we'd like to always remove the native focus when the keyboard is dismissed.
13 | */
14 | export const TextInput = ({ label }: { label: string }) => {
15 | const ref = useRef(null);
16 |
17 | return (
18 |
19 | {label}
20 | {
23 | ref?.current?.focus();
24 | }}
25 | onFocus={() => {
26 | ref?.current?.focus();
27 | }}
28 | onBlur={() => {
29 | ref?.current?.blur();
30 | }}
31 | >
32 | {({ isFocused }) => }
33 |
34 |
35 | );
36 | };
37 |
38 | const StyledTextInput = styled(RNTextInput)<{ isFocused: boolean }>(({ isFocused, theme }) => ({
39 | borderColor: isFocused ? 'white' : 'black',
40 | borderWidth: 2,
41 | borderRadius: 8,
42 | color: 'white',
43 | backgroundColor: theme.colors.background.mainHover,
44 | }));
45 |
--------------------------------------------------------------------------------
/packages/example/src/design-system/components/Typography.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/native';
2 | import { ReactNode } from 'react';
3 | import { TextProps } from 'react-native';
4 | import { type FontWeight, type TypographyVariant } from '../theme/typography';
5 |
6 | export type TypographyProps = TextProps & {
7 | variant?: TypographyVariant;
8 | fontWeight?: FontWeight;
9 | children?: ReactNode;
10 | };
11 |
12 | export const Typography = ({
13 | variant = 'body',
14 | fontWeight = 'regular',
15 | children,
16 | ...textProps
17 | }: TypographyProps) => {
18 | return (
19 |
20 | {children}
21 |
22 | );
23 | };
24 |
25 | const StyledText = styled.Text<{
26 | variant: TypographyVariant;
27 | fontWeight: FontWeight;
28 | }>(({ variant, fontWeight, theme }) => ({
29 | ...theme.typography[variant][fontWeight],
30 | color: 'white',
31 | flexWrap: 'wrap',
32 | }));
33 |
--------------------------------------------------------------------------------
/packages/example/src/design-system/helpers/Icons.tsx:
--------------------------------------------------------------------------------
1 | import { IconName, iconsCatalog } from './IconsCatalog';
2 |
3 | export const Icon = ({ icon, size, color }: { icon: IconName; size: number; color: string }) => {
4 | const IconComponent = iconsCatalog[icon];
5 |
6 | return ;
7 | };
8 |
--------------------------------------------------------------------------------
/packages/example/src/design-system/helpers/IconsCatalog.ts:
--------------------------------------------------------------------------------
1 | import { Grid3X3, Home, LayoutDashboard, LayoutGrid, Timer } from 'lucide-react-native';
2 |
3 | export const iconsCatalog = {
4 | Home: Home,
5 | Grid3X3: Grid3X3,
6 | LayoutGrid: LayoutGrid,
7 | LayoutDashboard: LayoutDashboard,
8 | Timer: Timer,
9 | };
10 |
11 | export type IconName = keyof typeof iconsCatalog;
12 |
--------------------------------------------------------------------------------
/packages/example/src/design-system/helpers/scaledPixels.ts:
--------------------------------------------------------------------------------
1 | import { Dimensions } from 'react-native';
2 |
3 | export const screen = Dimensions.get('window');
4 | const scale = (screen.width || 1920) / 1920;
5 |
6 | /**
7 | * Unfortunately, AndroidTV handles pixels in a strange manner
8 | * PixelRatio does not seem to solve the problem properly on web.
9 | * So we just scale the pixels manually.
10 | *
11 | * https://github.com/react-native-tvos/react-native-tvos/issues/57
12 | */
13 | export const scaledPixels = (pixels: number) => pixels * scale;
14 |
--------------------------------------------------------------------------------
/packages/example/src/design-system/helpers/useFocusAnimation.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from 'react';
2 | import { Animated } from 'react-native';
3 |
4 | export const useFocusAnimation = (isFocused: boolean) => {
5 | const scaleAnimation = useRef(new Animated.Value(0)).current;
6 |
7 | useEffect(() => {
8 | Animated.spring(scaleAnimation, {
9 | toValue: isFocused ? 1.1 : 1,
10 | useNativeDriver: true,
11 | damping: 10,
12 | stiffness: 100,
13 | }).start();
14 | }, [isFocused, scaleAnimation]);
15 |
16 | return { transform: [{ scale: scaleAnimation }] };
17 | };
18 |
--------------------------------------------------------------------------------
/packages/example/src/design-system/helpers/useFocusAnimation.web.ts:
--------------------------------------------------------------------------------
1 | export const useFocusAnimation = (isFocused: boolean) => {
2 | return {
3 | transition: 'transform 0.4s ease-in-out',
4 | transform: [{ scale: isFocused ? 1.1 : 1 }],
5 | };
6 | };
7 |
--------------------------------------------------------------------------------
/packages/example/src/design-system/theme/colors.ts:
--------------------------------------------------------------------------------
1 | export const colors = {
2 | primary: {
3 | main: '#E50914',
4 | mainHover: '#D30813',
5 | light: '#EEF0F5',
6 | lightHover: '#D5D7DC',
7 | contrastText: '#FFFFFF',
8 | },
9 | secondary: {
10 | main: '#46D369',
11 | mainHover: '#3FB65C',
12 | light: '#EEF0F5',
13 | lightHover: '#D5D7DC',
14 | contrastText: '#FFFFFF',
15 | },
16 | background: {
17 | main: '#111111',
18 | mainHover: '#1a1a1a',
19 | mainActive: '#2a2a2a',
20 | light: '#EEF0F5',
21 | lightHover: '#D5D7DC',
22 | contrastText: '#FFFFFF',
23 | },
24 | };
25 |
--------------------------------------------------------------------------------
/packages/example/src/design-system/theme/sizes.ts:
--------------------------------------------------------------------------------
1 | import { scaledPixels } from '../helpers/scaledPixels';
2 |
3 | export const sizes = {
4 | program: {
5 | landscape: { width: scaledPixels(450), height: scaledPixels(200) },
6 | portrait: { width: scaledPixels(200), height: scaledPixels(250) },
7 | long: { width: scaledPixels(416), height: scaledPixels(250) },
8 | },
9 | menu: {
10 | open: scaledPixels(400),
11 | closed: scaledPixels(100),
12 | icon: scaledPixels(20),
13 | },
14 | };
15 |
--------------------------------------------------------------------------------
/packages/example/src/design-system/theme/spacings.ts:
--------------------------------------------------------------------------------
1 | import { scaledPixels } from '../helpers/scaledPixels';
2 |
3 | export const spacings = {
4 | 0: scaledPixels(0),
5 | '$0.5': scaledPixels(2),
6 | $1: scaledPixels(4),
7 | $2: scaledPixels(8),
8 | $3: scaledPixels(12),
9 | $4: scaledPixels(16),
10 | $5: scaledPixels(20),
11 | $6: scaledPixels(24),
12 | $7: scaledPixels(28),
13 | $8: scaledPixels(32),
14 | $9: scaledPixels(36),
15 | $10: scaledPixels(40),
16 | $12: scaledPixels(48),
17 | $15: scaledPixels(60),
18 | $20: scaledPixels(80),
19 | };
20 |
--------------------------------------------------------------------------------
/packages/example/src/design-system/theme/theme.ts:
--------------------------------------------------------------------------------
1 | import { colors } from './colors';
2 | import { sizes } from './sizes';
3 | import { spacings } from './spacings';
4 | import { typography } from './typography';
5 |
6 | export const theme = { spacings, sizes, colors, typography };
7 |
--------------------------------------------------------------------------------
/packages/example/src/design-system/theme/theme.types.ts:
--------------------------------------------------------------------------------
1 | import { theme } from './theme';
2 | export type Theme = typeof theme;
3 |
--------------------------------------------------------------------------------
/packages/example/src/design-system/theme/typography.ts:
--------------------------------------------------------------------------------
1 | import { scaledPixels } from '../helpers/scaledPixels';
2 |
3 | export const fontFamilies = {
4 | montserrat: {
5 | medium: 'Montserrat-Medium',
6 | semiBold: 'Montserrat-SemiBold',
7 | bold: 'Montserrat-Bold',
8 | },
9 | };
10 |
11 | export const typography = {
12 | title: {
13 | regular: {
14 | fontFamily: fontFamilies.montserrat.semiBold,
15 | fontSize: scaledPixels(32),
16 | lineHeight: scaledPixels(40),
17 | },
18 | strong: {
19 | fontFamily: fontFamilies.montserrat.bold,
20 | fontSize: scaledPixels(32),
21 | lineHeight: scaledPixels(40),
22 | },
23 | },
24 | body: {
25 | regular: {
26 | fontFamily: fontFamilies.montserrat.medium,
27 | fontSize: scaledPixels(24),
28 | lineHeight: scaledPixels(32),
29 | },
30 | strong: {
31 | fontFamily: fontFamilies.montserrat.semiBold,
32 | fontSize: scaledPixels(24),
33 | lineHeight: scaledPixels(32),
34 | },
35 | },
36 | } as const;
37 |
38 | export type TypographyVariant = keyof typeof typography;
39 |
40 | export type FontWeight = 'regular' | 'strong';
41 |
--------------------------------------------------------------------------------
/packages/example/src/design-system/typings/emotion.d.ts:
--------------------------------------------------------------------------------
1 | import '@emotion/react';
2 |
3 | import { type Theme as ThemeInterface } from '../theme/theme.types';
4 | declare module '@emotion/react' {
5 | // eslint-disable-next-line @typescript-eslint/no-empty-interface -- we do want an empty interface, it's indicated in the emotion docs
6 | export interface Theme extends ThemeInterface {}
7 | }
8 |
--------------------------------------------------------------------------------
/packages/example/src/hooks/useFonts.tsx:
--------------------------------------------------------------------------------
1 | import { useFonts as useFontsLoader } from 'expo-font';
2 |
3 | export const useFonts = (): { areFontsLoaded: boolean } => {
4 | const [fontsLoaded, fontError] = useFontsLoader({
5 | 'Montserrat-Bold': require('../../assets/fonts/Montserrat-Bold.ttf'),
6 | 'Montserrat-Regular': require('../../assets/fonts/Montserrat-Regular.ttf'),
7 | 'Montserrat-SemiBold': require('../../assets/fonts/Montserrat-SemiBold.ttf'),
8 | 'Montserrat-Medium': require('../../assets/fonts/Montserrat-Medium.ttf'),
9 | });
10 |
11 | if (!fontsLoaded && !fontError) {
12 | return { areFontsLoaded: false };
13 | }
14 |
15 | return { areFontsLoaded: true };
16 | };
17 |
--------------------------------------------------------------------------------
/packages/example/src/hooks/useKey.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 | import RemoteControlManager from '../components/remote-control/RemoteControlManager';
3 | import { SupportedKeys } from '../components/remote-control/SupportedKeys';
4 |
5 | /**
6 | * A convenient hook to listen to a key and react to it
7 | *
8 | * @example useKey(SupportedKeys.Back, () => { console.log('pressed back!') })
9 | */
10 | export const useKey = (key: SupportedKeys, callback: (pressedKey: SupportedKeys) => boolean) => {
11 | useEffect(() => {
12 | const remoteControlListener = (actualKey: SupportedKeys) => {
13 | if (actualKey !== key) return;
14 | return callback(key);
15 | };
16 | // @ts-expect-error TODO fix the type error
17 | RemoteControlManager.addKeydownListener(remoteControlListener);
18 | // @ts-expect-error TODO fix the type error
19 | return () => RemoteControlManager.removeKeydownListener(remoteControlListener);
20 | }, [key, callback]);
21 | };
22 |
--------------------------------------------------------------------------------
/packages/example/src/modules/header/assets/rabbitLarge0.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bamlab/react-tv-space-navigation/3ae894aa4bb593a25ea2c2b1ae40fe8061b4b4c7/packages/example/src/modules/header/assets/rabbitLarge0.png
--------------------------------------------------------------------------------
/packages/example/src/modules/header/assets/rabbitLarge1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bamlab/react-tv-space-navigation/3ae894aa4bb593a25ea2c2b1ae40fe8061b4b4c7/packages/example/src/modules/header/assets/rabbitLarge1.png
--------------------------------------------------------------------------------
/packages/example/src/modules/header/assets/rabbitLarge2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bamlab/react-tv-space-navigation/3ae894aa4bb593a25ea2c2b1ae40fe8061b4b4c7/packages/example/src/modules/header/assets/rabbitLarge2.png
--------------------------------------------------------------------------------
/packages/example/src/modules/header/assets/rabbitLarge3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bamlab/react-tv-space-navigation/3ae894aa4bb593a25ea2c2b1ae40fe8061b4b4c7/packages/example/src/modules/header/assets/rabbitLarge3.png
--------------------------------------------------------------------------------
/packages/example/src/modules/header/assets/rabbitLarge4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bamlab/react-tv-space-navigation/3ae894aa4bb593a25ea2c2b1ae40fe8061b4b4c7/packages/example/src/modules/header/assets/rabbitLarge4.png
--------------------------------------------------------------------------------
/packages/example/src/modules/header/assets/rabbitLarge5.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bamlab/react-tv-space-navigation/3ae894aa4bb593a25ea2c2b1ae40fe8061b4b4c7/packages/example/src/modules/header/assets/rabbitLarge5.png
--------------------------------------------------------------------------------
/packages/example/src/modules/header/assets/rabbitLarge6.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bamlab/react-tv-space-navigation/3ae894aa4bb593a25ea2c2b1ae40fe8061b4b4c7/packages/example/src/modules/header/assets/rabbitLarge6.png
--------------------------------------------------------------------------------
/packages/example/src/modules/header/assets/rabbitLarge7.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bamlab/react-tv-space-navigation/3ae894aa4bb593a25ea2c2b1ae40fe8061b4b4c7/packages/example/src/modules/header/assets/rabbitLarge7.png
--------------------------------------------------------------------------------
/packages/example/src/modules/header/assets/rabbitLarge8.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bamlab/react-tv-space-navigation/3ae894aa4bb593a25ea2c2b1ae40fe8061b4b4c7/packages/example/src/modules/header/assets/rabbitLarge8.png
--------------------------------------------------------------------------------
/packages/example/src/modules/header/view/Header.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-console */
2 | import { Button } from '../../../design-system/components/Button';
3 | import { Typography } from '../../../design-system/components/Typography';
4 | import { SpatialNavigationNode } from 'react-tv-space-navigation';
5 | import { Spacer } from '../../../design-system/components/Spacer';
6 | import { Image } from 'react-native';
7 | import styled from '@emotion/native';
8 |
9 | const images = {
10 | 0: require('../assets/rabbitLarge0.png'),
11 | 1: require('../assets/rabbitLarge1.png'),
12 | 2: require('../assets/rabbitLarge2.png'),
13 | 3: require('../assets/rabbitLarge3.png'),
14 | 4: require('../assets/rabbitLarge4.png'),
15 | 5: require('../assets/rabbitLarge5.png'),
16 | 6: require('../assets/rabbitLarge6.png'),
17 | 7: require('../assets/rabbitLarge7.png'),
18 | 8: require('../assets/rabbitLarge8.png'),
19 | };
20 |
21 | interface HeaderProps {
22 | title: string;
23 | description: string;
24 | verticalSize: number;
25 | }
26 |
27 | export const Header = ({ title, description, verticalSize }: HeaderProps) => {
28 | // @ts-expect-error TODO fix type error
29 | const imageSource = images[Math.floor(Math.random() * 9)];
30 | return (
31 |
32 |
33 |
34 | {title}
35 |
36 | {description}
37 |
38 | console.log('Randomed!')} />
39 | console.log('Favorited!')} />
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 | );
48 | };
49 |
50 | const InformationContainer = styled.View({
51 | width: '48%',
52 | });
53 |
54 | const ButtonContainer = styled.View(({ theme }) => ({
55 | flexDirection: 'row',
56 | gap: theme.spacings.$6,
57 | }));
58 |
59 | const ImageContainer = styled.View({
60 | width: '48%',
61 | });
62 |
63 | const Descritption = styled(Typography)({
64 | flex: 1,
65 | });
66 |
67 | const Container = styled.View<{ height: number }>(({ height, theme }) => ({
68 | height: height,
69 | flexDirection: 'row',
70 | justifyContent: 'space-between',
71 | padding: theme.spacings.$6,
72 | }));
73 |
74 | const ProgramImage = styled(Image)({
75 | width: '100%',
76 | height: '100%',
77 | objectFit: 'cover',
78 | borderRadius: 20,
79 | });
80 |
--------------------------------------------------------------------------------
/packages/example/src/modules/program/assets/rabbit1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bamlab/react-tv-space-navigation/3ae894aa4bb593a25ea2c2b1ae40fe8061b4b4c7/packages/example/src/modules/program/assets/rabbit1.png
--------------------------------------------------------------------------------
/packages/example/src/modules/program/assets/rabbit10.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bamlab/react-tv-space-navigation/3ae894aa4bb593a25ea2c2b1ae40fe8061b4b4c7/packages/example/src/modules/program/assets/rabbit10.png
--------------------------------------------------------------------------------
/packages/example/src/modules/program/assets/rabbit11.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bamlab/react-tv-space-navigation/3ae894aa4bb593a25ea2c2b1ae40fe8061b4b4c7/packages/example/src/modules/program/assets/rabbit11.png
--------------------------------------------------------------------------------
/packages/example/src/modules/program/assets/rabbit12.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bamlab/react-tv-space-navigation/3ae894aa4bb593a25ea2c2b1ae40fe8061b4b4c7/packages/example/src/modules/program/assets/rabbit12.png
--------------------------------------------------------------------------------
/packages/example/src/modules/program/assets/rabbit13.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bamlab/react-tv-space-navigation/3ae894aa4bb593a25ea2c2b1ae40fe8061b4b4c7/packages/example/src/modules/program/assets/rabbit13.png
--------------------------------------------------------------------------------
/packages/example/src/modules/program/assets/rabbit14.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bamlab/react-tv-space-navigation/3ae894aa4bb593a25ea2c2b1ae40fe8061b4b4c7/packages/example/src/modules/program/assets/rabbit14.png
--------------------------------------------------------------------------------
/packages/example/src/modules/program/assets/rabbit15.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bamlab/react-tv-space-navigation/3ae894aa4bb593a25ea2c2b1ae40fe8061b4b4c7/packages/example/src/modules/program/assets/rabbit15.png
--------------------------------------------------------------------------------
/packages/example/src/modules/program/assets/rabbit16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bamlab/react-tv-space-navigation/3ae894aa4bb593a25ea2c2b1ae40fe8061b4b4c7/packages/example/src/modules/program/assets/rabbit16.png
--------------------------------------------------------------------------------
/packages/example/src/modules/program/assets/rabbit17.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bamlab/react-tv-space-navigation/3ae894aa4bb593a25ea2c2b1ae40fe8061b4b4c7/packages/example/src/modules/program/assets/rabbit17.png
--------------------------------------------------------------------------------
/packages/example/src/modules/program/assets/rabbit18.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bamlab/react-tv-space-navigation/3ae894aa4bb593a25ea2c2b1ae40fe8061b4b4c7/packages/example/src/modules/program/assets/rabbit18.png
--------------------------------------------------------------------------------
/packages/example/src/modules/program/assets/rabbit19.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bamlab/react-tv-space-navigation/3ae894aa4bb593a25ea2c2b1ae40fe8061b4b4c7/packages/example/src/modules/program/assets/rabbit19.png
--------------------------------------------------------------------------------
/packages/example/src/modules/program/assets/rabbit2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bamlab/react-tv-space-navigation/3ae894aa4bb593a25ea2c2b1ae40fe8061b4b4c7/packages/example/src/modules/program/assets/rabbit2.png
--------------------------------------------------------------------------------
/packages/example/src/modules/program/assets/rabbit20.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bamlab/react-tv-space-navigation/3ae894aa4bb593a25ea2c2b1ae40fe8061b4b4c7/packages/example/src/modules/program/assets/rabbit20.png
--------------------------------------------------------------------------------
/packages/example/src/modules/program/assets/rabbit21.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bamlab/react-tv-space-navigation/3ae894aa4bb593a25ea2c2b1ae40fe8061b4b4c7/packages/example/src/modules/program/assets/rabbit21.png
--------------------------------------------------------------------------------
/packages/example/src/modules/program/assets/rabbit22.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bamlab/react-tv-space-navigation/3ae894aa4bb593a25ea2c2b1ae40fe8061b4b4c7/packages/example/src/modules/program/assets/rabbit22.png
--------------------------------------------------------------------------------
/packages/example/src/modules/program/assets/rabbit23.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bamlab/react-tv-space-navigation/3ae894aa4bb593a25ea2c2b1ae40fe8061b4b4c7/packages/example/src/modules/program/assets/rabbit23.png
--------------------------------------------------------------------------------
/packages/example/src/modules/program/assets/rabbit24.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bamlab/react-tv-space-navigation/3ae894aa4bb593a25ea2c2b1ae40fe8061b4b4c7/packages/example/src/modules/program/assets/rabbit24.png
--------------------------------------------------------------------------------
/packages/example/src/modules/program/assets/rabbit25.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bamlab/react-tv-space-navigation/3ae894aa4bb593a25ea2c2b1ae40fe8061b4b4c7/packages/example/src/modules/program/assets/rabbit25.png
--------------------------------------------------------------------------------
/packages/example/src/modules/program/assets/rabbit3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bamlab/react-tv-space-navigation/3ae894aa4bb593a25ea2c2b1ae40fe8061b4b4c7/packages/example/src/modules/program/assets/rabbit3.png
--------------------------------------------------------------------------------
/packages/example/src/modules/program/assets/rabbit4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bamlab/react-tv-space-navigation/3ae894aa4bb593a25ea2c2b1ae40fe8061b4b4c7/packages/example/src/modules/program/assets/rabbit4.png
--------------------------------------------------------------------------------
/packages/example/src/modules/program/assets/rabbit5.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bamlab/react-tv-space-navigation/3ae894aa4bb593a25ea2c2b1ae40fe8061b4b4c7/packages/example/src/modules/program/assets/rabbit5.png
--------------------------------------------------------------------------------
/packages/example/src/modules/program/assets/rabbit6.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bamlab/react-tv-space-navigation/3ae894aa4bb593a25ea2c2b1ae40fe8061b4b4c7/packages/example/src/modules/program/assets/rabbit6.png
--------------------------------------------------------------------------------
/packages/example/src/modules/program/assets/rabbit7.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bamlab/react-tv-space-navigation/3ae894aa4bb593a25ea2c2b1ae40fe8061b4b4c7/packages/example/src/modules/program/assets/rabbit7.png
--------------------------------------------------------------------------------
/packages/example/src/modules/program/assets/rabbit8.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bamlab/react-tv-space-navigation/3ae894aa4bb593a25ea2c2b1ae40fe8061b4b4c7/packages/example/src/modules/program/assets/rabbit8.png
--------------------------------------------------------------------------------
/packages/example/src/modules/program/assets/rabbit9.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bamlab/react-tv-space-navigation/3ae894aa4bb593a25ea2c2b1ae40fe8061b4b4c7/packages/example/src/modules/program/assets/rabbit9.png
--------------------------------------------------------------------------------
/packages/example/src/modules/program/domain/programInfo.ts:
--------------------------------------------------------------------------------
1 | import { ImageSourcePropType } from 'react-native';
2 |
3 | export type ProgramInfo = {
4 | id: string;
5 | title: string;
6 | image: ImageSourcePropType;
7 | description: string;
8 | };
9 |
--------------------------------------------------------------------------------
/packages/example/src/modules/program/view/Program.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/native';
2 | import React from 'react';
3 | import { Animated, Image, View } from 'react-native';
4 | import { ProgramInfo } from '../domain/programInfo';
5 | import { useFocusAnimation } from '../../../design-system/helpers/useFocusAnimation';
6 | import { Typography } from '../../../design-system/components/Typography';
7 |
8 | type ProgramProps = {
9 | isFocused?: boolean;
10 | programInfo: ProgramInfo;
11 | label?: string;
12 | variant?: 'portrait' | 'landscape';
13 | };
14 |
15 | const Label = React.memo(({ label }: { label: string }) => {
16 | return {label};
17 | });
18 | Label.displayName = 'Label';
19 |
20 | export const Program = React.memo(
21 | React.forwardRef(
22 | ({ isFocused = false, programInfo, label, variant = 'portrait' }, ref) => {
23 | const imageSource = programInfo.image;
24 | const scaleAnimation = useFocusAnimation(isFocused);
25 |
26 | return (
27 |
33 |
34 | {label ? (
35 |
36 |
37 |
38 | ) : null}
39 |
40 | );
41 | },
42 | ),
43 | );
44 |
45 | Program.displayName = 'Program';
46 |
47 | const ProgramContainer = styled(Animated.View)<{
48 | isFocused: boolean;
49 | variant: 'portrait' | 'landscape';
50 | }>(({ isFocused, variant, theme }) => ({
51 | height: theme.sizes.program.portrait.height, // Height is the same for both variants
52 | width:
53 | variant === 'landscape'
54 | ? theme.sizes.program.landscape.width
55 | : theme.sizes.program.portrait.width,
56 | overflow: 'hidden',
57 | borderRadius: 20,
58 | borderColor: isFocused ? theme.colors.primary.light : 'transparent',
59 | borderWidth: 3,
60 | cursor: 'pointer',
61 | }));
62 |
63 | const ProgramImage = React.memo(
64 | styled(Image)({
65 | height: '100%',
66 | width: '100%',
67 | }),
68 | );
69 |
70 | const Overlay = React.memo(
71 | styled.View({
72 | position: 'absolute',
73 | bottom: 12,
74 | left: 12,
75 | }),
76 | );
77 |
--------------------------------------------------------------------------------
/packages/example/src/modules/program/view/ProgramList.test.tsx:
--------------------------------------------------------------------------------
1 | import { render } from '@testing-library/react-native';
2 | import { theme } from '../../../design-system/theme/theme';
3 | import { ThemeProvider } from '@emotion/react';
4 | import { SpatialNavigationRoot, DefaultFocus } from 'react-tv-space-navigation';
5 | import testRemoteControlManager from '../../../components/tests/helpers/testRemoteControlManager';
6 | import '../../../components/tests/helpers/configureTestRemoteControl';
7 | import { ProgramList } from './ProgramList';
8 | import * as programInfos from '../infra/programInfos';
9 | import { programsFixture } from '../../../components/tests/fixtures/programInfos';
10 |
11 | jest.mock('@react-navigation/native');
12 |
13 | const renderWithProviders = (component: JSX.Element) => {
14 | return render(
15 |
16 |
17 | {component}
18 |
19 | ,
20 | );
21 | };
22 |
23 | describe('ProgramList', () => {
24 | jest.spyOn(programInfos, 'getPrograms').mockReturnValue(programsFixture);
25 |
26 | it('renders the list with every items', () => {
27 | const screen = renderWithProviders();
28 |
29 | screen.getByLabelText('Program 1');
30 | screen.getByLabelText('Program 2');
31 | });
32 |
33 | it('renders the list and focus elements accordingly with inputs', () => {
34 | const screen = renderWithProviders();
35 |
36 | const program1 = screen.getByLabelText('Program 1');
37 | expect(program1).toBeSelected();
38 |
39 | testRemoteControlManager.handleRight();
40 | const program2 = screen.getByLabelText('Program 2');
41 | expect(program2).toBeSelected();
42 |
43 | testRemoteControlManager.handleLeft();
44 | expect(program1).toBeSelected();
45 | });
46 | });
47 |
--------------------------------------------------------------------------------
/packages/example/src/modules/program/view/ProgramListWithTitle.tsx:
--------------------------------------------------------------------------------
1 | import { MutableRefObject } from 'react';
2 | import { Box } from '../../../design-system/components/Box';
3 | import { Spacer } from '../../../design-system/components/Spacer';
4 | import { Typography } from '../../../design-system/components/Typography';
5 | import { ProgramsRow } from './ProgramList';
6 | import { SpatialNavigationVirtualizedListRef } from '../../../../../lib/src/spatial-navigation/types/SpatialNavigationVirtualizedListRef';
7 |
8 | type Props = {
9 | title: string;
10 | listSize?: number;
11 | parentRef?: MutableRefObject;
12 | };
13 |
14 | export const ProgramListWithTitle = ({ title, parentRef, listSize }: Props) => {
15 | return (
16 |
17 |
18 | {title}
19 |
20 |
21 |
22 |
23 | );
24 | };
25 |
26 | export const ProgramListWithTitleAndVariableSizes = ({ title, listSize }: Props) => {
27 | return (
28 |
29 |
30 | {title}
31 |
32 |
33 |
34 |
35 | );
36 | };
37 |
--------------------------------------------------------------------------------
/packages/example/src/modules/program/view/ProgramNode.tsx:
--------------------------------------------------------------------------------
1 | import { SpatialNavigationFocusableView } from 'react-tv-space-navigation';
2 |
3 | import { ProgramInfo } from '../domain/programInfo';
4 | import { Program } from './Program';
5 | import { forwardRef } from 'react';
6 | import { SpatialNavigationNodeRef } from '../../../../../lib/src/spatial-navigation/types/SpatialNavigationNodeRef';
7 | import { useRotateAnimation } from './useRotateAnimation';
8 | import { Animated } from 'react-native';
9 |
10 | type Props = {
11 | programInfo: ProgramInfo;
12 | onSelect?: () => void;
13 | indexRange?: [number, number];
14 | label?: string;
15 | variant?: 'portrait' | 'landscape';
16 | };
17 |
18 | export const ProgramNode = forwardRef(
19 | ({ programInfo, onSelect, indexRange, label, variant }: Props, ref) => {
20 | const { rotate360, animatedStyle } = useRotateAnimation();
21 |
22 | return (
23 |
30 | {({ isFocused, isRootActive }) => (
31 |
32 |
38 |
39 | )}
40 |
41 | );
42 | },
43 | );
44 | ProgramNode.displayName = 'ProgramNode';
45 |
--------------------------------------------------------------------------------
/packages/example/src/modules/program/view/useRotateAnimation.ts:
--------------------------------------------------------------------------------
1 | import { useRef } from 'react';
2 | import { Animated } from 'react-native';
3 |
4 | export const useRotateAnimation = () => {
5 | const rotationZ = useRef(new Animated.Value(0)).current;
6 |
7 | const rotate360 = () => {
8 | Animated.timing(rotationZ, {
9 | toValue: 360,
10 | duration: 1000, // Adjust duration as needed
11 | useNativeDriver: true,
12 | }).start(() => {
13 | rotationZ.setValue(0); // Reset to 0 after completing a full rotation
14 | });
15 | };
16 |
17 | const animatedStyle = {
18 | transform: [
19 | {
20 | rotateZ: rotationZ.interpolate({
21 | inputRange: [0, 360],
22 | outputRange: ['0deg', '360deg'],
23 | }),
24 | },
25 | ],
26 | };
27 |
28 | return { rotate360, animatedStyle };
29 | };
30 |
--------------------------------------------------------------------------------
/packages/example/src/pages/AsynchronousContent.tsx:
--------------------------------------------------------------------------------
1 | import { SpatialNavigationNode, SpatialNavigationView } from 'react-tv-space-navigation';
2 | import { Page } from '../components/Page';
3 | import { Button } from '../design-system/components/Button';
4 | import { Typography } from '../design-system/components/Typography';
5 | import { Box } from '../design-system/components/Box';
6 | import { Spacer } from '../design-system/components/Spacer';
7 | import { useState } from 'react';
8 | import { ActivityIndicator } from 'react-native';
9 | import styled from '@emotion/native';
10 |
11 | function sleep(ms: number) {
12 | return new Promise((resolve) => setTimeout(resolve, ms));
13 | }
14 |
15 | export const AsynchronousContent = () => {
16 | const [shouldShow, setShouldShow] = useState(false);
17 | const [isLoading, setIsLoading] = useState(false);
18 |
19 | const toggle = async () => {
20 | if (isLoading) return;
21 |
22 | setIsLoading(true);
23 | await sleep(1000);
24 | setIsLoading(false);
25 |
26 | setShouldShow((prev) => !prev);
27 | };
28 |
29 | return (
30 |
31 |
32 |
33 | {
34 | 'Here are some details about a common pitfall with this library: how do I show/hide focusable elements properly?'
35 | }
36 |
37 | {'Use this button to trigger an asynchronous show/hide'}
38 |
39 |
40 |
41 | {isLoading && }
42 |
43 |
44 |
45 | {
46 | 'Then, try out the focus below. Focus should not be messed up after asynchronous modifications :)'
47 | }
48 |
49 |
50 |
51 |
52 | {shouldShow ? : <>>}
53 |
54 |
55 |
56 |
57 | Check out the code to understand how it is done.
58 |
59 |
60 | );
61 | };
62 |
63 | const StyledNavigationRow = styled(SpatialNavigationView)({
64 | gap: 40,
65 | });
66 |
--------------------------------------------------------------------------------
/packages/example/src/pages/Home.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/native';
2 | import { DefaultFocus, SpatialNavigationScrollView } from 'react-tv-space-navigation';
3 | import { Page } from '../components/Page';
4 | import { Box } from '../design-system/components/Box';
5 | import { Spacer } from '../design-system/components/Spacer';
6 | import { Typography } from '../design-system/components/Typography';
7 | import {
8 | ProgramListWithTitle,
9 | ProgramListWithTitleAndVariableSizes,
10 | } from '../modules/program/view/ProgramListWithTitle';
11 | import { BottomArrow, TopArrow } from '../design-system/components/Arrows';
12 | import { StyleSheet } from 'react-native';
13 |
14 | export const Home = () => {
15 | return (
16 |
17 |
18 | Hoppix
19 |
20 |
21 | }
24 | ascendingArrowContainerStyle={styles.bottomArrowContainer}
25 | descendingArrow={}
26 | descendingArrowContainerStyle={styles.topArrowContainer}
27 | >
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
41 |
42 |
46 |
47 |
48 |
49 |
50 |
51 |
52 | );
53 | };
54 |
55 | const TitleContainer = styled.View(({ theme }) => ({ padding: theme.spacings.$4 }));
56 |
57 | const Title = styled(Typography)(({ theme }) => ({
58 | textAlign: 'center',
59 | color: theme.colors.primary.main,
60 | }));
61 |
62 | const styles = StyleSheet.create({
63 | topArrowContainer: {
64 | width: '100%',
65 | height: 100,
66 | position: 'absolute',
67 | alignItems: 'center',
68 | justifyContent: 'center',
69 | top: 20,
70 | left: 0,
71 | },
72 | bottomArrowContainer: {
73 | width: '100%',
74 | height: 100,
75 | position: 'absolute',
76 | alignItems: 'center',
77 | justifyContent: 'center',
78 | bottom: -15,
79 | left: 0,
80 | },
81 | });
82 |
--------------------------------------------------------------------------------
/packages/example/src/pages/ListWithVariableSize.test.tsx:
--------------------------------------------------------------------------------
1 | import { ThemeProvider } from '@emotion/react';
2 | import { render } from '@testing-library/react-native';
3 | import { theme } from '../design-system/theme/theme';
4 | import { ListWithVariableSize } from './ListWithVariableSize';
5 | import { NavigationContainer } from '@react-navigation/native';
6 | import '../components/tests/helpers/configureTestRemoteControl';
7 | import testRemoteControlManager from '../components/tests/helpers/testRemoteControlManager';
8 |
9 | jest.mock('../modules/program/infra/programInfos', () => ({
10 | getPrograms: () => {
11 | return jest.requireActual('../modules/program/infra/programInfos').programInfos;
12 | },
13 | }));
14 |
15 | const renderWithProviders = (page: JSX.Element) => {
16 | return render(
17 |
18 | {page}
19 | ,
20 | );
21 | };
22 | describe('ListWithVariableSize', () => {
23 | it('node is still focusable after being removed', () => {
24 | const screen = renderWithProviders();
25 |
26 | // Go to last programd and focus it
27 | testRemoteControlManager.handleRight();
28 | testRemoteControlManager.handleRight();
29 | testRemoteControlManager.handleRight();
30 | expect(screen.getByLabelText('Program 4')).toBeSelected();
31 |
32 | // Go back to first position
33 | testRemoteControlManager.handleLeft();
34 | testRemoteControlManager.handleLeft();
35 | testRemoteControlManager.handleLeft();
36 |
37 | // Remove last item
38 | testRemoteControlManager.handleDown();
39 | testRemoteControlManager.handleDown();
40 | testRemoteControlManager.handleEnter();
41 |
42 | expect(screen.queryByLabelText('Program 4')).not.toBeOnTheScreen();
43 |
44 | // Add back last item
45 | testRemoteControlManager.handleUp();
46 | testRemoteControlManager.handleEnter();
47 | expect(screen.getByLabelText('Program 4')).toBeOnTheScreen();
48 |
49 | // Focus last item
50 | testRemoteControlManager.handleUp();
51 | testRemoteControlManager.handleRight();
52 | testRemoteControlManager.handleRight();
53 | testRemoteControlManager.handleRight();
54 |
55 | expect(screen.getByLabelText('Program 4')).toBeSelected();
56 | });
57 | });
58 |
--------------------------------------------------------------------------------
/packages/example/src/pages/ListWithVariableSize.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/native';
2 | import { Page } from '../components/Page';
3 | import { getPrograms } from '../modules/program/infra/programInfos';
4 | import { SpatialNavigationView } from '../../../lib/src/spatial-navigation/components/View';
5 | import { scaledPixels } from '../design-system/helpers/scaledPixels';
6 | import { DefaultFocus } from '../../../lib/src/spatial-navigation/context/DefaultFocusContext';
7 | import { SpatialNavigationNode } from '../../../lib/src/spatial-navigation/components/Node';
8 | import { Spacer } from '../design-system/components/Spacer';
9 | import { Button } from '../design-system/components/Button';
10 | import { useState } from 'react';
11 | import { ProgramsRow } from '../modules/program/view/ProgramList';
12 | import { useTheme } from '@emotion/react';
13 |
14 | const ROW_PADDING = scaledPixels(70);
15 |
16 | const MAX = 1000;
17 |
18 | export const ListWithVariableSize = () => {
19 | const theme = useTheme();
20 | const [programsBase, setProgramsBase] = useState(getPrograms(MAX));
21 |
22 | const [numberOfPrograms, setNumberOfPrograms] = useState(4);
23 |
24 | const addItem = () => {
25 | setNumberOfPrograms((prev) => {
26 | if (prev === MAX) return prev;
27 |
28 | return prev + 1;
29 | });
30 | };
31 |
32 | const removeItem = () => {
33 | setNumberOfPrograms((prev) => {
34 | if (prev === 0) return prev;
35 | return prev - 1;
36 | });
37 | };
38 |
39 | const shuffleItems = () => {
40 | setProgramsBase((prev) => [...prev].sort(() => Math.random() - 0.5));
41 | };
42 |
43 | const programs = programsBase.slice(0, numberOfPrograms);
44 |
45 | return (
46 |
47 |
48 |
49 |
50 |
51 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 | );
67 | };
68 |
69 | const Container = styled.View({
70 | flex: 1,
71 | padding: scaledPixels(30),
72 | });
73 |
74 | const ListContainer = styled.View(({ theme }) => ({
75 | flexDirection: 'row',
76 | gap: theme.spacings.$4,
77 | padding: theme.spacings.$4,
78 | }));
79 |
--------------------------------------------------------------------------------
/packages/example/src/pages/NonVirtualizedGridPage.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | DefaultFocus,
3 | SpatialNavigationScrollView,
4 | SpatialNavigationView,
5 | } from 'react-tv-space-navigation';
6 | import { Page } from '../components/Page';
7 | import '../components/configureRemoteControl';
8 | import { getPrograms } from '../modules/program/infra/programInfos';
9 | import { useNavigation } from '@react-navigation/native';
10 | import { NativeStackNavigationProp } from '@react-navigation/native-stack';
11 | import { RootStackParamList } from '../../App';
12 | import styled from '@emotion/native';
13 | import { scaledPixels } from '../design-system/helpers/scaledPixels';
14 | import { ProgramNode } from '../modules/program/view/ProgramNode';
15 | import chunk from 'lodash/chunk';
16 | import { ProgramInfo } from '../modules/program/domain/programInfo';
17 | import { theme } from '../design-system/theme/theme';
18 | import { Header } from '../modules/header/view/Header';
19 | import { BottomArrow, TopArrow } from '../design-system/components/Arrows';
20 | import { StyleSheet } from 'react-native';
21 |
22 | const ROW_SIZE = 7;
23 | const HEADER_SIZE = scaledPixels(400);
24 |
25 | const renderProgramsList = (programsList: ProgramInfo[]) => (
26 |
27 | );
28 |
29 | export const NonVirtualizedGridPage = () => {
30 | const programsLists = chunk(getPrograms(), ROW_SIZE);
31 | return (
32 |
33 |
34 |
35 | }
38 | ascendingArrow={}
39 | descendingArrowContainerStyle={styles.topArrowContainer}
40 | ascendingArrowContainerStyle={styles.bottomArrowContainer}
41 | >
42 |
47 |
48 | {programsLists.map(renderProgramsList)}
49 |
50 |
51 |
52 |
53 |
54 | );
55 | };
56 |
57 | const ProgramRow = ({ programs }: { programs: ProgramInfo[] }) => {
58 | const navigation = useNavigation>();
59 | return (
60 |
61 | {programs.map((program) => {
62 | return (
63 | navigation.push('ProgramDetail', { programInfo: program })}
66 | key={program.id}
67 | />
68 | );
69 | })}
70 |
71 | );
72 | };
73 |
74 | const ListContainer = styled(SpatialNavigationView)(({ theme }) => ({
75 | gap: theme.spacings.$4,
76 | padding: theme.spacings.$4,
77 | }));
78 |
79 | const GridContainer = styled.View({
80 | backgroundColor: theme.colors.background.mainHover,
81 | margin: 'auto',
82 | height: '95%',
83 | width: '88%',
84 | borderRadius: scaledPixels(20),
85 | padding: scaledPixels(30),
86 | });
87 |
88 | const CenteringView = styled.View({
89 | flex: 1,
90 | justifyContent: 'center',
91 | alignItems: 'center',
92 | });
93 |
94 | const styles = StyleSheet.create({
95 | topArrowContainer: {
96 | width: '100%',
97 | height: 100,
98 | position: 'absolute',
99 | alignItems: 'center',
100 | justifyContent: 'center',
101 | top: -15,
102 | left: 0,
103 | },
104 | bottomArrowContainer: {
105 | width: '100%',
106 | height: 100,
107 | position: 'absolute',
108 | alignItems: 'center',
109 | justifyContent: 'center',
110 | bottom: -15,
111 | left: 0,
112 | },
113 | });
114 |
--------------------------------------------------------------------------------
/packages/example/src/pages/ProgramDetail.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/native';
2 | import { RouteProp } from '@react-navigation/native';
3 | import { DefaultFocus } from 'react-tv-space-navigation';
4 | import { RootStackParamList } from '../../App';
5 | import { Page } from '../components/Page';
6 | import { Box } from '../design-system/components/Box';
7 | import { Spacer } from '../design-system/components/Spacer';
8 | import { Typography } from '../design-system/components/Typography';
9 | import { ProgramListWithTitle } from '../modules/program/view/ProgramListWithTitle';
10 | import { Button } from '../design-system/components/Button';
11 | import { useState } from 'react';
12 | import { SubtitlesModal } from '../components/modals/SubtitlesModal';
13 |
14 | export const ProgramDetail = ({
15 | route,
16 | }: {
17 | route: RouteProp;
18 | }) => {
19 | const [isModalVisible, setIsModalVisible] = useState(false);
20 | const [subtitles, setSubtitles] = useState('No');
21 | const { programInfo } = route.params;
22 |
23 | return (
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 | {programInfo.title}
34 |
35 |
36 |
37 | {programInfo.description}
38 |
39 |
40 | {/* eslint-disable-next-line no-console */}
41 | console.log('Playing!')} />
42 |
43 | {/* eslint-disable-next-line no-console */}
44 | console.log('More info!')} />
45 |
46 | setIsModalVisible(true)} />
47 |
48 |
49 |
50 |
51 |
52 |
53 |
58 |
59 | );
60 | };
61 |
62 | const Container = styled(Box)({
63 | height: '60%',
64 | });
65 |
66 | const JumbotronContainer = styled.View({
67 | width: '60%',
68 | height: '100%',
69 | overflow: 'hidden',
70 | borderRadius: 20,
71 | });
72 |
73 | const Jumbotron = styled.Image({
74 | width: '100%',
75 | height: '100%',
76 | resizeMode: 'cover',
77 | });
78 |
79 | const Description = styled(Typography)({
80 | textAlign: 'justify',
81 | });
82 |
--------------------------------------------------------------------------------
/packages/example/src/pages/ProgramGridPage.tsx:
--------------------------------------------------------------------------------
1 | import { StyleSheet, View } from 'react-native';
2 | import { DefaultFocus } from 'react-tv-space-navigation';
3 | import { Page } from '../components/Page';
4 | import { VirtualizedSpatialGrid } from '../components/VirtualizedSpatialGrid';
5 | import { scaledPixels } from '../design-system/helpers/scaledPixels';
6 |
7 | export const ProgramGridPage = () => {
8 | return (
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | );
17 | };
18 |
19 | const styles = StyleSheet.create({
20 | container: { padding: scaledPixels(40), flex: 1 },
21 | });
22 |
--------------------------------------------------------------------------------
/packages/example/src/testing/constants.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * UTC+1 in Winter (the default date is in Winter)
3 | */
4 | export const TEST_DEFAULT_TZ = 'Europe/Paris';
5 |
6 | /**
7 | * 15:23 UTC -> 16:23 In Paris
8 | */
9 | export const TEST_DEFAULT_DATE = '2023-03-05T15:23:49.294Z';
10 |
11 | export const TEST_DEFAULT_MATH_RANDOM = 0.372294134538401;
12 |
13 | export const TEST_TIMEOUT = 10000; // Timeout in milliseconds (e.g., 10000ms = 10 seconds)
14 |
--------------------------------------------------------------------------------
/packages/example/src/testing/jest-setup.ts:
--------------------------------------------------------------------------------
1 | export {};
2 |
--------------------------------------------------------------------------------
/packages/example/src/testing/jest-setupAfterEnv.ts:
--------------------------------------------------------------------------------
1 | import '@testing-library/react-native/extend-expect';
2 |
3 | import { TEST_DEFAULT_DATE, TEST_DEFAULT_MATH_RANDOM } from './constants';
4 |
5 | /**
6 | * Some globals have no reason to not ever be mocked if we want to have reproducible tests.
7 | * Put those things here: Date, Math.random, etc.
8 | *
9 | * You can still customize the mock in an isolated way for a given test or test suite
10 | * BEWARE that your customizations in tests and test suites don't apply to top-level module code (it runs before the `beforeEach`)
11 | */
12 | const setupPermanentMocks = () => {
13 | // Note: Timezone is set in src/testing/jest-globalSetup.ts (it wouldn't work to set it here)
14 | jest.useFakeTimers({
15 | // We're not really interested in stopping the microtasks queue, what we want to mock is "timers"
16 | doNotFake: [
17 | 'setImmediate', // see https://github.com/callstack/react-native-testing-library/issues/1347
18 | 'clearImmediate',
19 | 'nextTick',
20 | 'queueMicrotask',
21 | 'requestIdleCallback',
22 | 'cancelIdleCallback',
23 | 'requestAnimationFrame',
24 | 'cancelAnimationFrame',
25 | 'hrtime',
26 | 'performance',
27 | ],
28 | now: new Date(TEST_DEFAULT_DATE), // To customize in a test, use `jest.setSystemTime`
29 | });
30 |
31 | Math.random = () => TEST_DEFAULT_MATH_RANDOM; // To customize in a given test, use `jest.spyOn(Math, "random").mockReturnValue(xx)`
32 | };
33 |
34 | // Some code runs before `beforeEach` (top-level code from imported modules), so this line is needed
35 | setupPermanentMocks();
36 |
37 | // And then this one is needed to re-set the mocks after the automatic `clearMocks`
38 | beforeEach(() => {
39 | setupPermanentMocks();
40 | });
41 |
--------------------------------------------------------------------------------
/packages/example/src/typings/assets.d.ts:
--------------------------------------------------------------------------------
1 | declare module '*.png' {
2 | const value: import('react-native').ImageSourcePropType;
3 | export default value;
4 | }
5 |
--------------------------------------------------------------------------------
/packages/example/src/utils/repeat.ts:
--------------------------------------------------------------------------------
1 | export const repeat = (callback: () => void, delay: number, repetitions: number) => {
2 | let repeatsLeft = repetitions;
3 |
4 | const interval = setInterval(() => {
5 | if (repeatsLeft === 0) {
6 | clearInterval(interval);
7 | return;
8 | }
9 | callback();
10 | repeatsLeft--;
11 | }, delay);
12 | };
13 |
--------------------------------------------------------------------------------
/packages/example/src/utils/throttle.ts:
--------------------------------------------------------------------------------
1 | export const throttle = (
2 | callback: { (): void; (...arg0: unknown[]): void },
3 | delay: number | undefined,
4 | ) => {
5 | let wait = false;
6 |
7 | return (...args: unknown[]) => {
8 | if (wait) {
9 | return;
10 | }
11 |
12 | callback(...args);
13 | wait = true;
14 | setTimeout(() => {
15 | wait = false;
16 | }, delay);
17 | };
18 | };
19 |
--------------------------------------------------------------------------------
/packages/example/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "paths": {
4 | "react-tv-space-navigation": ["../lib/src/index"]
5 | }
6 | },
7 | "include": [
8 | "../lib/src/**/*",
9 | "../example/src/**/*"
10 | ],
11 | "extends": "../../tsconfig.base"
12 | }
13 |
--------------------------------------------------------------------------------
/packages/lib/.npmignore:
--------------------------------------------------------------------------------
1 | *.test.*
2 | setup-tests.js
3 | coverage
4 | yarn-error.log
5 | *.png
6 | .circleci/config.yml
7 | .eslintcache
8 |
--------------------------------------------------------------------------------
/packages/lib/babel.jest.config.js:
--------------------------------------------------------------------------------
1 | // This is only used by jest
2 | module.exports = {
3 | sourceMaps: 'inline',
4 | presets: [
5 | 'module:metro-react-native-babel-preset',
6 | '@babel/preset-env',
7 | [
8 | '@babel/preset-react',
9 | {
10 | runtime: 'automatic',
11 | },
12 | ],
13 | '@babel/preset-typescript',
14 | ],
15 | plugins: [['@babel/plugin-proposal-class-properties', { loose: false }]],
16 | };
17 |
--------------------------------------------------------------------------------
/packages/lib/jest.config.js:
--------------------------------------------------------------------------------
1 | // @ts-check
2 |
3 | /**
4 | * Code in the react-native ecosystem if often shipped untransformed, with flow or typescript in files
5 | * App code also needs to be transformed (it's TypeScript), but the rest of node_modules doesn't need to.
6 | * Transforming the minimum amount of code makes tests run much faster
7 | *
8 | * If encountering a syntax error during tests with a new package, add it to this list
9 | */
10 |
11 | // eslint-disable-next-line @typescript-eslint/no-var-requires
12 | const path = require('path');
13 |
14 | const packagesToTransform = [
15 | 'react-native',
16 | 'react-native-(.*)',
17 | '@react-native',
18 | '@react-native-community',
19 | '@react-native-tvos',
20 | '@react-navigation',
21 | ];
22 |
23 | /** @type {import('@jest/types').Config.InitialOptions} */
24 | const config = {
25 | preset: '@testing-library/react-native',
26 | /*
27 | * What the preset provides:
28 | * - a transformer to handle media assets (png, video)
29 | */
30 | // test environment setup
31 | setupFiles: ['./src/testing/jest-setup.ts'],
32 | setupFilesAfterEnv: ['./src/testing/jest-setupAfterEnv.ts'],
33 | clearMocks: true,
34 | // module resolution
35 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
36 | testRegex: '\\.test\\.[jt]sx?$',
37 | transform: {
38 | '\\.[jt]sx?$': [
39 | 'babel-jest',
40 | { configFile: path.resolve(__dirname, './babel.jest.config.js') },
41 | ],
42 | },
43 | transformIgnorePatterns: [`node_modules/(?!(${packagesToTransform.join('|')})/)`],
44 | cacheDirectory: '.cache/jest',
45 | // coverage
46 | collectCoverageFrom: ['src/**/*.{js,jsx,ts,tsx}'],
47 | coveragePathIgnorePatterns: ['/node_modules/'],
48 | // tools
49 | watchPlugins: ['jest-watch-typeahead/filename', 'jest-watch-typeahead/testname'],
50 | reporters: ['default', 'github-actions'], // Remove this line if your CI is not on Github actions
51 | snapshotResolver: './jestSnapshotResolver.js',
52 | };
53 |
54 | module.exports = config;
55 |
--------------------------------------------------------------------------------
/packages/lib/jestSnapshotResolver.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | testPathForConsistencyCheck: 'some/__tests__/example.test.js',
3 | /** resolves from test to snapshot path */
4 | resolveSnapshotPath: (testPath, snapshotExtension) => {
5 | return testPath + snapshotExtension;
6 | },
7 | /** resolves from snapshot to test path */
8 | resolveTestPath: (snapshotFilePath, snapshotExtension) => {
9 | return snapshotFilePath.slice(0, -snapshotExtension.length);
10 | },
11 | };
12 |
--------------------------------------------------------------------------------
/packages/lib/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-tv-space-navigation",
3 | "version": "5.1.1",
4 | "main": "dist/index.js",
5 | "repository": {
6 | "type": "git",
7 | "url": "git+https://github.com/bamlab/react-tv-space-navigation.git"
8 | },
9 | "license": "MIT",
10 | "devDependencies": {
11 | "@testing-library/react-hooks": "^8.0.1",
12 | "@testing-library/react-native": "^12.3.1",
13 | "@types/jest": "^29.5.3",
14 | "@types/lodash.uniqueid": "^4.0.8",
15 | "babel-jest": "^29.6.1",
16 | "jest": "^29.6.1",
17 | "jest-environment-jsdom": "^29.6.2",
18 | "jest-watch-typeahead": "^2.2.2",
19 | "typescript": "^5.1.6",
20 | "webpack": "^5.88.1",
21 | "webpack-cli": "^5.1.4",
22 | "webpack-dev-server": "^4.15.1"
23 | },
24 | "scripts": {
25 | "test": "jest",
26 | "test:types": "tsc",
27 | "publish:prepare": "cp ../../README.md . && yarn build",
28 | "publish:package": "yarn publish:prepare && npm publish --access public && rm README.md",
29 | "build": "webpack --config webpack.config.js"
30 | },
31 | "dependencies": {
32 | "@bam.tech/lrud": "^8.0.2",
33 | "lodash.uniqueid": "^4.0.1"
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/packages/lib/src/index.ts:
--------------------------------------------------------------------------------
1 | import { configureRemoteControl } from './spatial-navigation/configureRemoteControl';
2 | export { Directions } from '@bam.tech/lrud';
3 | export { SpatialNavigationNode } from './spatial-navigation/components/Node';
4 | export { SpatialNavigationRoot } from './spatial-navigation/components/Root';
5 | export { SpatialNavigationScrollView } from './spatial-navigation/components/ScrollView/ScrollView';
6 | export { SpatialNavigationView } from './spatial-navigation/components/View';
7 | export { DefaultFocus } from './spatial-navigation/context/DefaultFocusContext';
8 | export { SpatialNavigationVirtualizedList } from './spatial-navigation/components/virtualizedList/SpatialNavigationVirtualizedList';
9 | export { SpatialNavigationVirtualizedGrid } from './spatial-navigation/components/virtualizedGrid/SpatialNavigationVirtualizedGrid';
10 | export { useSpatialNavigatorFocusableAccessibilityProps } from './spatial-navigation/hooks/useSpatialNavigatorFocusableAccessibilityProps';
11 | export { useLockSpatialNavigation } from './spatial-navigation/context/LockSpatialNavigationContext';
12 | export { SpatialNavigationNodeRef } from './spatial-navigation/types/SpatialNavigationNodeRef';
13 | export { SpatialNavigationVirtualizedListRef } from './spatial-navigation/types/SpatialNavigationVirtualizedListRef';
14 | export { SpatialNavigationFocusableView } from './spatial-navigation/components/FocusableView';
15 | export { SpatialNavigationDeviceTypeProvider } from './spatial-navigation/context/DeviceContext';
16 |
17 | export const SpatialNavigation = {
18 | configureRemoteControl,
19 | };
20 |
--------------------------------------------------------------------------------
/packages/lib/src/spatial-navigation/components/FocusableView.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | FocusableNodeState,
3 | SpatialNavigationNode,
4 | SpatialNavigationNodeDefaultProps,
5 | } from './Node';
6 | import { Platform, View, ViewStyle, ViewProps } from 'react-native';
7 | import { forwardRef, useImperativeHandle, useMemo, useRef } from 'react';
8 | import { SpatialNavigationNodeRef } from '../types/SpatialNavigationNodeRef';
9 | import { useSpatialNavigationDeviceType } from '../context/DeviceContext';
10 | import { useSpatialNavigatorFocusableAccessibilityProps } from '../hooks/useSpatialNavigatorFocusableAccessibilityProps';
11 |
12 | type FocusableViewProps = {
13 | style?: ViewStyle;
14 | children: React.ReactElement | ((props: FocusableNodeState) => React.ReactElement);
15 | viewProps?: ViewProps & {
16 | onMouseEnter?: () => void;
17 | };
18 | };
19 |
20 | type Props = SpatialNavigationNodeDefaultProps & FocusableViewProps;
21 |
22 | export const SpatialNavigationFocusableView = forwardRef(
23 | ({ children, style, viewProps, ...props }, ref) => {
24 | const { deviceTypeRef } = useSpatialNavigationDeviceType();
25 | const nodeRef = useRef(null);
26 |
27 | useImperativeHandle(
28 | ref,
29 | () => ({
30 | focus: () => nodeRef.current?.focus(),
31 | }),
32 | [nodeRef],
33 | );
34 |
35 | const webProps = Platform.select({
36 | web: {
37 | onMouseEnter: () => {
38 | if (viewProps?.onMouseEnter) {
39 | viewProps?.onMouseEnter();
40 | }
41 | if (deviceTypeRef.current === 'remotePointer') {
42 | nodeRef.current?.focus();
43 | }
44 | },
45 | onClick: () => {
46 | props.onSelect?.();
47 | },
48 | },
49 | default: {},
50 | });
51 |
52 | return (
53 |
54 | {(nodeState) => (
55 |
61 | {children}
62 |
63 | )}
64 |
65 | );
66 | },
67 | );
68 | SpatialNavigationFocusableView.displayName = 'SpatialNavigationFocusableView';
69 |
70 | type InnerFocusableViewProps = FocusableViewProps & {
71 | webProps:
72 | | {
73 | onMouseEnter: () => void;
74 | onClick: () => void;
75 | }
76 | | {
77 | onMouseEnter?: undefined;
78 | onClick?: undefined;
79 | };
80 | nodeState: FocusableNodeState;
81 | };
82 |
83 | const InnerFocusableView = forwardRef(
84 | ({ viewProps, webProps, children, nodeState, style }, ref) => {
85 | const accessibilityProps = useSpatialNavigatorFocusableAccessibilityProps();
86 | const accessibilityState = useMemo(
87 | () => ({ selected: nodeState.isFocused }),
88 | [nodeState.isFocused],
89 | );
90 |
91 | return (
92 |
100 | {typeof children === 'function' ? children(nodeState) : children}
101 |
102 | );
103 | },
104 | );
105 | InnerFocusableView.displayName = 'InnerFocusableView';
106 |
--------------------------------------------------------------------------------
/packages/lib/src/spatial-navigation/components/Root.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode, useEffect, useRef } from 'react';
2 | import { ParentIdContext } from '../context/ParentIdContext';
3 | import { SpatialNavigatorContext } from '../context/SpatialNavigatorContext';
4 | import { useCreateSpatialNavigator } from '../hooks/useCreateSpatialNavigator';
5 | import { useRemoteControl } from '../hooks/useRemoteControl';
6 | import { OnDirectionHandledWithoutMovement } from '../SpatialNavigator';
7 | import { LockSpatialNavigationContext, useIsLocked } from '../context/LockSpatialNavigationContext';
8 | import { IsRootActiveContext } from '../context/IsRootActiveContext';
9 |
10 | const ROOT_ID = 'root';
11 |
12 | type Props = {
13 | /**
14 | * Determines if the spatial navigation is active.
15 | * If false, the spatial navigation will be locked, and no nodes can be focused.
16 | * This is useful to handle a multi page app: you can disable the non-focused pages' spatial navigation roots.
17 | *
18 | * Note: this is a little redundant with the lock system, but it's useful to have a way to disable the spatial navigation from above AND from below.
19 | */
20 | isActive?: boolean;
21 | /**
22 | * Called when you're reaching a border of the navigator.
23 | * A use case for this would be the implementation of a side menu
24 | * that's shared between pages. You can have a separate navigator
25 | * for your side menu, which would be common across pages, and you'd
26 | * make this menu active when you reach the left side of your page navigator.
27 | */
28 | onDirectionHandledWithoutMovement?: OnDirectionHandledWithoutMovement;
29 | children: ReactNode;
30 | };
31 |
32 | export const SpatialNavigationRoot = ({
33 | isActive = true,
34 | onDirectionHandledWithoutMovement = () => undefined,
35 | children,
36 | }: Props) => {
37 | // We can't follow the react philosophy here: we can't recreate a navigator if this function changes
38 | // so we'll have to store its ref and update the ref if there is a new value to this function
39 | const onDirectionHandledWithoutMovementRef = useRef(
40 | () => undefined,
41 | );
42 | // Update the ref at every render
43 | onDirectionHandledWithoutMovementRef.current = onDirectionHandledWithoutMovement;
44 |
45 | const spatialNavigator = useCreateSpatialNavigator({
46 | onDirectionHandledWithoutMovementRef,
47 | });
48 |
49 | const { isLocked, lockActions } = useIsLocked();
50 |
51 | const isRootActive = isActive && !isLocked;
52 | useRemoteControl({ spatialNavigator, isActive: isRootActive });
53 |
54 | useEffect(() => {
55 | spatialNavigator.registerNode(ROOT_ID, { orientation: 'vertical' });
56 | return () => spatialNavigator.unregisterNode(ROOT_ID);
57 | }, [spatialNavigator]);
58 |
59 | return (
60 |
61 |
62 |
63 | {children}
64 |
65 |
66 |
67 | );
68 | };
69 |
--------------------------------------------------------------------------------
/packages/lib/src/spatial-navigation/components/ScrollView/AnyScrollView.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { ViewStyle, ScrollView } from 'react-native';
3 | import { CustomScrollView } from './CustomScrollView/CustomScrollView';
4 | import { CustomScrollViewRef } from './types';
5 |
6 | type Props = {
7 | useNativeScroll: boolean;
8 |
9 | horizontal?: boolean;
10 | children: React.ReactNode;
11 | style?: ViewStyle;
12 | contentContainerStyle?: ViewStyle;
13 | scrollDuration?: number;
14 | onScroll?: (event: { nativeEvent: { contentOffset: { y: number; x: number } } }) => void;
15 | testID?: string;
16 | };
17 |
18 | export const AnyScrollView = React.forwardRef(
19 | ({ useNativeScroll, ...props }: Props, ref) => {
20 | if (useNativeScroll) {
21 | return (
22 | }
24 | showsHorizontalScrollIndicator={false}
25 | showsVerticalScrollIndicator={false}
26 | scrollEnabled={false}
27 | scrollEventThrottle={16}
28 | {...props}
29 | />
30 | );
31 | }
32 |
33 | return ;
34 | },
35 | );
36 |
37 | AnyScrollView.displayName = 'AnyScrollView';
38 |
--------------------------------------------------------------------------------
/packages/lib/src/spatial-navigation/components/ScrollView/CustomScrollView/CustomScrollView.hooks.ts:
--------------------------------------------------------------------------------
1 | import { useRef, useEffect } from 'react';
2 | import { Animated, Platform } from 'react-native';
3 |
4 | const useStyleNative = (horizontal: boolean, scroll: number, scrollDuration: number) => {
5 | const animation = useRef(new Animated.Value(0)).current;
6 |
7 | useEffect(() => {
8 | Animated.timing(animation, {
9 | toValue: -scroll,
10 | duration: scrollDuration,
11 | useNativeDriver: true,
12 | }).start();
13 | }, [animation, scroll, scrollDuration]);
14 |
15 | return {
16 | transform: [horizontal ? { translateX: animation } : { translateY: animation }],
17 | };
18 | };
19 |
20 | const useStyleWeb = (horizontal: boolean, scroll: number, scrollDuration: number) => {
21 | return [
22 | {
23 | transform: [horizontal ? { translateX: -scroll } : { translateY: -scroll }],
24 | },
25 | {
26 | transitionDuration: `${scrollDuration}ms`,
27 | transitionProperty: 'transform',
28 | transitionTimingFunction: 'ease-out',
29 | transform: [horizontal ? { translateX: -scroll } : { translateY: -scroll }],
30 | },
31 | ];
32 | };
33 |
34 | export const useStyle = (horizontal: boolean, scroll: number, scrollDuration: number) => {
35 | if (Platform.OS === 'web') {
36 | // eslint-disable-next-line react-hooks/rules-of-hooks -- it's okay because Platform.OS is a constant
37 | return useStyleWeb(horizontal, scroll, scrollDuration);
38 | }
39 |
40 | // eslint-disable-next-line react-hooks/rules-of-hooks -- it's okay because Platform.OS is a constant
41 | return useStyleNative(horizontal, scroll, scrollDuration);
42 | };
43 |
--------------------------------------------------------------------------------
/packages/lib/src/spatial-navigation/components/ScrollView/CustomScrollView/CustomScrollView.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react-native/no-inline-styles */
2 | import { Animated, LayoutChangeEvent, View, ViewStyle } from 'react-native';
3 | import { forwardRef, useCallback, useRef, useState } from 'react';
4 | import { CustomScrollViewRef } from '../types';
5 | import { useStyle } from './CustomScrollView.hooks';
6 |
7 | type Props = {
8 | horizontal?: boolean;
9 | children: React.ReactNode;
10 | style?: ViewStyle;
11 | contentContainerStyle?: ViewStyle;
12 | scrollDuration?: number;
13 | onScroll?: (event: { nativeEvent: { contentOffset: { y: number; x: number } } }) => void;
14 | testID?: string;
15 | };
16 |
17 | export const CustomScrollView = forwardRef(
18 | (
19 | {
20 | style,
21 | contentContainerStyle,
22 | children,
23 | onScroll,
24 | horizontal = false,
25 | scrollDuration = 200,
26 | testID,
27 | },
28 | ref,
29 | ) => {
30 | const [scroll, setScroll] = useState(0);
31 | const contentSize = useRef(0);
32 | const parentSize = useRef(0);
33 |
34 | const animationStyle = useStyle(horizontal, scroll, scrollDuration);
35 |
36 | const onContentContainerLayout = useCallback(
37 | (event: LayoutChangeEvent) => {
38 | contentSize.current = event.nativeEvent.layout[horizontal ? 'width' : 'height'];
39 | },
40 | [horizontal],
41 | );
42 |
43 | const onParentLayout = useCallback(
44 | (event: LayoutChangeEvent): void => {
45 | parentSize.current = event.nativeEvent.layout[horizontal ? 'width' : 'height'];
46 | },
47 | [horizontal],
48 | );
49 |
50 | const updateRef = (currentRef: View | null) => {
51 | if (!currentRef) return;
52 |
53 | // eslint-disable-next-line @typescript-eslint/no-explicit-any -- couldn't find another way than a mutation... copying the ref makes it not work with measureLayout anymore
54 | const newRef = currentRef as any as CustomScrollViewRef;
55 | newRef.getInnerViewNode = () => currentRef;
56 | newRef.scrollTo = ({ x, y }) => {
57 | let scrollValue = 0;
58 | if (parentSize.current < contentSize.current) {
59 | if (x !== undefined) {
60 | scrollValue = Math.min(Math.max(0, x), contentSize.current);
61 | } else if (y !== undefined) {
62 | scrollValue = Math.min(Math.max(0, y), contentSize.current);
63 | }
64 | // Prevent from scrolling too far when reaching the end
65 | scrollValue = Math.min(scrollValue, contentSize.current - parentSize.current);
66 | }
67 | setScroll(scrollValue);
68 | const event = { nativeEvent: { contentOffset: { y: scrollValue, x: scrollValue } } };
69 | onScroll?.(event);
70 | };
71 |
72 | if (typeof ref === 'function') ref?.(newRef);
73 | else if (ref) ref.current = newRef;
74 | };
75 |
76 | return (
77 |
85 |
91 | {children}
92 |
93 |
94 | );
95 | },
96 | );
97 | CustomScrollView.displayName = 'CustomScrollView';
98 |
--------------------------------------------------------------------------------
/packages/lib/src/spatial-navigation/components/ScrollView/pointer/PointerScrollArrows.tsx:
--------------------------------------------------------------------------------
1 | import React, { ReactElement, ReactNode } from 'react';
2 | import { View, ViewStyle, StyleSheet } from 'react-native';
3 |
4 | export const PointerScrollArrows = React.memo(
5 | ({
6 | ascendingArrow,
7 | descendingArrowProps,
8 | ascendingArrowContainerStyle,
9 | descendingArrow,
10 | ascendingArrowProps,
11 | descendingArrowContainerStyle,
12 | }: {
13 | ascendingArrow?: ReactElement;
14 | ascendingArrowProps?: {
15 | onMouseEnter: () => void;
16 | onMouseLeave: () => void;
17 | };
18 | ascendingArrowContainerStyle?: ViewStyle;
19 | descendingArrow?: ReactNode;
20 | descendingArrowProps?: {
21 | onMouseEnter: () => void;
22 | onMouseLeave: () => void;
23 | };
24 | descendingArrowContainerStyle?: ViewStyle;
25 | }) => {
26 | return (
27 | <>
28 |
32 | {descendingArrow}
33 |
34 |
35 | {ascendingArrow}
36 |
37 | >
38 | );
39 | },
40 | );
41 | PointerScrollArrows.displayName = 'PointerScrollArrows';
42 |
43 | const styles = StyleSheet.create({
44 | arrowContainer: {
45 | position: 'absolute',
46 | },
47 | });
48 |
--------------------------------------------------------------------------------
/packages/lib/src/spatial-navigation/components/ScrollView/pointer/useRemotePointerScrollviewScrollProps.ts:
--------------------------------------------------------------------------------
1 | import React, { useCallback, useMemo } from 'react';
2 | import { Platform } from 'react-native';
3 | import { useSpatialNavigationDeviceType } from '../../../context/DeviceContext';
4 | import { CustomScrollViewRef } from '../types';
5 |
6 | export const useRemotePointerScrollviewScrollProps = ({
7 | pointerScrollSpeed,
8 | scrollY,
9 | scrollViewRef,
10 | }: {
11 | pointerScrollSpeed: number;
12 | scrollY: React.MutableRefObject;
13 | scrollViewRef: React.MutableRefObject;
14 | }) => {
15 | const {
16 | deviceType,
17 | deviceTypeRef,
18 | getScrollingIntervalId: getScrollingId,
19 | setScrollingIntervalId: setScrollingId,
20 | } = useSpatialNavigationDeviceType();
21 |
22 | const onMouseEnterTop = useCallback(() => {
23 | if (deviceTypeRef.current === 'remotePointer') {
24 | let currentScrollPosition = scrollY.current;
25 | const id = setInterval(() => {
26 | currentScrollPosition -= pointerScrollSpeed;
27 | scrollViewRef.current?.scrollTo({
28 | y: currentScrollPosition,
29 | animated: false,
30 | });
31 | }, 10);
32 | setScrollingId(id);
33 | }
34 | }, [deviceTypeRef, pointerScrollSpeed, scrollY, scrollViewRef, setScrollingId]);
35 |
36 | const onMouseEnterBottom = useCallback(() => {
37 | if (deviceTypeRef.current === 'remotePointer') {
38 | let currentScrollPosition = scrollY.current;
39 | const id = setInterval(() => {
40 | currentScrollPosition += pointerScrollSpeed;
41 | scrollViewRef.current?.scrollTo({
42 | y: currentScrollPosition,
43 | animated: false,
44 | });
45 | }, 10);
46 | setScrollingId(id);
47 | }
48 | }, [deviceTypeRef, pointerScrollSpeed, scrollY, scrollViewRef, setScrollingId]);
49 |
50 | const onMouseLeave = useCallback(() => {
51 | if (deviceTypeRef.current === 'remotePointer') {
52 | const intervalId = getScrollingId();
53 | if (intervalId) {
54 | clearInterval(intervalId);
55 | setScrollingId(null);
56 | }
57 | }
58 | }, [deviceTypeRef, getScrollingId, setScrollingId]);
59 |
60 | const ascendingArrowProps = useMemo(
61 | () =>
62 | Platform.select({
63 | web: { onMouseEnter: onMouseEnterBottom, onMouseLeave: onMouseLeave },
64 | }),
65 | [onMouseEnterBottom, onMouseLeave],
66 | );
67 |
68 | const descendingArrowProps = useMemo(
69 | () =>
70 | Platform.select({
71 | web: { onMouseEnter: onMouseEnterTop, onMouseLeave: onMouseLeave },
72 | }),
73 | [onMouseEnterTop, onMouseLeave],
74 | );
75 |
76 | return {
77 | deviceType,
78 | deviceTypeRef,
79 | ascendingArrowProps,
80 | descendingArrowProps,
81 | };
82 | };
83 |
--------------------------------------------------------------------------------
/packages/lib/src/spatial-navigation/components/ScrollView/types.ts:
--------------------------------------------------------------------------------
1 | export type CustomScrollViewRef = {
2 | // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Already undocumented in RN https://github.com/facebook/react-native/blob/1c1c8335db2494672cf955cf4db574e23fd2198a/packages/react-native/Libraries/Components/ScrollView/ScrollView.d.ts#L861
3 | getInnerViewNode: () => any;
4 | scrollTo: (args: { x?: number; y?: number; animated: boolean }) => void;
5 | };
6 |
--------------------------------------------------------------------------------
/packages/lib/src/spatial-navigation/components/View.tsx:
--------------------------------------------------------------------------------
1 | import { StyleSheet, View, ViewStyle } from 'react-native';
2 | import { SpatialNavigationNode } from './Node';
3 | import { forwardRef } from 'react';
4 | import { SpatialNavigationNodeRef } from '../types/SpatialNavigationNodeRef';
5 |
6 | type Props = {
7 | children: React.ReactNode;
8 | style?: ViewStyle;
9 | direction: 'horizontal' | 'vertical';
10 | alignInGrid?: boolean;
11 | };
12 |
13 | export const SpatialNavigationView = forwardRef(
14 | ({ direction = 'horizontal', alignInGrid = false, children, style }: Props, ref) => {
15 | return (
16 |
17 |
20 | {children}
21 |
22 |
23 | );
24 | },
25 | );
26 | SpatialNavigationView.displayName = 'SpatialNavigationView';
27 |
28 | const styles = StyleSheet.create({
29 | viewVertical: {
30 | display: 'flex',
31 | flexDirection: 'column',
32 | },
33 | viewHorizontal: {
34 | display: 'flex',
35 | flexDirection: 'row',
36 | },
37 | });
38 |
--------------------------------------------------------------------------------
/packages/lib/src/spatial-navigation/components/tests/TestButton.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/native';
2 | import { Text } from 'react-native';
3 | import { SpatialNavigationNode } from '../Node';
4 |
5 | export type PropsTestButton = {
6 | onSelect?: () => void;
7 | title: string;
8 | };
9 |
10 | export const TestButton = ({ onSelect, title }: PropsTestButton) => {
11 | return (
12 |
13 | {({ isFocused }) => (
14 |
22 | {title}
23 |
24 | )}
25 |
26 | );
27 | };
28 |
29 | const TextContainer = styled.View<{ isFocused: boolean }>(({ isFocused }) => ({
30 | borderRadius: 100,
31 | padding: 6,
32 | backgroundColor: isFocused ? 'red' : 'transparent',
33 | }));
34 |
--------------------------------------------------------------------------------
/packages/lib/src/spatial-navigation/components/tests/helpers/configureTestRemoteControl.ts:
--------------------------------------------------------------------------------
1 | import { Directions, SpatialNavigation } from '../../../..';
2 | import TestRemoteControlManager, { SupportedKeys } from './testRemoteControlManager';
3 |
4 | SpatialNavigation.configureRemoteControl({
5 | remoteControlSubscriber: (callback) => {
6 | const mapping: { [key in SupportedKeys]: Directions | null } = {
7 | [SupportedKeys.Right]: Directions.RIGHT,
8 | [SupportedKeys.Left]: Directions.LEFT,
9 | [SupportedKeys.Up]: Directions.UP,
10 | [SupportedKeys.Down]: Directions.DOWN,
11 | [SupportedKeys.Enter]: Directions.ENTER,
12 | [SupportedKeys.Back]: null,
13 | };
14 |
15 | const remoteControlListener = (keyEvent: SupportedKeys) => {
16 | callback(mapping[keyEvent]);
17 | };
18 |
19 | return TestRemoteControlManager.addKeydownListener(remoteControlListener);
20 | },
21 |
22 | remoteControlUnsubscriber: (remoteControlListener) => {
23 | TestRemoteControlManager.removeKeydownListener(remoteControlListener);
24 | },
25 | });
26 |
--------------------------------------------------------------------------------
/packages/lib/src/spatial-navigation/components/tests/helpers/testRemoteControlManager.ts:
--------------------------------------------------------------------------------
1 | import { act } from '@testing-library/react-native';
2 | import mitt from 'mitt';
3 |
4 | export enum SupportedKeys {
5 | Up = 'Up',
6 | Down = 'Down',
7 | Left = 'Left',
8 | Right = 'Right',
9 | Enter = 'Enter',
10 | Back = 'Back',
11 | }
12 |
13 | class TestRemoteControlManager {
14 | private eventEmitter = mitt<{ keyDown: SupportedKeys }>();
15 |
16 | public handleUp = () => {
17 | act(() => {
18 | this.eventEmitter.emit('keyDown', SupportedKeys.Up);
19 | jest.runAllTimers();
20 | });
21 | act(() => jest.runAllTimers());
22 | };
23 |
24 | public handleDown = () => {
25 | act(() => {
26 | this.eventEmitter.emit('keyDown', SupportedKeys.Down);
27 | });
28 | act(() => jest.runAllTimers());
29 | };
30 |
31 | public handleLeft = () => {
32 | act(() => {
33 | this.eventEmitter.emit('keyDown', SupportedKeys.Left);
34 | });
35 | act(() => jest.runAllTimers());
36 | };
37 |
38 | public handleRight = () => {
39 | act(() => {
40 | this.eventEmitter.emit('keyDown', SupportedKeys.Right);
41 | });
42 | act(() => jest.runAllTimers());
43 | };
44 |
45 | public handleEnter = () => {
46 | act(() => {
47 | this.eventEmitter.emit('keyDown', SupportedKeys.Enter);
48 | });
49 | act(() => jest.runAllTimers());
50 | };
51 |
52 | public handleBackSpace = () => {
53 | act(() => {
54 | this.eventEmitter.emit('keyDown', SupportedKeys.Back);
55 | });
56 | act(() => jest.runAllTimers());
57 | };
58 |
59 | addKeydownListener = (listener: (event: SupportedKeys) => void) => {
60 | this.eventEmitter.on('keyDown', listener);
61 | return listener;
62 | };
63 |
64 | removeKeydownListener = (listener: (event: SupportedKeys) => void) => {
65 | this.eventEmitter.off('keyDown', listener);
66 | };
67 | }
68 |
69 | export default new TestRemoteControlManager();
70 |
--------------------------------------------------------------------------------
/packages/lib/src/spatial-navigation/components/virtualizedGrid/helpers/convertToGrid.ts:
--------------------------------------------------------------------------------
1 | import chunk from 'lodash/chunk';
2 | import { GridRowType } from '../SpatialNavigationVirtualizedGrid';
3 |
4 | import { NodeOrientation } from '../../../types/orientation';
5 |
6 | export const convertToGrid = (
7 | data: T[],
8 | numberOfColumns: number,
9 | header?: JSX.Element,
10 | ): GridRowType[] => {
11 | const rows: T[][] = chunk(data, numberOfColumns);
12 |
13 | return rows.map((items, index) => {
14 | //We do this to have index taking into account the header
15 | const computedIndex = header ? index + 1 : index;
16 | return { items, index: computedIndex };
17 | });
18 | };
19 |
20 | export const invertOrientation = (orientation: NodeOrientation): NodeOrientation =>
21 | orientation === 'vertical' ? 'horizontal' : 'vertical';
22 |
--------------------------------------------------------------------------------
/packages/lib/src/spatial-navigation/components/virtualizedList/SpatialNavigationVirtualizedList.tsx:
--------------------------------------------------------------------------------
1 | import { ForwardedRef } from 'react';
2 | import { SpatialNavigationNode } from '../Node';
3 | import {
4 | PointerScrollProps,
5 | SpatialNavigationVirtualizedListWithScroll,
6 | SpatialNavigationVirtualizedListWithScrollProps,
7 | } from './SpatialNavigationVirtualizedListWithScroll';
8 | import { typedMemo } from '../../helpers/TypedMemo';
9 |
10 | import { typedForwardRef } from '../../helpers/TypedForwardRef';
11 | import { SpatialNavigationVirtualizedListRef } from '../../types/SpatialNavigationVirtualizedListRef';
12 |
13 | /**
14 | * Use this component to render horizontally or vertically virtualized lists with spatial navigation
15 | * This component wraps the virtualized list inside a parent navigation node.
16 | * */
17 | export const SpatialNavigationVirtualizedList = typedMemo(
18 | typedForwardRef(
19 | (
20 | props: SpatialNavigationVirtualizedListWithScrollProps & PointerScrollProps,
21 | ref: ForwardedRef,
22 | ) => {
23 | return (
24 |
28 | {...props} ref={ref} />
29 |
30 | );
31 | },
32 | ),
33 | );
34 | SpatialNavigationVirtualizedList.displayName = 'SpatialNavigationVirtualizedList';
35 |
--------------------------------------------------------------------------------
/packages/lib/src/spatial-navigation/components/virtualizedList/VirtualizedListWithSize.tsx:
--------------------------------------------------------------------------------
1 | import { StyleSheet, View, Dimensions } from 'react-native';
2 | import { typedMemo } from '../../helpers/TypedMemo';
3 | import { VirtualizedList, VirtualizedListProps } from './VirtualizedList';
4 | import { useState } from 'react';
5 |
6 | /**
7 | * This component has for only purpose to give to the VirtualizedList its actual
8 | * width and height. It is used to avoid the VirtualizedList to render with a width
9 | * or height not defined (as it is used later for computing offsets for example).
10 | * The size is computed only once and then the VirtualizedList is rendered. This
11 | * doesn't support dynamic size changes.
12 | */
13 | export const VirtualizedListWithSize = typedMemo(
14 | (props: Omit, 'listSizeInPx'>) => {
15 | const isVertical = props.orientation === 'vertical';
16 | const [listSizeInPx, setListSizeInPx] = useState(
17 | isVertical ? Dimensions.get('window').height : Dimensions.get('window').width,
18 | );
19 | const [hasAlreadyRendered, setHasAlreadyRendered] = useState(false);
20 |
21 | return (
22 | {
25 | if (!hasAlreadyRendered) {
26 | const sizeKey = isVertical ? 'height' : 'width';
27 | if (event.nativeEvent.layout[sizeKey] !== 0) {
28 | setListSizeInPx(event.nativeEvent.layout[sizeKey]);
29 | setHasAlreadyRendered(true);
30 | }
31 | }
32 | }}
33 | testID={props.testID ? props.testID + '-size-giver' : undefined}
34 | >
35 |
36 |
37 | );
38 | },
39 | );
40 | VirtualizedListWithSize.displayName = 'VirtualizedListWithSize';
41 |
42 | const style = StyleSheet.create({
43 | container: {
44 | width: '100%',
45 | height: '100%',
46 | },
47 | });
48 |
--------------------------------------------------------------------------------
/packages/lib/src/spatial-navigation/components/virtualizedList/helpers/computeTranslation.ts:
--------------------------------------------------------------------------------
1 | import { ScrollBehavior } from '../VirtualizedList';
2 | import { getSizeInPxFromOneItemToAnother } from './getSizeInPxFromOneItemToAnother';
3 |
4 | const computeStickToStartTranslation = ({
5 | currentlyFocusedItemIndex,
6 | itemSizeInPx,
7 | data,
8 | maxPossibleLeftAlignedIndex,
9 | }: {
10 | currentlyFocusedItemIndex: number;
11 | itemSizeInPx: number | ((item: T) => number);
12 | data: T[];
13 | maxPossibleLeftAlignedIndex: number;
14 | }) => {
15 | const scrollOffset =
16 | currentlyFocusedItemIndex < maxPossibleLeftAlignedIndex
17 | ? getSizeInPxFromOneItemToAnother(data, itemSizeInPx, 0, currentlyFocusedItemIndex)
18 | : getSizeInPxFromOneItemToAnother(data, itemSizeInPx, 0, maxPossibleLeftAlignedIndex);
19 | return -scrollOffset;
20 | };
21 |
22 | const computeStickToEndTranslation = ({
23 | currentlyFocusedItemIndex,
24 | itemSizeInPx,
25 | data,
26 | listSizeInPx,
27 | maxPossibleRightAlignedIndex,
28 | }: {
29 | currentlyFocusedItemIndex: number;
30 | itemSizeInPx: number | ((item: T) => number);
31 | data: T[];
32 | listSizeInPx: number;
33 | maxPossibleRightAlignedIndex: number;
34 | }) => {
35 | if (currentlyFocusedItemIndex <= maxPossibleRightAlignedIndex) return -0;
36 |
37 | const currentlyFocusedItemSize =
38 | typeof itemSizeInPx === 'function'
39 | ? itemSizeInPx(data[currentlyFocusedItemIndex])
40 | : itemSizeInPx;
41 |
42 | const sizeOfListFromStartToCurrentlyFocusedItem = getSizeInPxFromOneItemToAnother(
43 | data,
44 | itemSizeInPx,
45 | 0,
46 | currentlyFocusedItemIndex,
47 | );
48 |
49 | const scrollOffset =
50 | sizeOfListFromStartToCurrentlyFocusedItem + currentlyFocusedItemSize - listSizeInPx;
51 | return -scrollOffset;
52 | };
53 |
54 | const computeJumpOnScrollTranslation = ({
55 | currentlyFocusedItemIndex,
56 | itemSizeInPx,
57 | nbMaxOfItems,
58 | numberOfItemsVisibleOnScreen,
59 | }: {
60 | currentlyFocusedItemIndex: number;
61 | itemSizeInPx: number | ((item: T) => number);
62 | nbMaxOfItems: number;
63 | numberOfItemsVisibleOnScreen: number;
64 | }) => {
65 | if (typeof itemSizeInPx === 'function')
66 | throw new Error('jump-on-scroll scroll behavior is not supported with dynamic item size');
67 |
68 | const maxPossibleLeftAlignedIndex = Math.max(nbMaxOfItems - numberOfItemsVisibleOnScreen, 0);
69 | const indexOfItemToFocus =
70 | currentlyFocusedItemIndex - (currentlyFocusedItemIndex % numberOfItemsVisibleOnScreen);
71 | const leftAlignedIndex = Math.min(indexOfItemToFocus, maxPossibleLeftAlignedIndex);
72 | const scrollOffset = leftAlignedIndex * itemSizeInPx;
73 | return -scrollOffset;
74 | };
75 |
76 | export const computeTranslation = ({
77 | currentlyFocusedItemIndex,
78 | itemSizeInPx,
79 | nbMaxOfItems,
80 | numberOfItemsVisibleOnScreen,
81 | scrollBehavior,
82 | data,
83 | listSizeInPx,
84 | maxPossibleLeftAlignedIndex,
85 | maxPossibleRightAlignedIndex,
86 | }: {
87 | currentlyFocusedItemIndex: number;
88 | itemSizeInPx: number | ((item: T) => number);
89 | nbMaxOfItems: number;
90 | numberOfItemsVisibleOnScreen: number;
91 | scrollBehavior: ScrollBehavior;
92 | data: T[];
93 | listSizeInPx: number;
94 | maxPossibleLeftAlignedIndex: number;
95 | maxPossibleRightAlignedIndex: number;
96 | }) => {
97 | switch (scrollBehavior) {
98 | case 'stick-to-start':
99 | return computeStickToStartTranslation({
100 | currentlyFocusedItemIndex,
101 | itemSizeInPx,
102 | data,
103 | maxPossibleLeftAlignedIndex,
104 | });
105 | case 'stick-to-end':
106 | return computeStickToEndTranslation({
107 | currentlyFocusedItemIndex,
108 | itemSizeInPx,
109 | data,
110 | listSizeInPx,
111 | maxPossibleRightAlignedIndex,
112 | });
113 | case 'jump-on-scroll':
114 | return computeJumpOnScrollTranslation({
115 | currentlyFocusedItemIndex,
116 | itemSizeInPx,
117 | nbMaxOfItems,
118 | numberOfItemsVisibleOnScreen,
119 | });
120 | default:
121 | throw new Error(`Invalid scroll behavior: ${scrollBehavior}`);
122 | }
123 | };
124 |
--------------------------------------------------------------------------------
/packages/lib/src/spatial-navigation/components/virtualizedList/helpers/createScrollOffsetArray.ts:
--------------------------------------------------------------------------------
1 | import { ScrollBehavior } from '../VirtualizedList';
2 | import { computeTranslation } from './computeTranslation';
3 | import { getLastLeftItemIndex, getLastRightItemIndex } from './getLastItemIndex';
4 |
5 | /**
6 | * This function precomputes all scroll offsets
7 | * It won't move until data moves or the itemSize changes
8 | */
9 | export const computeAllScrollOffsets = ({
10 | itemSize,
11 | nbMaxOfItems,
12 | numberOfItemsVisibleOnScreen,
13 | scrollBehavior,
14 | data,
15 | listSizeInPx,
16 | }: {
17 | itemSize: number | ((item: T) => number);
18 | nbMaxOfItems: number;
19 | numberOfItemsVisibleOnScreen: number;
20 | scrollBehavior: ScrollBehavior;
21 | data: T[];
22 | listSizeInPx: number;
23 | }) => {
24 | const maxPossibleLeftAlignedIndex = getLastLeftItemIndex(data, itemSize, listSizeInPx);
25 | const maxPossibleRightAlignedIndex = getLastRightItemIndex(data, itemSize, listSizeInPx);
26 |
27 | const scrollOffsets = data.map((_, index) =>
28 | computeTranslation({
29 | currentlyFocusedItemIndex: index,
30 | itemSizeInPx: itemSize,
31 | nbMaxOfItems: nbMaxOfItems ?? data.length,
32 | numberOfItemsVisibleOnScreen: numberOfItemsVisibleOnScreen,
33 | scrollBehavior: scrollBehavior,
34 | data: data,
35 | listSizeInPx: listSizeInPx,
36 | maxPossibleLeftAlignedIndex: maxPossibleLeftAlignedIndex,
37 | maxPossibleRightAlignedIndex: maxPossibleRightAlignedIndex,
38 | }),
39 | );
40 |
41 | return scrollOffsets;
42 | };
43 |
--------------------------------------------------------------------------------
/packages/lib/src/spatial-navigation/components/virtualizedList/helpers/getAdditionalNumberOfItemsRendered.ts:
--------------------------------------------------------------------------------
1 | import { ScrollBehavior } from '../VirtualizedList';
2 |
3 | /**
4 | * If list rendered elements is too small, it creates spatial navigation bugs
5 | * like not being able to go back on the left
6 | *
7 | * There are other ways to fix this than forcing a minimum number of additional items to render
8 | * but having a minimum number items to render inferior to the window size makes no sense anyway
9 | */
10 | const MINIMUM_ADDITIONAL_ITEMS_TO_WORK = 2;
11 |
12 | export const getAdditionalNumberOfItemsRendered = (
13 | scrollBehavior: ScrollBehavior,
14 | numberOfElementsVisibleOnScreen: number,
15 | additionalNumberOfItemsRendered: number,
16 | ) => {
17 | if (additionalNumberOfItemsRendered < 0) {
18 | console.error(
19 | '[VirtualizedList] Negative number of additional items to render was given, no elements will be rendered',
20 | );
21 | }
22 |
23 | switch (scrollBehavior) {
24 | case 'jump-on-scroll':
25 | // This is a special case
26 | // Since we're jumping on scroll, we need to render more items to make sure that a whole
27 | // window is ready when we jump!
28 | return 2 * numberOfElementsVisibleOnScreen + 1 + additionalNumberOfItemsRendered;
29 | default:
30 | return (
31 | numberOfElementsVisibleOnScreen +
32 | MINIMUM_ADDITIONAL_ITEMS_TO_WORK +
33 | additionalNumberOfItemsRendered
34 | );
35 | }
36 | };
37 |
--------------------------------------------------------------------------------
/packages/lib/src/spatial-navigation/components/virtualizedList/helpers/getLastItemIndex.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * This function is used to compute the index of the last item that allows the end of the list to fully fit in the screen.
3 | * It is used when scrolling on stick-to-start mode.
4 | *
5 | * ```
6 | * ┌───────────────────────────────────────┐
7 | * │ Screen │
8 | * │ │
9 | * │ │
10 | * ┌───┼─┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │
11 | * │ 3│ │ │ 4 │ │ 5 │ │ 6 │ │ 7 │ │
12 | * │┌──┼┐│ │┌───┐│ │┌───┐│ │┌───┐│ │┌───┐│ │
13 | * ││ C│││ ││ D ││ ││ E ││ ││ F ││ ││ G ││ │
14 | * │└──┼┘│ │└───┘│ │└───┘│ │└───┘│ │└───┘│ │
15 | * └───┼─┘ └─────┘ └─────┘ └─────┘ └─────┘ │
16 | * │ │
17 | * └───────────────────────────────────────┘
18 | * ```
19 | *
20 | * In this case the last item that allows the end of the list to fully fit in the screen is item 4, so the
21 | * scroll in stick-to-start mode will be stopped after item 4, to keep
22 | * item 4 to 7 in the screen.
23 | *
24 | */
25 | export const getLastLeftItemIndex = (
26 | data: T[],
27 | itemSizeInPx: number | ((item: T) => number),
28 | listSizeInPx: number,
29 | ): number => {
30 | if (typeof itemSizeInPx === 'function') {
31 | let totalSize = 0;
32 |
33 | for (let index = data.length - 1; index >= 0; index--) {
34 | totalSize += itemSizeInPx(data[index]);
35 |
36 | if (totalSize >= listSizeInPx) {
37 | // If we exceed the list size, we return the index of the previous item (list is iterated backwards, so index + 1)
38 | return index + 1;
39 | }
40 | }
41 |
42 | return 0;
43 | }
44 |
45 | const result = data.length - Math.floor(listSizeInPx / itemSizeInPx);
46 |
47 | if (result < 0) {
48 | return 0;
49 | }
50 | return result;
51 | };
52 |
53 | /**
54 | *
55 | * This function is used to compute the index of the last item that fits in the screen when at the beginning of a list.
56 | * It is used when scrolling on stick-to-end mode to know when to start or stop scrolling
57 | *
58 | * ```
59 | * ┌───────────────────────────────────────┐
60 | * │ Screen │
61 | * │ │
62 | * │ │
63 | * │ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─┼───┐
64 | * │ │ 1 │ │ 2 │ │ 3 │ │ 4 │ │ │5 │
65 | * │ │┌───┐│ │┌───┐│ │┌───┐│ │┌───┐│ │┌┼──┐│
66 | * │ ││ A ││ ││ B ││ ││ C ││ ││ D ││ │││E ││
67 | * │ │└───┘│ │└───┘│ │└───┘│ │└───┘│ │└┼──┘│
68 | * │ └─────┘ └─────┘ └─────┘ └─────┘ └─┼───┘
69 | * │ │
70 | * └───────────────────────────────────────┘
71 | * ```
72 | *
73 | * In this case the last item that fits in the screen is item 4, so the
74 | * scroll in stick-to-end mode will be computed after item 4, to keep
75 | * item 5 (and the followings) on the right of the screen.
76 | *
77 | */
78 | export const getLastRightItemIndex = (
79 | data: T[],
80 | itemSizeInPx: number | ((item: T) => number),
81 | listSizeInPx: number,
82 | ): number => {
83 | if (typeof itemSizeInPx === 'function') {
84 | let totalSize = 0;
85 |
86 | for (let index = 0; index < data.length; index++) {
87 | totalSize += itemSizeInPx(data[index]);
88 |
89 | if (totalSize >= listSizeInPx) {
90 | // If we exceed the list size, we return the index of the previous item
91 | return index - 1;
92 | }
93 | }
94 |
95 | return data.length - 1;
96 | }
97 | const result = Math.floor(listSizeInPx / itemSizeInPx) - 1;
98 |
99 | if (result > data.length - 1) {
100 | // We substract 1 because index starts from 0
101 | return data.length - 1;
102 | }
103 | return result;
104 | };
105 |
--------------------------------------------------------------------------------
/packages/lib/src/spatial-navigation/components/virtualizedList/helpers/getNumberOfItemsVisibleOnScreen.ts:
--------------------------------------------------------------------------------
1 | const getMinSizeOfItems = ({
2 | data,
3 | itemSize,
4 | }: {
5 | data: T[];
6 | itemSize: number | ((item: T) => number);
7 | }) => {
8 | if (typeof itemSize === 'number') {
9 | return itemSize;
10 | }
11 |
12 | if (data.length === 0) {
13 | return 0;
14 | }
15 |
16 | const firstElementSize = itemSize(data[0]);
17 |
18 | const minSize = data.reduce((smallestSize, item) => {
19 | const currentSize = itemSize(item);
20 | if (currentSize < smallestSize) return currentSize;
21 | return smallestSize;
22 | }, firstElementSize);
23 |
24 | if (minSize === 0) {
25 | console.warn('The size of the smallest item in the list is 0. The list will appear empty.');
26 | }
27 |
28 | return minSize;
29 | };
30 |
31 | export const getNumberOfItemsVisibleOnScreen = ({
32 | data,
33 | listSizeInPx,
34 | itemSize,
35 | }: {
36 | data: T[];
37 | listSizeInPx: number;
38 | itemSize: number | ((item: T) => number);
39 | }) => {
40 | if (data.length === 0) {
41 | return 0;
42 | }
43 |
44 | const itemSizeToComputeRanges = getMinSizeOfItems({ data, itemSize });
45 |
46 | if (!itemSizeToComputeRanges) {
47 | return 0;
48 | }
49 |
50 | if (itemSizeToComputeRanges === 0) {
51 | console.warn('The size of the smallest item in the list is 0. The list will appear empty.');
52 | return 0;
53 | }
54 |
55 | return Math.floor(listSizeInPx / itemSizeToComputeRanges);
56 | };
57 |
--------------------------------------------------------------------------------
/packages/lib/src/spatial-navigation/components/virtualizedList/helpers/getSizeInPxFromOneItemToAnother.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * This function is used to compute the size in pixels of a range of items in a list.
3 | * If you want the size taken by items from index 0 to 5, you can call this function with
4 | * start = 0 and end = 5. The size is computed by summing the size of each item in the range.
5 | * @param data The list of items
6 | * @param itemSizeInPx The size of an item in pixels. It can be a number or a function that takes an item and returns a number.
7 | * @param start The start index of the range
8 | * @param end The end index of the range
9 | * @returns The size in pixels of the range of items
10 | **/
11 | export const getSizeInPxFromOneItemToAnother = (
12 | data: T[],
13 | itemSizeInPx: number | ((item: T) => number),
14 | start: number,
15 | end: number,
16 | ): number => {
17 | if (typeof itemSizeInPx === 'function') {
18 | return data.slice(start, end).reduce((acc, item) => acc + itemSizeInPx(item), 0);
19 | }
20 | return data.slice(start, end).length * itemSizeInPx;
21 | };
22 |
--------------------------------------------------------------------------------
/packages/lib/src/spatial-navigation/components/virtualizedList/helpers/updateVirtualNodeRegistration.test.ts:
--------------------------------------------------------------------------------
1 | import { updateVirtualNodeRegistration } from './updateVirtualNodeRegistration';
2 |
3 | const mockAddNode = jest.fn();
4 |
5 | describe('updateVirtualNodeRegistration', () => {
6 | it('should call the addNode as many time as there are new item', () => {
7 | const previousItems = ['a', 'b', 'c', 'd'];
8 | const currentItems = ['a', 'b', 'c', 'd', 'e', 'f'];
9 |
10 | updateVirtualNodeRegistration({
11 | previousItems,
12 | currentItems,
13 | addVirtualNode: mockAddNode,
14 | removeVirtualNode: jest.fn(),
15 | });
16 |
17 | expect(mockAddNode).toHaveBeenCalledTimes(2);
18 | });
19 |
20 | it('should not do anything if the array are the same', () => {
21 | const previousItems = ['a', 'b', 'c', 'd'];
22 | const currentItems = ['a', 'b', 'c', 'd'];
23 |
24 | updateVirtualNodeRegistration({
25 | previousItems,
26 | currentItems,
27 | addVirtualNode: mockAddNode,
28 | removeVirtualNode: jest.fn(),
29 | });
30 |
31 | expect(mockAddNode).not.toHaveBeenCalled();
32 | });
33 | });
34 |
--------------------------------------------------------------------------------
/packages/lib/src/spatial-navigation/components/virtualizedList/helpers/updateVirtualNodeRegistration.ts:
--------------------------------------------------------------------------------
1 | const registerNewNode = ({
2 | currentItems,
3 | previousItems,
4 | addVirtualNode,
5 | }: {
6 | currentItems: Array;
7 | previousItems: Array;
8 | addVirtualNode: (index: number) => void;
9 | }) => {
10 | currentItems.forEach((_, index) => {
11 | // Currently this is the only way to compare both array and to know which elements to add
12 | if (index > previousItems.length - 1) {
13 | addVirtualNode(index);
14 | }
15 | });
16 | };
17 |
18 | const unregisterOldNode = ({
19 | currentItems,
20 | previousItems,
21 | removeVirtualNode,
22 | }: {
23 | currentItems: Array;
24 | previousItems: Array;
25 | removeVirtualNode: (index: number) => void;
26 | }) => {
27 | for (let index = previousItems.length - 1; index > currentItems.length - 1; index--) {
28 | removeVirtualNode(index);
29 | }
30 | };
31 |
32 | /**
33 | * This function aims to compare 2 arrays of items : currentItems and previousItems and :
34 | * - addVirtualNode for every item from currentItems that weren't in previousItems
35 | * - removeVirtualNode for every item from previousItems that aren't there anymore in currentItems
36 | * - re-order all the items
37 | * For now it only does the Step 1.
38 | */
39 | export const updateVirtualNodeRegistration = ({
40 | currentItems,
41 | previousItems,
42 | addVirtualNode,
43 | removeVirtualNode,
44 | }: {
45 | currentItems: Array;
46 | previousItems: Array;
47 | addVirtualNode: (index: number) => void;
48 | removeVirtualNode: (index: number) => void;
49 | }) => {
50 | // Step 1 : addVirtualNode for every item from currentItems that weren't in previousItems
51 | registerNewNode({ currentItems, previousItems, addVirtualNode });
52 |
53 | // Step 2 : removeVirtualNode for every from previousItems that aren't there anymore in currentItems
54 | unregisterOldNode({ currentItems, previousItems, removeVirtualNode });
55 | };
56 |
--------------------------------------------------------------------------------
/packages/lib/src/spatial-navigation/components/virtualizedList/hooks/useCachedValues.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useRef } from 'react';
2 |
3 | /**
4 | * Basically a useMemo for an array that creates elements on the go (not all at the beginning).
5 | *
6 | * The input & output might seem similar -> the difference is that
7 | * - input `nthElementConstructor` always returns a new instance of the Nth element
8 | * - output`getNthMemoizedElement` always return the same instance of the Nth element (memoized).
9 | *
10 | * @warning nthElementConstructor should never change
11 | *
12 | * @param nthElementConstructor a callback that returns what we want the Nth element to be.
13 | * @returns a callback to get the Nth memoized element.
14 | */
15 | export const useCachedValues = (nthElementConstructor: (n: number) => T): ((n: number) => T) => {
16 | const memoizedElements = useRef<{ [n: number]: T }>({});
17 |
18 | return useCallback((n: number) => {
19 | if (memoizedElements.current[n]) return memoizedElements.current[n] as T;
20 |
21 | const newElement = nthElementConstructor(n);
22 | memoizedElements.current[n] = newElement;
23 | return newElement;
24 | /** We purposefully dont put `nthElementConstructor` as a dependency because, if it changed,
25 | * we would have to re-construct the whole cache. This use-case is not supported yet. */
26 | // eslint-disable-next-line react-hooks/exhaustive-deps
27 | }, []);
28 | };
29 |
--------------------------------------------------------------------------------
/packages/lib/src/spatial-navigation/components/virtualizedList/hooks/useVirtualizedListAnimation.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from 'react';
2 | import { Animated, Easing } from 'react-native';
3 | import { TypeVirtualizedListAnimation } from '../../../types/TypeVirtualizedListAnimation';
4 |
5 | export const useVirtualizedListAnimation: TypeVirtualizedListAnimation = ({
6 | currentlyFocusedItemIndex,
7 | vertical = false,
8 | scrollDuration,
9 | scrollOffsetsArray,
10 | }) => {
11 | const translation = useRef(new Animated.Value(0)).current;
12 | const newTranslationValue = scrollOffsetsArray[currentlyFocusedItemIndex];
13 |
14 | useEffect(() => {
15 | Animated.timing(translation, {
16 | toValue: newTranslationValue,
17 | duration: scrollDuration,
18 | useNativeDriver: true,
19 | easing: Easing.out(Easing.sin),
20 | }).start();
21 | }, [translation, newTranslationValue, scrollDuration]);
22 |
23 | return {
24 | transform: [vertical ? { translateY: translation } : { translateX: translation }],
25 | };
26 | };
27 |
28 | export const useWebVirtualizedListAnimation: TypeVirtualizedListAnimation = ({
29 | currentlyFocusedItemIndex,
30 | vertical = false,
31 | scrollDuration,
32 | scrollOffsetsArray,
33 | }) => {
34 | const animationDuration = `${scrollDuration}ms`;
35 | const newTranslationValue = scrollOffsetsArray[currentlyFocusedItemIndex];
36 |
37 | return {
38 | transitionDuration: animationDuration,
39 | transitionProperty: 'transform',
40 | transitionTimingFunction: 'ease-out',
41 | transform: [
42 | vertical ? { translateY: newTranslationValue } : { translateX: newTranslationValue },
43 | ],
44 | };
45 | };
46 |
--------------------------------------------------------------------------------
/packages/lib/src/spatial-navigation/configureRemoteControl.ts:
--------------------------------------------------------------------------------
1 | import { Direction } from '@bam.tech/lrud';
2 |
3 | // eslint-disable-next-line @typescript-eslint/no-explicit-any -- can't know for sure what the subscriber will be...
4 | type SubscriberType = any;
5 |
6 | export interface RemoteControlConfiguration {
7 | remoteControlSubscriber: (lrudCallback: (direction: Direction | null) => void) => SubscriberType;
8 | remoteControlUnsubscriber: (subscriber: SubscriberType) => void;
9 | }
10 |
11 | export let remoteControlSubscriber:
12 | | RemoteControlConfiguration['remoteControlSubscriber']
13 | | undefined = undefined;
14 | export let remoteControlUnsubscriber:
15 | | RemoteControlConfiguration['remoteControlUnsubscriber']
16 | | undefined = undefined;
17 |
18 | export const configureRemoteControl = (options: RemoteControlConfiguration) => {
19 | remoteControlSubscriber = options.remoteControlSubscriber;
20 | remoteControlUnsubscriber = options.remoteControlUnsubscriber;
21 | };
22 |
--------------------------------------------------------------------------------
/packages/lib/src/spatial-navigation/context/DefaultFocusContext.tsx:
--------------------------------------------------------------------------------
1 | import React, { createContext, useContext } from 'react';
2 |
3 | const SpatialNavigatorDefaultFocusContext = createContext(false);
4 |
5 | export const useSpatialNavigatorDefaultFocus = () => {
6 | const spatialNavigatorDefaultFocus = useContext(SpatialNavigatorDefaultFocusContext);
7 | return spatialNavigatorDefaultFocus;
8 | };
9 |
10 | type Props = {
11 | children: React.ReactNode;
12 | enable?: boolean;
13 | };
14 |
15 | export const DefaultFocus = ({ children, enable = true }: Props) => {
16 | return (
17 |
18 | {children}
19 |
20 | );
21 | };
22 |
--------------------------------------------------------------------------------
/packages/lib/src/spatial-navigation/context/DeviceContext.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | createContext,
3 | useCallback,
4 | useContext,
5 | useEffect,
6 | useMemo,
7 | useRef,
8 | useState,
9 | } from 'react';
10 | import { Platform } from 'react-native';
11 |
12 | type Device = 'remoteKeys' | 'remotePointer';
13 |
14 | interface DeviceContextProps {
15 | /** Use `deviceType` only if you need a render, otherwise use `deviceTypeRef` */
16 | deviceType: Device;
17 | /** Use `deviceTypeRef` for user events or if you don't need render, otherwise use `deviceType` */
18 | deviceTypeRef: React.MutableRefObject;
19 | setDeviceType: (deviceType: Device) => void;
20 | getScrollingIntervalId: () => NodeJS.Timeout | null;
21 | setScrollingIntervalId: (scrollingId: NodeJS.Timeout | null) => void;
22 | }
23 |
24 | export const DeviceContext = createContext({
25 | deviceType: 'remoteKeys',
26 | deviceTypeRef: { current: 'remoteKeys' },
27 | setDeviceType: () => {},
28 | getScrollingIntervalId: () => null,
29 | setScrollingIntervalId: () => {},
30 | });
31 |
32 | interface DeviceProviderProps {
33 | children: React.ReactNode;
34 | }
35 |
36 | export const SpatialNavigationDeviceTypeProvider = ({ children }: DeviceProviderProps) => {
37 | const [deviceType, setDeviceTypeWithoutRef] = useState('remoteKeys');
38 |
39 | const deviceTypeRef = useRef(deviceType);
40 | const scrollingId = useRef(null);
41 |
42 | const setDeviceType = useCallback((deviceType: Device) => {
43 | deviceTypeRef.current = deviceType;
44 | setDeviceTypeWithoutRef(deviceType);
45 | }, []);
46 |
47 | const setScrollingIntervalId = useCallback((id: NodeJS.Timeout | null) => {
48 | if (scrollingId.current) {
49 | clearInterval(scrollingId.current);
50 | }
51 | scrollingId.current = id;
52 | }, []);
53 |
54 | const getScrollingIntervalId = useCallback(() => scrollingId.current, []);
55 |
56 | useEffect(() => {
57 | if (deviceType === 'remotePointer' || Platform.OS !== 'web') return;
58 |
59 | const callback = () => {
60 | setDeviceType('remotePointer');
61 | };
62 |
63 | window.addEventListener('mousemove', callback);
64 | return () => window.removeEventListener('mousemove', callback);
65 | }, [deviceType, setDeviceType]);
66 |
67 | const value = useMemo(
68 | () => ({
69 | deviceType,
70 | deviceTypeRef,
71 | setDeviceType,
72 | getScrollingIntervalId,
73 | setScrollingIntervalId,
74 | }),
75 | [deviceType, setDeviceType, getScrollingIntervalId, setScrollingIntervalId],
76 | );
77 |
78 | return {children};
79 | };
80 |
81 | export const useSpatialNavigationDeviceType = (): DeviceContextProps => useContext(DeviceContext);
82 |
--------------------------------------------------------------------------------
/packages/lib/src/spatial-navigation/context/IsRootActiveContext.ts:
--------------------------------------------------------------------------------
1 | import { createContext, useContext } from 'react';
2 |
3 | export const IsRootActiveContext = createContext(true);
4 |
5 | export const useIsRootActive = () => {
6 | return useContext(IsRootActiveContext);
7 | };
8 |
--------------------------------------------------------------------------------
/packages/lib/src/spatial-navigation/context/LockSpatialNavigationContext.ts:
--------------------------------------------------------------------------------
1 | import { createContext, useContext, useMemo, useReducer } from 'react';
2 |
3 | /**
4 | * We store the number of times that we have been asked to lock the navigator
5 | * to avoid any race conditions
6 | *
7 | * It's more reliable than a simple boolean
8 | */
9 | const lockReducer = (state: number, action: 'lock' | 'unlock'): number => {
10 | switch (action) {
11 | case 'lock':
12 | return state + 1;
13 | case 'unlock':
14 | return state - 1;
15 | default:
16 | return state;
17 | }
18 | };
19 |
20 | export const useIsLocked = () => {
21 | const [lockAmount, dispatch] = useReducer(lockReducer, 0);
22 |
23 | const lockActions = useMemo(
24 | () => ({
25 | lock: () => dispatch('lock'),
26 | unlock: () => dispatch('unlock'),
27 | }),
28 | [dispatch],
29 | );
30 |
31 | return {
32 | isLocked: lockAmount !== 0,
33 | lockActions,
34 | };
35 | };
36 |
37 | export const LockSpatialNavigationContext = createContext<{
38 | lock: () => void;
39 | unlock: () => void;
40 | }>({
41 | lock: () => undefined,
42 | unlock: () => undefined,
43 | });
44 |
45 | export const useLockSpatialNavigation = () => {
46 | const { lock, unlock } = useContext(LockSpatialNavigationContext);
47 | return { lock, unlock };
48 | };
49 |
--------------------------------------------------------------------------------
/packages/lib/src/spatial-navigation/context/ParentIdContext.ts:
--------------------------------------------------------------------------------
1 | import { createContext, useContext } from 'react';
2 |
3 | export const ParentIdContext = createContext(null);
4 |
5 | export const useParentId = () => {
6 | const parentId = useContext(ParentIdContext);
7 | if (!parentId) throw new Error('Node used without any Parent!');
8 | return parentId;
9 | };
10 |
--------------------------------------------------------------------------------
/packages/lib/src/spatial-navigation/context/ParentScrollContext.ts:
--------------------------------------------------------------------------------
1 | import { createContext, RefObject, useContext } from 'react';
2 | import { View } from 'react-native';
3 |
4 | export type ScrollToNodeCallback = (ref: RefObject, additionalOffset?: number) => void;
5 | export const SpatialNavigatorParentScrollContext = createContext(() => {});
6 |
7 | export const useSpatialNavigatorParentScroll = (): {
8 | scrollToNodeIfNeeded: ScrollToNodeCallback;
9 | } => {
10 | const scrollToNodeIfNeeded = useContext(SpatialNavigatorParentScrollContext);
11 | return { scrollToNodeIfNeeded };
12 | };
13 |
--------------------------------------------------------------------------------
/packages/lib/src/spatial-navigation/context/SpatialNavigatorContext.ts:
--------------------------------------------------------------------------------
1 | import SpatialNavigator from '../SpatialNavigator';
2 | import { createContext, useContext } from 'react';
3 |
4 | export const SpatialNavigatorContext = createContext(null);
5 |
6 | export const useSpatialNavigator = () => {
7 | const spatialNavigator = useContext(SpatialNavigatorContext);
8 | if (!spatialNavigator)
9 | throw new Error(
10 | 'No registered spatial navigator on this page. Use the component.',
11 | );
12 | return spatialNavigator;
13 | };
14 |
--------------------------------------------------------------------------------
/packages/lib/src/spatial-navigation/helpers/TypedForwardRef.tsx:
--------------------------------------------------------------------------------
1 | import { ForwardedRef, ReactElement, RefAttributes, forwardRef } from 'react';
2 |
3 | /**
4 | * This works like React.forwardRef but for components with generics props.
5 | * @warning Don't use this if your component type isn't generic => `const Component = () => {...}` and displayName is not supported yet
6 | */
7 | export function typedForwardRef(
8 | render: (props: P, ref: ForwardedRef) => ReactElement | null,
9 | ): (props: P & RefAttributes) => ReactElement | null {
10 | return forwardRef(render) as (props: P & RefAttributes) => ReactElement | null;
11 | }
12 |
--------------------------------------------------------------------------------
/packages/lib/src/spatial-navigation/helpers/TypedMemo.tsx:
--------------------------------------------------------------------------------
1 | import { ComponentType, memo, ComponentProps } from 'react';
2 |
3 | type PropsComparator = (
4 | prevProps: Readonly>,
5 | nextProps: Readonly>,
6 | ) => boolean;
7 |
8 | /**
9 | * This works like React.memo but for components with generics props.
10 | * See issue: https://github.com/DefinitelyTyped/DefinitelyTyped/issues/37087
11 | * @warning Don't use this if your component type isn't generic => `const Component = () => {...}`
12 | */
13 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
14 | export function typedMemo>(
15 | Component: C,
16 | propsAreEqual?: PropsComparator,
17 | ) {
18 | return memo(Component, propsAreEqual) as unknown as C & { displayName?: string };
19 | }
20 |
--------------------------------------------------------------------------------
/packages/lib/src/spatial-navigation/helpers/isError.ts:
--------------------------------------------------------------------------------
1 | export const isError = (e: unknown): e is Error => e instanceof Error;
2 |
--------------------------------------------------------------------------------
/packages/lib/src/spatial-navigation/helpers/mergeRefs.ts:
--------------------------------------------------------------------------------
1 | // copy-paste from react-merge-refs lib
2 | import type * as React from 'react';
3 |
4 | export function mergeRefs(
5 | refs: Array | React.LegacyRef | undefined | null>,
6 | ): React.RefCallback {
7 | return (value) => {
8 | refs.forEach((ref) => {
9 | if (typeof ref === 'function') {
10 | ref(value);
11 | } else if (ref != null) {
12 | (ref as React.MutableRefObject).current = value;
13 | }
14 | });
15 | };
16 | }
17 |
--------------------------------------------------------------------------------
/packages/lib/src/spatial-navigation/helpers/scrollToNewlyfocusedElement.ts:
--------------------------------------------------------------------------------
1 | import { RefObject } from 'react';
2 | import { CustomScrollViewRef } from '../components/ScrollView/types';
3 |
4 | export type Props = {
5 | newlyFocusedElementDistanceToLeftRelativeToLayout: number;
6 | newlyFocusedElementDistanceToTopRelativeToLayout: number;
7 | horizontal?: boolean;
8 | offsetFromStart: number;
9 | scrollViewRef: RefObject;
10 | };
11 |
12 | export const scrollToNewlyFocusedElement = ({
13 | newlyFocusedElementDistanceToLeftRelativeToLayout,
14 | newlyFocusedElementDistanceToTopRelativeToLayout,
15 | horizontal,
16 | offsetFromStart,
17 | scrollViewRef,
18 | }: Props) => {
19 | if (horizontal) {
20 | scrollViewRef?.current?.scrollTo({
21 | x: newlyFocusedElementDistanceToLeftRelativeToLayout - offsetFromStart,
22 | // @todo make this a props of the component
23 | animated: true,
24 | });
25 | } else {
26 | scrollViewRef?.current?.scrollTo({
27 | y: newlyFocusedElementDistanceToTopRelativeToLayout - offsetFromStart,
28 | // @todo make this a props of the component
29 | animated: true,
30 | });
31 | }
32 | };
33 |
--------------------------------------------------------------------------------
/packages/lib/src/spatial-navigation/hooks/useCreateSpatialNavigator.ts:
--------------------------------------------------------------------------------
1 | import SpatialNavigator, { OnDirectionHandledWithoutMovement } from '../SpatialNavigator';
2 | import { useMemo } from 'react';
3 |
4 | type SpatialNavigatorHookParams = {
5 | onDirectionHandledWithoutMovementRef: React.MutableRefObject;
6 | };
7 |
8 | export const useCreateSpatialNavigator = ({
9 | onDirectionHandledWithoutMovementRef,
10 | }: SpatialNavigatorHookParams) => {
11 | const spatialNavigator = useMemo(
12 | () =>
13 | new SpatialNavigator({
14 | onDirectionHandledWithoutMovementRef,
15 | }),
16 | // This dependency should be safe and won't recreate a navigator every time since it's a ref
17 | [onDirectionHandledWithoutMovementRef],
18 | );
19 |
20 | return spatialNavigator;
21 | };
22 |
--------------------------------------------------------------------------------
/packages/lib/src/spatial-navigation/hooks/useRemoteControl.ts:
--------------------------------------------------------------------------------
1 | import SpatialNavigator from '../SpatialNavigator';
2 | import { useEffect } from 'react';
3 | import { remoteControlSubscriber, remoteControlUnsubscriber } from '../configureRemoteControl';
4 | import { useSpatialNavigationDeviceType } from '../context/DeviceContext';
5 |
6 | export const useRemoteControl = ({
7 | spatialNavigator,
8 | isActive,
9 | }: {
10 | spatialNavigator: SpatialNavigator;
11 | isActive: boolean;
12 | }) => {
13 | const { setDeviceType, setScrollingIntervalId: setScrollingId } =
14 | useSpatialNavigationDeviceType();
15 | useEffect(() => {
16 | if (!remoteControlSubscriber) {
17 | console.warn(
18 | '[React Spatial Navigation] You probably forgot to configure the remote control. Please call the configuration function.',
19 | );
20 |
21 | return;
22 | }
23 |
24 | if (!isActive) {
25 | return () => undefined;
26 | }
27 |
28 | const listener = remoteControlSubscriber((direction) => {
29 | setDeviceType('remoteKeys');
30 | spatialNavigator.handleKeyDown(direction);
31 | setScrollingId(null);
32 | });
33 | return () => {
34 | if (!remoteControlUnsubscriber) {
35 | console.warn(
36 | '[React Spatial Navigation] You did not provide a remote control unsubscriber. Are you sure you called configuration correctly?',
37 | );
38 |
39 | return;
40 | }
41 | remoteControlUnsubscriber(listener);
42 | };
43 | }, [spatialNavigator, isActive, setDeviceType, setScrollingId]);
44 | };
45 |
--------------------------------------------------------------------------------
/packages/lib/src/spatial-navigation/hooks/useSpatialNavigatorFocusableAccessibilityProps.ts:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react';
2 | import { useParentId } from '../context/ParentIdContext';
3 | import { useSpatialNavigator } from '../context/SpatialNavigatorContext';
4 |
5 | export const useSpatialNavigatorFocusableAccessibilityProps = () => {
6 | const spatialNavigator = useSpatialNavigator();
7 | const id = useParentId();
8 |
9 | const accessibilityProps = useMemo(
10 | () => ({
11 | accessible: true,
12 | accessibilityRole: 'button' as const,
13 | accessibilityActions: [{ name: 'activate' }] as const,
14 | onAccessibilityAction: () => {
15 | const currentNode = spatialNavigator.getCurrentFocusNode();
16 |
17 | if (currentNode?.id === id) {
18 | spatialNavigator.getCurrentFocusNode()?.onSelect?.(currentNode);
19 | } else {
20 | spatialNavigator.grabFocus(id);
21 | }
22 | },
23 | }),
24 | [id, spatialNavigator],
25 | );
26 |
27 | return accessibilityProps;
28 | };
29 |
--------------------------------------------------------------------------------
/packages/lib/src/spatial-navigation/hooks/useUniqueId.ts:
--------------------------------------------------------------------------------
1 | import uniqueId from 'lodash.uniqueid';
2 | import { useMemo } from 'react';
3 |
4 | export const useUniqueId = ({ prefix }: { prefix?: string } = {}) =>
5 | useMemo(() => uniqueId(prefix), [prefix]);
6 |
--------------------------------------------------------------------------------
/packages/lib/src/spatial-navigation/types/SpatialNavigationNodeRef.ts:
--------------------------------------------------------------------------------
1 | export type SpatialNavigationNodeRef = {
2 | focus: () => void;
3 | };
4 |
--------------------------------------------------------------------------------
/packages/lib/src/spatial-navigation/types/SpatialNavigationVirtualizedListRef.ts:
--------------------------------------------------------------------------------
1 | export type SpatialNavigationVirtualizedListRef = {
2 | focus: (index: number) => void;
3 | scrollTo: (index: number) => void;
4 | currentlyFocusedItemIndex: number;
5 | };
6 |
--------------------------------------------------------------------------------
/packages/lib/src/spatial-navigation/types/TypeVirtualizedListAnimation.ts:
--------------------------------------------------------------------------------
1 | import { ViewStyle, Animated } from 'react-native';
2 |
3 | export type TypeVirtualizedListAnimation = (args: {
4 | currentlyFocusedItemIndex: number;
5 | vertical?: boolean;
6 | scrollDuration: number;
7 | scrollOffsetsArray: number[];
8 | }) => Animated.WithAnimatedValue;
9 |
--------------------------------------------------------------------------------
/packages/lib/src/spatial-navigation/types/orientation.ts:
--------------------------------------------------------------------------------
1 | export type NodeOrientation = 'horizontal' | 'vertical';
2 |
--------------------------------------------------------------------------------
/packages/lib/src/testing/constants.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * UTC+1 in Winter (the default date is in Winter)
3 | */
4 | export const TEST_DEFAULT_TZ = 'Europe/Paris';
5 |
6 | /**
7 | * 15:23 UTC -> 16:23 In Paris
8 | */
9 | export const TEST_DEFAULT_DATE = '2023-03-05T15:23:49.294Z';
10 |
11 | export const TEST_DEFAULT_MATH_RANDOM = 0.372294134538401;
12 |
13 | export const TEST_TIMEOUT = 10000; // Timeout in milliseconds (e.g., 10000ms = 10 seconds)
14 |
--------------------------------------------------------------------------------
/packages/lib/src/testing/jest-setup.ts:
--------------------------------------------------------------------------------
1 | export {};
2 |
--------------------------------------------------------------------------------
/packages/lib/src/testing/jest-setupAfterEnv.ts:
--------------------------------------------------------------------------------
1 | import '@testing-library/react-native/extend-expect';
2 |
3 | import { TEST_DEFAULT_DATE, TEST_DEFAULT_MATH_RANDOM } from './constants';
4 |
5 | /**
6 | * Some globals have no reason to not ever be mocked if we want to have reproducible tests.
7 | * Put those things here: Date, Math.random, etc.
8 | *
9 | * You can still customize the mock in an isolated way for a given test or test suite
10 | * BEWARE that your customizations in tests and test suites don't apply to top-level module code (it runs before the `beforeEach`)
11 | */
12 | const setupPermanentMocks = () => {
13 | // Note: Timezone is set in src/testing/jest-globalSetup.ts (it wouldn't work to set it here)
14 | jest.useFakeTimers({
15 | // We're not really interested in stopping the microtasks queue, what we want to mock is "timers"
16 | doNotFake: [
17 | 'setImmediate', // see https://github.com/callstack/react-native-testing-library/issues/1347
18 | 'clearImmediate',
19 | 'nextTick',
20 | 'queueMicrotask',
21 | 'requestIdleCallback',
22 | 'cancelIdleCallback',
23 | 'requestAnimationFrame',
24 | 'cancelAnimationFrame',
25 | 'hrtime',
26 | 'performance',
27 | ],
28 | now: new Date(TEST_DEFAULT_DATE), // To customize in a test, use `jest.setSystemTime`
29 | });
30 |
31 | Math.random = () => TEST_DEFAULT_MATH_RANDOM; // To customize in a given test, use `jest.spyOn(Math, "random").mockReturnValue(xx)`
32 | };
33 |
34 | // Some code runs before `beforeEach` (top-level code from imported modules), so this line is needed
35 | setupPermanentMocks();
36 |
37 | // And then this one is needed to re-set the mocks after the automatic `clearMocks`
38 | beforeEach(() => {
39 | setupPermanentMocks();
40 | });
41 |
--------------------------------------------------------------------------------
/packages/lib/src/testing/setComponentLayoutSize.ts:
--------------------------------------------------------------------------------
1 | import { RenderResult, fireEvent } from '@testing-library/react-native';
2 |
3 | export const setComponentLayoutSize = (
4 | listTestId: string,
5 | component: RenderResult,
6 | size: { width: number; height: number },
7 | ) => {
8 | const listElementSizeGiver = component.getByTestId(listTestId + '-size-giver');
9 |
10 | fireEvent(listElementSizeGiver, 'layout', {
11 | nativeEvent: { layout: { width: size.width, height: size.height } },
12 | });
13 | };
14 |
--------------------------------------------------------------------------------
/packages/lib/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.base.json",
3 | "include": [
4 | "./src/**/*"
5 | ]
6 | }
7 |
--------------------------------------------------------------------------------
/packages/lib/webpack.config.js:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line @typescript-eslint/no-var-requires
2 | const path = require('path');
3 |
4 | module.exports = {
5 | entry: './src/index.ts',
6 | output: {
7 | path: path.resolve(__dirname, 'dist'),
8 | filename: 'index.js',
9 | library: 'ReactSpatialNavigation',
10 | libraryTarget: 'umd',
11 | umdNamedDefine: true,
12 | globalObject: 'this',
13 | },
14 | devtool: 'source-map',
15 | mode: 'production',
16 | module: {
17 | rules: [
18 | {
19 | test: /\.(ts|tsx)$/,
20 | exclude: /node_modules/,
21 | use: [
22 | {
23 | loader: 'babel-loader',
24 | options: {
25 | presets: [['@babel/preset-react', { runtime: 'automatic' }]],
26 | plugins: ['transform-class-properties'],
27 | },
28 | },
29 | {
30 | loader: 'ts-loader',
31 | options: {
32 | compilerOptions: {
33 | declaration: true,
34 | declarationDir: './dist',
35 | },
36 | },
37 | },
38 | ],
39 | },
40 | {
41 | test: /\.css$/i,
42 | use: ['style-loader', 'css-loader'],
43 | },
44 | ],
45 | },
46 | resolve: {
47 | extensions: ['.js', '.jsx', '.ts', '.tsx'],
48 | },
49 | externals: {
50 | react: 'react',
51 | 'react-dom': 'react-dom',
52 | 'react-native': 'react-native',
53 | 'react-native-web': 'react-native-web',
54 | },
55 | optimization: {
56 | minimize: false,
57 | },
58 | };
59 |
--------------------------------------------------------------------------------
/prettier.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | trailingComma: 'all',
3 | semi: true,
4 | singleQuote: true,
5 | bracketSpacing: true,
6 | printWidth: 100,
7 | tabWidth: 2,
8 | };
9 |
--------------------------------------------------------------------------------
/tsconfig.base.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "strict": true,
4 | "skipLibCheck": true,
5 | "outDir": "./dist/",
6 | "sourceMap": true,
7 | "noImplicitAny": true,
8 | "module": "commonjs",
9 | "target": "es6",
10 | "esModuleInterop": true,
11 | "jsx": "react-jsx"
12 | },
13 | "include": [
14 | "./src/**/*",
15 | "./example/**/*"
16 | ]
17 | }
18 |
--------------------------------------------------------------------------------