├── .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 | ![banner](./docs/banner.png) 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 | ![demo](./docs/demo.gif) 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 | ![talkback](./talkback.gif) 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 | ![demo](./conditional-rendering-problem.png) 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 | ![demo](./conditional-rendering-solution.png) 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 |