├── pnpm-workspace.yaml ├── .github ├── README.md ├── workflows │ ├── build.yml │ ├── publish.yml │ ├── pages.yml │ └── codeql-analysis.yml └── actions │ └── build │ └── action.yml ├── .vscode └── settings.json ├── .gitattributes ├── .gitignore ├── packages ├── react-keyboard-navigator-example │ ├── pages │ │ ├── page.scss │ │ ├── $.mdx │ │ ├── _theme.tsx │ │ ├── randomPlacement.scss │ │ ├── interestGallery.scss │ │ ├── interestGallery$.tsx │ │ ├── randomPlacement$.tsx │ │ ├── macOSFinder.scss │ │ └── macOSFinder$.tsx │ ├── index.html │ ├── vite.config.ts │ ├── components │ │ └── badge.tsx │ └── package.json └── react-keyboard-navigator │ ├── src │ ├── assert.ts │ ├── OpaqueType.ts │ ├── hooks │ │ ├── useValueGetter.ts │ │ ├── useEvent.ts │ │ └── useNextTickCallback.ts │ ├── utils │ │ ├── mod.ts │ │ ├── helper.ts │ │ ├── groupPointsIntoZones.ts │ │ ├── calculatePositionPoint.ts │ │ ├── __tests__ │ │ │ ├── calculatePositionPoint.spec.ts │ │ │ ├── groupPointsIntoZones.spec.ts │ │ │ ├── mod.spec.ts │ │ │ ├── points.data.ts │ │ │ └── groupByDirection.spec.ts │ │ └── groupByDirection.ts │ ├── types │ │ └── type.ts │ ├── index.ts │ ├── helpers │ │ ├── __tests__ │ │ │ ├── StrategiesHelper.spec.ts │ │ │ └── DirectionMapPresets.spec.ts │ │ ├── EventCallbackPresets.ts │ │ ├── DirectionMapPresets.ts │ │ └── StrategiesHelper.ts │ ├── ReactKeyboardNavigator.tsx │ └── useKeyboardNavigator.ts │ ├── package.json │ └── README.md ├── tsconfig.json ├── LICENSE ├── package.json └── .eslintrc.js /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - packages/* -------------------------------------------------------------------------------- /.github/README.md: -------------------------------------------------------------------------------- 1 | ../packages/react-keyboard-navigator/README.md -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.scss linguist-detectable=false 2 | *.css linguist-detectable=false 3 | 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | lib 5 | *.local 6 | *.tgz 7 | .pnpm-debug.log 8 | .vscode 9 | coverage -------------------------------------------------------------------------------- /packages/react-keyboard-navigator-example/pages/page.scss: -------------------------------------------------------------------------------- 1 | .simple-style { 2 | @import "styled-css-base/presets/simple/index"; 3 | } -------------------------------------------------------------------------------- /packages/react-keyboard-navigator-example/pages/$.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Introduction 3 | --- 4 | 5 | import README from 'react-keyboard-navigator/README.md' 6 | 7 | 8 | -------------------------------------------------------------------------------- /packages/react-keyboard-navigator/src/assert.ts: -------------------------------------------------------------------------------- 1 | export function assertExist (item: T | undefined, message: string): asserts item is T { 2 | if (item === undefined) { 3 | throw new Error(message) 4 | } 5 | } -------------------------------------------------------------------------------- /packages/react-keyboard-navigator/src/OpaqueType.ts: -------------------------------------------------------------------------------- 1 | declare const __opaque: unique symbol 2 | 3 | export type OpaqueType = T & { [__opaque]: true } 4 | 5 | export const createOpaqueTypeConstructor = (createValueWay: () => T): () => OpaqueType => { 6 | return () => createValueWay() as T & { [__opaque]: true } 7 | } 8 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | - push 5 | - pull_request 6 | - workflow_call 7 | 8 | jobs: 9 | install-build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v2 15 | 16 | - name: Build 17 | uses: ./.github/actions/build -------------------------------------------------------------------------------- /packages/react-keyboard-navigator-example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ⌨️ React Keyboard Navigator 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /packages/react-keyboard-navigator/src/hooks/useValueGetter.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useLayoutEffect, useRef } from 'react' 2 | 3 | export const useValueGetter = (value: V): () => V => { 4 | const valueRef = useRef(value) 5 | 6 | useLayoutEffect(() => { 7 | valueRef.current = value 8 | }) 9 | 10 | return useCallback<() => V>(() => valueRef.current, []) 11 | } 12 | -------------------------------------------------------------------------------- /packages/react-keyboard-navigator-example/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import * as path from 'path' 3 | import react from '@vitejs/plugin-react' 4 | import pages from 'vite-plugin-react-pages' 5 | 6 | export default defineConfig({ 7 | plugins: [ 8 | react(), 9 | pages({ 10 | pagesDir: path.join(__dirname, 'pages'), 11 | useHashRouter: true, 12 | }), 13 | ], 14 | }) 15 | -------------------------------------------------------------------------------- /packages/react-keyboard-navigator-example/pages/_theme.tsx: -------------------------------------------------------------------------------- 1 | import { createTheme } from 'vite-pages-theme-doc' 2 | import './page.scss' 3 | 4 | export default createTheme({ 5 | logo: '⌨ React Keyboard Navigator', 6 | topNavs: [ 7 | { 8 | label: 'Source of examples', 9 | href: 'https://github.com/zheeeng/react-keyboard-navigator/tree/main/packages/react-keyboard-navigator-example/pages', 10 | }, 11 | { 12 | label: 'Github ⭐', 13 | href: 'https://github.com/zheeeng/react-keyboard-navigator', 14 | }, 15 | ], 16 | }) 17 | -------------------------------------------------------------------------------- /packages/react-keyboard-navigator/src/utils/mod.ts: -------------------------------------------------------------------------------- 1 | export function mod(target: number, lowerBound: number, upperBound: number) { 2 | const fixedLowerBound = Math.min(lowerBound, upperBound) 3 | const fixedUpperBound = Math.max(lowerBound, upperBound) 4 | 5 | const numberSpan = fixedUpperBound - fixedLowerBound + 1 6 | const remainder = (target - fixedLowerBound) % numberSpan 7 | 8 | if (remainder < 0) { 9 | return remainder + numberSpan + fixedLowerBound 10 | } 11 | 12 | return remainder + fixedLowerBound 13 | } 14 | -------------------------------------------------------------------------------- /packages/react-keyboard-navigator/src/types/type.ts: -------------------------------------------------------------------------------- 1 | export type KeyboardDirection = 'UP' | 'DOWN' | 'LEFT' | 'RIGHT' | 'UP_LEFT' | 'UP_RIGHT' | 'DOWN_LEFT' | 'DOWN_RIGHT' 2 | 3 | export type DirectionDetails = { key: string, strategy: DistanceStrategy } 4 | export type DirectionKeyMap = Partial> 5 | export type DirectionMap = Partial> 6 | 7 | export type DistanceStrategy = 'Distance' | 'Secant' | 'Cosine' | 'Sine' | 'Tangent' | ((distance: number, angleDegree: number) => number) 8 | -------------------------------------------------------------------------------- /packages/react-keyboard-navigator/src/utils/helper.ts: -------------------------------------------------------------------------------- 1 | export function isObject (testItem: unknown): testItem is Record { 2 | return typeof testItem === 'object' && testItem !== null 3 | } 4 | 5 | export function objectMap , U>(item: T, mapper: (i: T[keyof T], key: keyof T) => U) { 6 | return Object.keys(item).reduce( 7 | (rec, key: keyof T) => { 8 | rec[key] = mapper(item[key], key) 9 | 10 | return rec 11 | }, 12 | {} as Record, 13 | ) 14 | } -------------------------------------------------------------------------------- /packages/react-keyboard-navigator/src/index.ts: -------------------------------------------------------------------------------- 1 | export type { KeyboardNavigatorBoardProps, KeyboardNavigatorElementProps } from './ReactKeyboardNavigator' 2 | export { KeyboardNavigatorBoard, KeyboardNavigatorElement } from './ReactKeyboardNavigator' 3 | export type { UseKeyboardNavigatorOption } from './useKeyboardNavigator' 4 | export { useKeyboardNavigator, } from './useKeyboardNavigator' 5 | export { StrategiesHelper } from './helpers/StrategiesHelper' 6 | export { DirectionMapPresets } from './helpers/DirectionMapPresets' 7 | export { EventCallbackPresets } from './helpers/EventCallbackPresets' -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES6", 4 | "jsx": "react-jsx", 5 | "lib": ["ES2015", "DOM"], 6 | "declaration": true, 7 | "moduleResolution": "Node", 8 | "esModuleInterop": true, 9 | "strict": true, 10 | "skipLibCheck": true, 11 | "strictNullChecks": true, 12 | "noImplicitAny": true, 13 | "strictFunctionTypes": true, 14 | "strictBindCallApply": true, 15 | "noUncheckedIndexedAccess": true, 16 | "strictPropertyInitialization": true, 17 | "alwaysStrict": true, 18 | "noErrorTruncation": true 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | publish: 10 | if: github.repository == 'zheeeng/react-keyboard-navigator' 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v2 15 | 16 | - name: Build 17 | uses: ./.github/actions/build 18 | 19 | - name: Publish 20 | uses: JS-DevTools/npm-publish@v1 21 | with: 22 | token: ${{ secrets.NPM_TOKEN }} 23 | package: ./packages/react-keyboard-navigator/package.json 24 | -------------------------------------------------------------------------------- /packages/react-keyboard-navigator/src/helpers/__tests__/StrategiesHelper.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from 'vitest' 2 | import { StrategiesHelper } from '../StrategiesHelper' 3 | 4 | describe('test StrategiesHelper data structure', () => { 5 | test('its data structure', () => { 6 | expect(StrategiesHelper).toEqual({ 7 | distance: expect.any(Function), 8 | secant: expect.any(Function), 9 | cosine: expect.any(Function), 10 | sine: expect.any(Function), 11 | tangent: expect.any(Function), 12 | }) 13 | }) 14 | }) -------------------------------------------------------------------------------- /packages/react-keyboard-navigator/src/hooks/useEvent.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useLayoutEffect, useRef } from 'react' 2 | 3 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 4 | export const useEvent = unknown>(handler: F): F => { 5 | const handlerRef = useRef(null) 6 | 7 | useLayoutEffect(() => { 8 | handlerRef.current = handler 9 | }) 10 | 11 | // eslint-disable-next-line react-hooks/exhaustive-deps 12 | return useCallback(((...args: Parameters) => handlerRef.current?.(...args)) as F, []) 13 | } 14 | 15 | 16 | -------------------------------------------------------------------------------- /packages/react-keyboard-navigator-example/components/badge.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export type BadgeProps = { 4 | reference: string, 5 | } 6 | 7 | export const Badge = React.memo( 8 | function ({ reference }) { 9 | return ( 10 |
11 | Reference: {reference} 12 |
13 | ) 14 | } 15 | ) 16 | 17 | Badge.displayName = 'Badge' -------------------------------------------------------------------------------- /packages/react-keyboard-navigator/src/hooks/useNextTickCallback.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { useCallback, useEffect } from 'react' 3 | 4 | export const useNextTickCallback = (handler: (...params: P) => unknown): (...params: Params) => void => { 5 | const [params, setParams] = useState() 6 | 7 | useEffect( 8 | () => { 9 | if (!params) { 10 | return 11 | } 12 | 13 | handler(...params) 14 | }, 15 | [handler, params] 16 | ) 17 | 18 | const trigger = useCallback((...params: Params) => setParams(params), []) 19 | 20 | return trigger 21 | } 22 | -------------------------------------------------------------------------------- /.github/workflows/pages.yml: -------------------------------------------------------------------------------- 1 | name: Pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | pages: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v2 14 | 15 | - name: Build 16 | uses: ./.github/actions/build 17 | 18 | - name: Deploy 19 | uses: crazy-max/ghaction-github-pages@v2 20 | with: 21 | target_branch: gh-pages 22 | build_dir: packages/react-keyboard-navigator-example/dist 23 | fqdn: react-keyboard-navigator.zheeeng.me 24 | author: Zheeeng 25 | jekyll: false 26 | env: 27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 28 | -------------------------------------------------------------------------------- /packages/react-keyboard-navigator-example/pages/randomPlacement.scss: -------------------------------------------------------------------------------- 1 | .randomPlacement { 2 | padding: 24px; 3 | 4 | main { 5 | display: block; 6 | position: relative; 7 | width: 400px; 8 | height: 400px; 9 | outline: dashed black 2px; 10 | outline-offset: 20px; 11 | margin: 20px; 12 | } 13 | 14 | .block { 15 | display: block; 16 | position: absolute; 17 | width: 40px; 18 | height: 40px; 19 | margin-top: -20px; 20 | margin-left: -20px; 21 | border: 2px solid; 22 | } 23 | 24 | .comparison { 25 | display: flex; 26 | } 27 | 28 | .comparison > * { 29 | margin-right: 10px; 30 | } 31 | } -------------------------------------------------------------------------------- /packages/react-keyboard-navigator-example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-keyboard-navigator-example", 3 | "private": true, 4 | "scripts": { 5 | "dev": "vite serve", 6 | "build": "rm -rf dist && vite build --outDir dist" 7 | }, 8 | "keywords": [], 9 | "author": "", 10 | "license": "ISC", 11 | "dependencies": { 12 | "react": "^18.2.0", 13 | "react-dom": "^18.2.0", 14 | "react-keyboard-navigator": "workspace:*", 15 | "react-router-dom": "^6.14.2", 16 | "scroll-into-view-if-needed": "^3.0.10", 17 | "styled-css-base": "^0.0.12" 18 | }, 19 | "devDependencies": { 20 | "@types/node": "^18.17.0", 21 | "@types/react": "^17.0.62", 22 | "@vitejs/plugin-react": "^4.0.3", 23 | "sass": "^1.64.1", 24 | "serve": "^14.2.0", 25 | "vite": "^4.4.7", 26 | "vite-pages-theme-doc": "^4.1.6", 27 | "vite-plugin-react-pages": "^4.1.4" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /packages/react-keyboard-navigator/src/helpers/EventCallbackPresets.ts: -------------------------------------------------------------------------------- 1 | export const EventCallbackPresets = { 2 | preventDefault: (evt: KeyboardEvent) => { 3 | evt.preventDefault() 4 | }, 5 | stopPropagation: (evt: KeyboardEvent) => { 6 | evt.stopPropagation() 7 | }, 8 | stopImmediatePropagation: (evt: KeyboardEvent) => { 9 | evt.stopImmediatePropagation() 10 | }, 11 | stopOnActiveInputElement: () => { 12 | if (document.activeElement instanceof HTMLInputElement || document.activeElement instanceof HTMLTextAreaElement) { 13 | return false 14 | } 15 | }, 16 | stopOnActiveInputElementAndPreventDefault: (evt: KeyboardEvent) => { 17 | if (document.activeElement instanceof HTMLInputElement || document.activeElement instanceof HTMLTextAreaElement) { 18 | return false 19 | } else { 20 | evt.preventDefault() 21 | } 22 | }, 23 | } 24 | -------------------------------------------------------------------------------- /.github/actions/build/action.yml: -------------------------------------------------------------------------------- 1 | name: Build PNPM Project 2 | description: Install PNPM dependencies and execute build scripts 3 | inputs: 4 | node-version: 5 | description: 'Specify the node version, defaults to 14.x' 6 | default: '16.x' 7 | runs: 8 | using: composite 9 | steps: 10 | - name: Setup Node 11 | uses: actions/setup-node@v2 12 | with: 13 | node-version: ${{ inputs.node-version }} 14 | 15 | - name: Cache PNPM modules 16 | uses: actions/cache@v2 17 | with: 18 | path: ~/.pnpm-store 19 | key: ${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} 20 | restore-keys: | 21 | ${{ runner.os }}- 22 | 23 | - name: Setup PNPM 24 | uses: pnpm/action-setup@v2.1.0 25 | with: 26 | version: 6 27 | run_install: true 28 | - name: Build 29 | run: pnpm build 30 | shell: bash 31 | 32 | - name: Lint 33 | run: pnpm lint 34 | shell: bash 35 | 36 | - name: TEST 37 | run: pnpm test 38 | shell: bash 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Zheeeng 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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "Zheeeng ", 3 | "private": false, 4 | "scripts": { 5 | "test": "vitest", 6 | "dev": "npm-run-all --parallel dev:*", 7 | "dev:lib": "pnpm --filter react-keyboard-navigator dev", 8 | "dev:example": "pnpm --filter react-keyboard-navigator-example dev", 9 | "build": "pnpm build:lib && pnpm build:example", 10 | "build:lib": "pnpm --filter react-keyboard-navigator build", 11 | "build:example": "pnpm --filter react-keyboard-navigator-example build", 12 | "lint": "eslint --ext .ts,.tsx .", 13 | "lint:fix": "eslint --fix --ext .ts,.tsx ." 14 | }, 15 | "devDependencies": { 16 | "@mdx-js/react": "^2.3.0", 17 | "@types/node": "^18.17.0", 18 | "@typescript-eslint/eslint-plugin": "^5.62.0", 19 | "@typescript-eslint/parser": "^5.62.0", 20 | "eslint": "^8.45.0", 21 | "eslint-plugin-promise": "^6.1.1", 22 | "eslint-plugin-react": "^7.33.0", 23 | "eslint-plugin-react-hooks": "^4.6.0", 24 | "jsdom": "^22.1.0", 25 | "npm-run-all": "^4.1.5", 26 | "pnpm": "^8.6.10", 27 | "typescript": "^5.1.6", 28 | "vitest": "^0.33.0" 29 | }, 30 | "engines": { 31 | "node": ">=14" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/react-keyboard-navigator/src/utils/groupPointsIntoZones.ts: -------------------------------------------------------------------------------- 1 | type PointWithPosition

= [point: P, angle: number, distance: number] 2 | 3 | type Point = { x: number, y: number } 4 | 5 | export function groupPointsIntoZones

(centerPoint: P, otherPoints: P[], zoneCount: number) { 6 | const { x: cx, y: cy } = centerPoint 7 | 8 | const otherPointsWithAngle: PointWithPosition

[] = otherPoints 9 | .map<[point: P, vx: number, vy: number]>(p => ([p, p.x - cx, p.y - cy])) 10 | // offset and magnitude angleDegree value 11 | .map<[point: P, angleDegree: number, distance: number]>(([p, vx, vy]) => ([ 12 | p, 13 | zoneCount * (Math.round((Math.atan2(vy, vx) * 360 / Math.PI + 900) * 10) / 10 % 720), 14 | Math.sqrt(vx * vx + vy * vy) 15 | ])) 16 | .filter(([,, distance]) => distance > 0) 17 | 18 | return (zoneNumber: number) => otherPointsWithAngle 19 | .filter(([, angleDegree]) => angleDegree >= (720 * zoneNumber - 720) && angleDegree < 720 * zoneNumber) 20 | // restore degree value 21 | .map>(([p, angleDegree, distance]) => ([p, angleDegree / zoneCount / 2, distance])) 22 | } 23 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | parserOptions: { 5 | ecmaFeatures: { 6 | jsx: true, 7 | }, 8 | ecmaVersion: 'latest', 9 | sourceType: 'module', 10 | tsconfigRootDir: __dirname, 11 | project: ['./tsconfig.json'], 12 | }, 13 | env: { 14 | browser: true, 15 | node: true, 16 | }, 17 | ignorePatterns: ['**/node_modules', "*.spec.ts", '**/dist', '*.js'], 18 | extends: [ 19 | 'eslint:recommended', 20 | 'plugin:react/recommended', 21 | 'plugin:react/jsx-runtime', 22 | 'plugin:@typescript-eslint/recommended', 23 | 'plugin:promise/recommended', 24 | 'plugin:react-hooks/recommended', 25 | 'plugin:@typescript-eslint/recommended-requiring-type-checking', 26 | ], 27 | plugins: ['react', '@typescript-eslint'], 28 | settings: { 29 | react: { 30 | createClass: 'createReactClass', 31 | pragma: 'React', 32 | fragment: 'Fragment', 33 | version: '16.8', 34 | }, 35 | }, 36 | rules: { 37 | quotes: ['error', 'single'], 38 | 'react/prop-types': 'off', 39 | '@typescript-eslint/no-unused-vars': [ 40 | 'error', 41 | { 42 | varsIgnorePattern: '^_', 43 | }, 44 | ], 45 | '@typescript-eslint/semi': ['error', 'never'], 46 | }, 47 | } 48 | -------------------------------------------------------------------------------- /packages/react-keyboard-navigator/src/utils/calculatePositionPoint.ts: -------------------------------------------------------------------------------- 1 | export type Rectangle = { x: number, y: number, width: number, height: number } 2 | 3 | export type Position = 4 | | 'center' 5 | | 'left-edge' | 'right-edge' | 'top-edge' | 'bottom-edge' 6 | | 'top-right-corner' | 'top-left-corner' | 'bottom-right-corner' | 'bottom-left-corner' 7 | 8 | export const calculatePositionPoint = ({ x, y, width, height }: Rectangle, position: Position): [x: number, y: number] => { 9 | switch (position) { 10 | case 'center': { 11 | return [x + width / 2, y + height / 2] 12 | } 13 | case 'left-edge': { 14 | return [x, y + height / 2] 15 | } 16 | case 'right-edge': { 17 | return [x + width, y + height / 2] 18 | } 19 | case 'top-edge': { 20 | return [x + width / 2, y] 21 | } 22 | case 'bottom-edge': { 23 | return [x + width / 2, y + height] 24 | } 25 | case 'top-right-corner': { 26 | return [x + width, y] 27 | } 28 | case 'top-left-corner': { 29 | return [x, y] 30 | } 31 | case 'bottom-right-corner': { 32 | return [x + width, y + height] 33 | } 34 | case 'bottom-left-corner': { 35 | return [x, y + height] 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /packages/react-keyboard-navigator/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-keyboard-navigator", 3 | "version": "0.1.3", 4 | "author": "Zheeeng ", 5 | "description": "A suite of React components and hook for selecting sibling components through the keyboard.", 6 | "keywords": [ 7 | "react", 8 | "component", 9 | "hook", 10 | "typescript", 11 | "keyboard", 12 | "navigator", 13 | "arrowKeys", 14 | "direction", 15 | "vite" 16 | ], 17 | "repository": "zheeeng/react-keyboard-navigator", 18 | "license": "MIT", 19 | "main": "dist/index.js", 20 | "module": "dist/index.mjs", 21 | "types": "dist/index.d.ts", 22 | "exports": { 23 | ".": { 24 | "require": "./dist/index.js", 25 | "types": "./dist/index.d.ts", 26 | "default": "./dist/index.mjs" 27 | }, 28 | "./README.md": "./README.md", 29 | "./package.json": "./package.json", 30 | "./*": "./dist/*" 31 | }, 32 | "files": [ 33 | "dist" 34 | ], 35 | "scripts": { 36 | "dev": "pnpm build --watch", 37 | "build": "tsup src/index.ts --format cjs,esm --dts --clean" 38 | }, 39 | "devDependencies": { 40 | "@types/react": "^18.2.16", 41 | "tsup": "^6.7.0" 42 | }, 43 | "peerDependencies": { 44 | "react": "^16.8.0 || ^17.0.0 || ^18.0.0" 45 | }, 46 | "dependencies": { 47 | "react": "^18.2.0" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /packages/react-keyboard-navigator/src/helpers/DirectionMapPresets.ts: -------------------------------------------------------------------------------- 1 | import { StrategiesHelper } from './StrategiesHelper' 2 | import { objectMap } from '../utils/helper' 3 | import { DirectionKeyMap } from '../types/type' 4 | 5 | const ArrowDirectionKeyMap: DirectionKeyMap = { 6 | UP: 'ArrowUp', 7 | DOWN: 'ArrowDown', 8 | LEFT: 'ArrowLeft', 9 | RIGHT: 'ArrowRight', 10 | } 11 | 12 | const WASDDirectionKeyMap: DirectionKeyMap = { 13 | UP: 'W', 14 | DOWN: 'S', 15 | LEFT: 'A', 16 | RIGHT: 'D', 17 | } 18 | 19 | const IJKLDirectionKeyMap: DirectionKeyMap = { 20 | UP: 'I', 21 | DOWN: 'k', 22 | LEFT: 'J', 23 | RIGHT: 'L', 24 | } 25 | 26 | const HJKLDirectionKeyMap: DirectionKeyMap = { 27 | UP: 'K', 28 | DOWN: 'J', 29 | LEFT: 'H', 30 | RIGHT: 'L', 31 | } 32 | 33 | const NumPadDirectionKeyMap: DirectionKeyMap = { 34 | UP_LEFT: '7', 35 | UP: '8', 36 | UP_RIGHT: '9', 37 | LEFT: '4', 38 | RIGHT: '6', 39 | DOWN_LEFT: '1', 40 | DOWN: '2', 41 | DOWN_RIGHT: '3', 42 | } 43 | 44 | export const DirectionMapPresets = { 45 | ArrowDirectionMap: objectMap(StrategiesHelper, helper => helper(ArrowDirectionKeyMap)), 46 | WASDDirectionMap: objectMap(StrategiesHelper, helper => helper(WASDDirectionKeyMap)), 47 | IJKLDirectionMap: objectMap(StrategiesHelper, helper => helper(IJKLDirectionKeyMap)), 48 | HJKLDirectionMap: objectMap(StrategiesHelper, helper => helper(HJKLDirectionKeyMap)), 49 | NumPadDirectionMap: objectMap(StrategiesHelper, helper => helper(NumPadDirectionKeyMap)), 50 | } 51 | -------------------------------------------------------------------------------- /packages/react-keyboard-navigator-example/pages/interestGallery.scss: -------------------------------------------------------------------------------- 1 | .interestGallery { 2 | main { 3 | width: 100%; 4 | display: flex; 5 | flex-wrap: wrap; 6 | } 7 | 8 | section { 9 | margin: 10px; 10 | width: 100%; 11 | } 12 | 13 | figure { 14 | position: relative; 15 | margin: 20px; 16 | border-radius: 1rem; 17 | transition: box-shadow 100ms ease-out; 18 | cursor: pointer; 19 | } 20 | 21 | figure:hover { 22 | box-shadow: 0 0 20px rgba(0, 0, 0, 0.8); 23 | transform: scale(1.02); 24 | } 25 | 26 | figure.active { 27 | box-shadow: 0 0 20px rgba(255, 0, 0, 0.8); 28 | transform: scale(1.02); 29 | } 30 | 31 | figure img { 32 | width: 200px !important; 33 | height: 200px !important; 34 | max-width: unset !important; 35 | max-height: unset !important; 36 | } 37 | 38 | figcaption { 39 | position: absolute; 40 | top: 50%; 41 | left: 0; 42 | width: 100%; 43 | font-size: 1.25em; 44 | display: flex; 45 | justify-content: center; 46 | align-items: center; 47 | background-color: rgba(255, 255, 255, 0.8); 48 | } 49 | 50 | figcaption div.text { 51 | color: gray; 52 | } 53 | 54 | figcaption div.closer { 55 | margin-left: 0.5em; 56 | cursor: pointer; 57 | width: 1.5em; 58 | height: 1.5em; 59 | display: flex; 60 | align-items: center; 61 | justify-content: center; 62 | } 63 | } -------------------------------------------------------------------------------- /packages/react-keyboard-navigator/src/utils/__tests__/calculatePositionPoint.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect, beforeAll, afterAll, vi, type SpyInstance } from 'vitest' 2 | 3 | /** 4 | * @vitest-environment jsdom 5 | */ 6 | 7 | import { calculatePositionPoint } from '../calculatePositionPoint' 8 | 9 | describe('calculatePositionPoint gets a tuple of center points [x, y]', () => { 10 | let element: HTMLDivElement 11 | let spy: SpyInstance<[], DOMRect> 12 | 13 | beforeAll(() => { 14 | element = document.createElement('div') 15 | 16 | spy = vi.spyOn(element, 'getBoundingClientRect').mockReturnValue({ 17 | bottom: 60, 18 | height: 60, 19 | left: 60, 20 | right: 60, 21 | top: 60, 22 | width: 60, 23 | x: 60, 24 | y: 60, 25 | toJSON() { 26 | /** pass **/ 27 | } 28 | }) 29 | }) 30 | 31 | afterAll(() => { 32 | spy.mockClear() 33 | }) 34 | 35 | test('getting specific position point', () => { 36 | expect( 37 | calculatePositionPoint(element.getBoundingClientRect(), 'center') 38 | ).toEqual([90, 90]) 39 | expect( 40 | calculatePositionPoint(element.getBoundingClientRect(), 'left-edge') 41 | ).toEqual([60, 90]) 42 | expect( 43 | calculatePositionPoint(element.getBoundingClientRect(), 'right-edge') 44 | ).toEqual([120, 90]) 45 | expect( 46 | calculatePositionPoint(element.getBoundingClientRect(), 'top-edge') 47 | ).toEqual([90, 60]) 48 | expect( 49 | calculatePositionPoint(element.getBoundingClientRect(), 'bottom-edge') 50 | ).toEqual([90, 120]) 51 | expect( 52 | calculatePositionPoint(element.getBoundingClientRect(), 'top-left-corner') 53 | ).toEqual([60, 60]) 54 | expect( 55 | calculatePositionPoint(element.getBoundingClientRect(), 'top-right-corner') 56 | ).toEqual([120, 60]) 57 | expect( 58 | calculatePositionPoint(element.getBoundingClientRect(), 'bottom-left-corner') 59 | ).toEqual([60, 120]) 60 | expect( 61 | calculatePositionPoint(element.getBoundingClientRect(), 'bottom-right-corner') 62 | ).toEqual([120, 120]) 63 | }) 64 | }) 65 | -------------------------------------------------------------------------------- /packages/react-keyboard-navigator/src/utils/__tests__/groupPointsIntoZones.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from 'vitest' 2 | import { groupPointsIntoZones } from '../groupPointsIntoZones' 3 | import { testPoints, testPointsList } from './points.data' 4 | 5 | describe('groupPointsIntoZones passes the basic cases', () => { 6 | 7 | function extractEachFirstElement (items: T[]): T[0][] { 8 | return items.map(([first]) => first) 9 | } 10 | 11 | const expectedOutput = [ 12 | [testPoints.zone1StartPoint], [testPoints.zone2StartPoint], [testPoints.zone3StartPoint], [testPoints.zone4StartPoint], 13 | [testPoints.zone5StartPoint], [testPoints.zone6StartPoint], [testPoints.zone7StartPoint], [testPoints.zone8StartPoint], 14 | [testPoints.zone9StartPoint], [testPoints.zone10StartPoint], [testPoints.zone11StartPoint], [testPoints.zone12StartPoint], 15 | [testPoints.zone13StartPoint], [testPoints.zone14StartPoint], [testPoints.zone15StartPoint], [testPoints.zone16StartPoint], 16 | ] 17 | 18 | test('divide points into 16 group correctly', () => { 19 | const getZone = groupPointsIntoZones( 20 | testPoints.centerPoint, 21 | testPointsList, 22 | 16, 23 | ) 24 | 25 | const testInput = [ 26 | getZone(1), getZone(2), getZone(3), getZone(4), 27 | getZone(5), getZone(6), getZone(7), getZone(8), 28 | getZone(9), getZone(10), getZone(11), getZone(12), 29 | getZone(13), getZone(14), getZone(15), getZone(16), 30 | ] 31 | 32 | expect(testInput.map(extractEachFirstElement)).toEqual(expectedOutput) 33 | }) 34 | 35 | test('outputting points exclude itself', () => { 36 | const getZone = groupPointsIntoZones( 37 | testPoints.centerPoint, 38 | [testPoints.centerPoint, ...testPointsList], 39 | 16, 40 | ) 41 | 42 | const testInput = [ 43 | getZone(1), getZone(2), getZone(3), getZone(4), 44 | getZone(5), getZone(6), getZone(7), getZone(8), 45 | getZone(9), getZone(10), getZone(11), getZone(12), 46 | getZone(13), getZone(14), getZone(15), getZone(16), 47 | ] 48 | 49 | expect(testInput.map(extractEachFirstElement)).toEqual(expectedOutput) 50 | 51 | }) 52 | }) 53 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | schedule: 21 | - cron: '17 21 * * 5' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v2 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v1 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 52 | 53 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 54 | # If this step fails, then you should remove it and run the build manually (see below) 55 | - name: Autobuild 56 | uses: github/codeql-action/autobuild@v1 57 | 58 | # ℹ️ Command-line programs to run using the OS shell. 59 | # 📚 https://git.io/JvXDl 60 | 61 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 62 | # and modify them (or add more) to build your code if your project 63 | # uses a compiled language 64 | 65 | #- run: | 66 | # make bootstrap 67 | # make release 68 | 69 | - name: Perform CodeQL Analysis 70 | uses: github/codeql-action/analyze@v1 71 | -------------------------------------------------------------------------------- /packages/react-keyboard-navigator/src/helpers/StrategiesHelper.ts: -------------------------------------------------------------------------------- 1 | import { DirectionDetails, DirectionKeyMap, DirectionMap, KeyboardDirection, DistanceStrategy } from '../types/type' 2 | import { isObject, objectMap } from '../utils/helper' 3 | 4 | function isDirectionDetails (testItem: unknown): testItem is DirectionDetails { 5 | if (!isObject(testItem)) { 6 | return false 7 | } 8 | 9 | if (!('key' in testItem) || typeof testItem?.key != 'string') { 10 | return false 11 | } 12 | 13 | if (!('strategy' in testItem) || typeof testItem.strategy !== 'string' && typeof testItem.strategy !== 'function') { 14 | return false 15 | } 16 | 17 | return true 18 | } 19 | 20 | const preferredStrategy = 'Secant' 21 | 22 | function normalizeOption (directionMap: DirectionKeyMap | DirectionMap, strategy: (direction: KeyboardDirection, originStrategy?: DistanceStrategy) => DistanceStrategy | undefined): DirectionMap { 23 | return objectMap(directionMap, (keyOrDirectionDetails, direction) => { 24 | if (typeof keyOrDirectionDetails === 'string') { 25 | return { 26 | key: keyOrDirectionDetails, 27 | strategy: strategy(direction) ?? preferredStrategy, 28 | } 29 | } else if (isDirectionDetails(keyOrDirectionDetails)) { 30 | return { 31 | key: keyOrDirectionDetails.key, 32 | strategy: strategy(direction, keyOrDirectionDetails.strategy) ?? keyOrDirectionDetails.strategy, 33 | } 34 | } else { 35 | return undefined 36 | } 37 | }) 38 | } 39 | 40 | export const StrategiesHelper = { 41 | distance: (directionMap: DirectionKeyMap | DirectionMap, keepOrigin = false): DirectionMap => { 42 | return normalizeOption(directionMap, (_, originStrategy) => keepOrigin ? originStrategy ?? 'Distance' : 'Distance') 43 | }, 44 | secant: (directionMap: DirectionKeyMap | DirectionMap, keepOrigin = false): DirectionMap => { 45 | return normalizeOption(directionMap, (_, originStrategy) => keepOrigin ? originStrategy ?? 'Secant' : 'Secant') 46 | }, 47 | cosine: (directionMap: DirectionKeyMap | DirectionMap, keepOrigin = false): DirectionMap => { 48 | return normalizeOption(directionMap, (_, originStrategy) => keepOrigin ? originStrategy ?? 'Cosine' : 'Cosine') 49 | }, 50 | sine: (directionMap: DirectionKeyMap | DirectionMap, keepOrigin = false): DirectionMap => { 51 | return normalizeOption(directionMap, (_, originStrategy) => keepOrigin ? originStrategy ?? 'Sine' : 'Sine') 52 | }, 53 | tangent: (directionMap: DirectionKeyMap | DirectionMap, keepOrigin = false): DirectionMap => { 54 | return normalizeOption(directionMap, (_, originStrategy) => keepOrigin ? originStrategy ?? 'Tangent' : 'Tangent') 55 | }, 56 | } 57 | -------------------------------------------------------------------------------- /packages/react-keyboard-navigator/src/utils/__tests__/mod.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from 'vitest' 2 | import { mod } from '../mod' 3 | 4 | describe('mod compacts in a numbers loop with specify lowerBound(inclusively) and upperBound(inclusively)', () => { 5 | test('numbers are one of the lowerBound or upperBound', () => { 6 | expect(mod(0, 0, 6)).toBe(0) 7 | expect(mod(1, 0, 6)).toBe(1) 8 | expect(mod(2, 0, 6)).toBe(2) 9 | expect(mod(3, 0, 6)).toBe(3) 10 | expect(mod(4, 0, 6)).toBe(4) 11 | expect(mod(5, 0, 6)).toBe(5) 12 | expect(mod(6, 0, 6)).toBe(6) 13 | }) 14 | 15 | test('numbers in between lowerBound and upperBound', () => { 16 | expect(mod(6, 2, 8)).toBe(6) 17 | expect(mod(6, 5, 7)).toBe(6) 18 | }) 19 | 20 | test('numbers are smaller than the lowerBound and upperBound', () => { 21 | expect(mod(1, 2, 8)).toBe(8) 22 | expect(mod(4, 5, 7)).toBe(7) 23 | expect(mod(3, 5, 7)).toBe(6) 24 | expect(mod(2, 5, 7)).toBe(5) 25 | expect(mod(1, 5, 7)).toBe(7) 26 | }) 27 | 28 | test('numbers are larger than the lowerBound and upperBound', () => { 29 | expect(mod(9, 2, 8)).toBe(2) 30 | expect(mod(8, 5, 7)).toBe(5) 31 | expect(mod(9, 5, 7)).toBe(6) 32 | expect(mod(10, 5, 7)).toBe(7) 33 | expect(mod(11, 5, 7)).toBe(5) 34 | 35 | }) 36 | 37 | test('mod is ok with misplace the lowerBound and upperBound', () => { 38 | expect(mod(6, 8, 2)).toBe(6) 39 | expect(mod(6, 5, 7)).toBe(6) 40 | expect(mod(1, 8, 2)).toBe(8) 41 | expect(mod(4, 7, 5)).toBe(7) 42 | expect(mod(3, 7, 5)).toBe(6) 43 | expect(mod(2, 7, 5)).toBe(5) 44 | expect(mod(1, 7, 5)).toBe(7) 45 | expect(mod(9, 8, 2)).toBe(2) 46 | expect(mod(8, 7, 5)).toBe(5) 47 | expect(mod(9, 7, 5)).toBe(6) 48 | expect(mod(10, 7, 5)).toBe(7) 49 | expect(mod(11, 7, 5)).toBe(5) 50 | }) 51 | 52 | test('mod handlers negative input correctly', () => { 53 | expect(mod(-1, 5, 7)).toBe(5) 54 | expect(mod(-2, 5, 7)).toBe(7) 55 | expect(mod(-3, 5, 7)).toBe(6) 56 | expect(mod(-4, 5, 7)).toBe(5) 57 | expect(mod(-5, 5, 7)).toBe(7) 58 | }) 59 | 60 | test('mod handlers negative range correctly', () => { 61 | expect(mod(1, -5, -7)).toBe(-5) 62 | expect(mod(2, -5, -7)).toBe(-7) 63 | expect(mod(3, -5, -7)).toBe(-6) 64 | expect(mod(4, -5, -7)).toBe(-5) 65 | expect(mod(5, -5, -7)).toBe(-7) 66 | }) 67 | 68 | test('mod handlers negative input and negative range correctly', () => { 69 | expect(mod(-1, -5, -7)).toBe(-7) 70 | expect(mod(-2, -5, -7)).toBe(-5) 71 | expect(mod(-3, -5, -7)).toBe(-6) 72 | expect(mod(-4, -5, -7)).toBe(-7) 73 | expect(mod(-5, -5, -7)).toBe(-5) 74 | }) 75 | }) -------------------------------------------------------------------------------- /packages/react-keyboard-navigator-example/pages/interestGallery$.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @title Interest Gallery 3 | * @order 3 4 | */ 5 | import { useState, useMemo } from 'react' 6 | import { KeyboardNavigatorBoard, KeyboardNavigatorElement, useKeyboardNavigator } from 'react-keyboard-navigator' 7 | import scrollIntoViewIfNeeded from 'scroll-into-view-if-needed' 8 | import './interestGallery.scss' 9 | 10 | const InterestGallery = () => { 11 | const [interests, setInterests] = useState(() => new Set([ 12 | 'city', 'nature', 'night', 'forest', 'lake', 'mountain', 'sky', 'train', 13 | 'japan', 'korea', 'china', 'india', 'thailand', 'vietnam', 'cambodia', 14 | 'book', 'movie', 'music', 'game', 'sport', 'food', 'painting', 15 | 'dog', 'cat', 'bird', 'fish', 'horse', 'cow', 16 | ])) 17 | 18 | const handleAddInterest = (e: React.KeyboardEvent) => { 19 | if (e.key === 'Enter') { 20 | const newInterest = e.currentTarget.value 21 | e.currentTarget.value = '' 22 | setInterests(new Set(interests.add(newInterest))) 23 | } 24 | } 25 | 26 | const handleRemoveInterest = (oldInterest: string) => { 27 | const ok = confirm('Are you sure you want to remove this interest?') 28 | 29 | if (ok) { 30 | setInterests(new Set((interests.delete(oldInterest), interests))) 31 | } 32 | } 33 | 34 | const [activePictureName, setActivePictureName] = useState('') 35 | 36 | const { markRef } = useKeyboardNavigator({ 37 | rootContainer: document.body, 38 | didChange: (_, toElement) => scrollIntoViewIfNeeded(toElement, { scrollMode: 'if-needed', behavior: 'smooth', block: 'nearest' }) 39 | }) 40 | 41 | const sortedInterests = useMemo(() => Array.from(interests.values()).sort((a, b) => a.localeCompare(b)), [interests]) 42 | 43 | return ( 44 |

45 | 46 |
47 |
48 | 52 |
53 |
54 | 55 | {sortedInterests.map(interest => ( 56 | setActivePictureName(interest) } 60 | active={interest === activePictureName} onActiveChange={() => setActivePictureName(interest)} 61 | > 62 | {interest}/ 63 |
64 |
{interest}
65 |
handleRemoveInterest(interest)}>❌
66 |
67 |
68 | ))} 69 |
70 |
71 | ) 72 | } 73 | 74 | export default InterestGallery 75 | -------------------------------------------------------------------------------- /packages/react-keyboard-navigator/src/utils/__tests__/points.data.ts: -------------------------------------------------------------------------------- 1 | function genPoint(centerPoint: { x: number, y: number }, degree: number, length: number, label: string) { 2 | const radian = Math.PI * degree / 180 3 | const x = centerPoint.x + length * Math.sin(radian) 4 | const y = centerPoint.y - length * Math.cos(radian) 5 | return { x, y, label } 6 | } 7 | 8 | /** 9 | * @description group points to 16 directions by its axis position 10 | *|----|-------------------------------------------------|---| 11 | *| | zone 15 | zone 16 | zone 1 | zone 2 | | 12 | *|----|-------------------------------------------------|---| 13 | *| z | #...........#..........#..........#...........# | z | 14 | *| o | ..#..........#.........#.........#..........#.. | o | 15 | *| n | ....#.........#........#........#.........#.... | n | 16 | *| e | ......#........#.......#.......#........#...... | e | 17 | *| 14 | ........#.......#......#......#.......#........ | 3 | 18 | *|----| ..........#......#.....#.....#......#.......... |---| 19 | *| z | #...........#.....#....#....#.....#...........# | z | 20 | *| o | ....#.........#....#...#...#....#.........#.... | o | 21 | *| n | ........#.......#...#..#..#...#.......#........ | n | 22 | *| e | ............#.....#..#.#.#..#.....#............ | e | 23 | *| 13 | ................#...#..#..#...#................ | 4 | 24 | *|----| ############################################### |---| 25 | *| z | ................#...#..#..#...#................ | z | 26 | *| o | ............#.....#..#.#.#..#.....#............ | o | 27 | *| n | ........#.......#...#..#..#...#.......#........ | n | 28 | *| e | ....#.........#....#...#...#....#.........#.... | e | 29 | *| 12 | #...........#.....#....#....#.....#...........# | 5 | 30 | *|----| ..........#......#.....#.....#......#.......... |---| 31 | *| z | ........#.......#......#......#.......#........ | z | 32 | *| o | ......#........#.......#.......#........#...... | o | 33 | *| n | ....#.........#........#........#.........#.... | n | 34 | *| e | ..#..........#.........#.........#..........#.. | e | 35 | *| 11 | #...........#..........#..........#...........# | 6 | 36 | *|----|-------------------------------------------------|---| 37 | *| | zone 10 | zone 9 | zone 8 | zone 7 | | 38 | *|----|-------------------------------------------------|---| 39 | */ 40 | export const testPoints = { 41 | centerPoint: { x: 200, y: 200 }, 42 | zone1StartPoint: genPoint({ x: 200, y: 200 }, 22.5 * 0 + 1, 100, 'z1'), 43 | zone2StartPoint: genPoint({ x: 200, y: 200 }, 22.5 * 1 + 1, 99, 'z2'), 44 | zone3StartPoint: genPoint({ x: 200, y: 200 }, 22.5 * 2 + 1, 98, 'z3'), 45 | zone4StartPoint: genPoint({ x: 200, y: 200 }, 22.5 * 3 + 1, 97, 'z4'), 46 | zone5StartPoint: genPoint({ x: 200, y: 200 }, 22.5 * 4 + 1, 96, 'z5'), 47 | zone6StartPoint: genPoint({ x: 200, y: 200 }, 22.5 * 5 + 1, 95, 'z6'), 48 | zone7StartPoint: genPoint({ x: 200, y: 200 }, 22.5 * 6 + 1, 94, 'z7'), 49 | zone8StartPoint: genPoint({ x: 200, y: 200 }, 22.5 * 7 + 1, 93, 'z8'), 50 | zone9StartPoint: genPoint({ x: 200, y: 200 }, 22.5 * 8 + 1, 92, 'z9'), 51 | zone10StartPoint: genPoint({ x: 200, y: 200 }, 22.5 * 9 + 1, 91, 'z10'), 52 | zone11StartPoint: genPoint({ x: 200, y: 200 }, 22.5 * 10 + 1, 90, 'z11'), 53 | zone12StartPoint: genPoint({ x: 200, y: 200 }, 22.5 * 11 + 1, 89, 'z12'), 54 | zone13StartPoint: genPoint({ x: 200, y: 200 }, 22.5 * 12 + 1, 88, 'z13'), 55 | zone14StartPoint: genPoint({ x: 200, y: 200 }, 22.5 * 13 + 1, 87, 'z14'), 56 | zone15StartPoint: genPoint({ x: 200, y: 200 }, 22.5 * 14 + 1, 86, 'z15'), 57 | zone16StartPoint: genPoint({ x: 200, y: 200 }, 22.5 * 15 + 1, 85, 'z16'), 58 | } 59 | 60 | export const testPointsList = [ 61 | testPoints.zone1StartPoint, testPoints.zone2StartPoint, testPoints.zone3StartPoint, testPoints.zone4StartPoint, 62 | testPoints.zone5StartPoint, testPoints.zone6StartPoint, testPoints.zone7StartPoint, testPoints.zone8StartPoint, 63 | testPoints.zone9StartPoint, testPoints.zone10StartPoint, testPoints.zone11StartPoint, testPoints.zone12StartPoint, 64 | testPoints.zone13StartPoint, testPoints.zone14StartPoint, testPoints.zone15StartPoint, testPoints.zone16StartPoint, 65 | ] 66 | -------------------------------------------------------------------------------- /packages/react-keyboard-navigator-example/pages/randomPlacement$.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @title Random Placement 3 | * @order 1 4 | */ 5 | import { useState } from 'react' 6 | import { KeyboardNavigatorBoard, KeyboardNavigatorElement, useKeyboardNavigator } from 'react-keyboard-navigator' 7 | import './randomPlacement.scss' 8 | 9 | const textSegments = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.'.split(' ') 10 | 11 | const genRandom = (max: number) => Math.floor(Math.random() * max) 12 | const genRandomCard = () => ({ 13 | key: genRandom(1000000), 14 | text: textSegments[genRandom(textSegments.length)], 15 | x: `${genRandom(100)}%`, y: `${genRandom(100)}%`, 16 | }) 17 | 18 | type Props = { 19 | blocks: { 20 | key: number; 21 | text: string | undefined; 22 | x: string; 23 | y: string; 24 | }[] 25 | } 26 | 27 | const Controlled = ({ blocks }: Props) => { 28 | const { markRef } = useKeyboardNavigator({ 29 | eventCallback: evt => evt.preventDefault() 30 | }) 31 | 32 | const [highlightBlockIndex, setHighlightBockIndex] = useState(0) 33 | 34 | const [boardActive, setBoardActive] = useState(true) 35 | 36 | return ( 37 |
38 |
setBoardActive(!boardActive)} style={{ cursor: 'pointer' }}>Active controlled: {boardActive ? '✅' : '❌'}
39 |
40 | 44 | {blocks.map((word, index) => ( 45 | setHighlightBockIndex(index)} 49 | markRef={markRef} active={index === highlightBlockIndex} onActiveChange={() => setHighlightBockIndex(index)} 50 | > 51 | {word.text}{' '} 52 | 53 | ))} 54 | 55 |
56 | ) 57 | } 58 | 59 | const Auto = ({ blocks }: Props) => { 60 | const { markRef } = useKeyboardNavigator() 61 | 62 | const [highlightBlockIndex, setHighlightBockIndex] = useState(0) 63 | 64 | return ( 65 |
66 |
AutoActive: click or focus this zone actives this board automatically
67 |
68 | 73 | {blocks.map((word, index) => ( 74 | setHighlightBockIndex(index)} 78 | markRef={markRef} active={index === highlightBlockIndex} onActiveChange={() => setHighlightBockIndex(index)} 79 | > 80 | {word.text}{' '} 81 | 82 | ))} 83 | 84 |
85 | ) 86 | } 87 | 88 | const RandomPlacement = () => { 89 | 90 | const [blocks, setBlocks] = useState( 91 | () => Array.from({ length: genRandom(10) + 5 }) 92 | .map(() => genRandomCard()) 93 | ) 94 | 95 | const handleAddBlock = () => { 96 | setBlocks(blocks.concat(genRandomCard())) 97 | } 98 | 99 | const handleDeleteBlock = () => { 100 | setBlocks(blocks.slice(0, blocks.length - 1)) 101 | } 102 | 103 | return ( 104 |
105 |
106 | 107 | 108 |
109 |
110 |
111 | 112 | 113 |
114 |
115 | ) 116 | } 117 | 118 | export default RandomPlacement -------------------------------------------------------------------------------- /packages/react-keyboard-navigator/src/ReactKeyboardNavigator.tsx: -------------------------------------------------------------------------------- 1 | import React, { useImperativeHandle, useEffect, useState, useMemo } from 'react' 2 | import { useEvent } from './hooks/useEvent' 3 | import { ActiveAction, RegistrySymbol, useElementRegister, useBoardRegister } from './useKeyboardNavigator' 4 | import { useValueGetter } from './hooks/useValueGetter' 5 | import { useNextTickCallback } from './hooks/useNextTickCallback' 6 | 7 | type Props = Omit, keyof P> & { as: T } & Omit 8 | 9 | type Component

= { 10 | (props: Props): JSX.Element | null 11 | displayName?: string 12 | } 13 | 14 | export type KeyboardNavigatorElementProps = { 15 | markRef: RegistrySymbol, 16 | active?: boolean, 17 | onActiveChange?: (active: boolean) => void, 18 | as: React.ElementType, 19 | } 20 | 21 | export const KeyboardNavigatorElement = React.memo(React.forwardRef( 22 | function KeyboardNavigatorElement({ markRef, active = false, onActiveChange = () => {/** pass */}, as: As, ...asProps }, ref) { 23 | const activeAction = useValueGetter([active, onActiveChange]) 24 | 25 | const elementRef = useElementRegister(markRef, activeAction) 26 | 27 | useImperativeHandle(ref, () => elementRef.current) 28 | 29 | return 30 | } 31 | )) as Component 32 | 33 | KeyboardNavigatorElement.displayName = 'KeyboardNavigatorElement' 34 | 35 | export type KeyboardNavigatorBoardProps = { 36 | markRef: RegistrySymbol, 37 | as: React.ElementType, 38 | active?: boolean, 39 | // if we explicitly passed the `active` prop, it means the `active` state of KeyboardNavigatorBoard is controlled by external, the `autoActive` prop is forced to `false`. 40 | // Otherwise, the `autoActive` fallbacks to enabled. 41 | autoActive?: boolean, 42 | onAutoActiveChange?: (active: boolean) => void, 43 | // if the `autoActive` feature is enabled, the initial is used to determine the initial active state, it has the default value of `false` 44 | initialActive?: boolean, 45 | } 46 | 47 | export const KeyboardNavigatorBoard = React.memo(React.forwardRef( 48 | function KeyboardNavigatorBoard({ 49 | markRef, as: As, 50 | active, autoActive, onAutoActiveChange, initialActive = false, 51 | ...asProps 52 | }, ref) { 53 | const fixedAutoActive = useMemo(() => active !== undefined ? false : autoActive ?? true, [active, autoActive]) 54 | const [autoActivated, setAutoActivated] = useState(initialActive) 55 | 56 | const fixedActive = useMemo(() => active !== undefined ? active : autoActivated, [active, autoActivated]) 57 | 58 | const getActive = useValueGetter(fixedActive) 59 | 60 | const elementRef = useBoardRegister(markRef, getActive) 61 | 62 | const handleAutoActiveChange = useEvent( 63 | (newAutoActive: boolean) => { 64 | setAutoActivated(newAutoActive) 65 | onAutoActiveChange?.(newAutoActive) 66 | } 67 | ) 68 | 69 | useImperativeHandle(ref, () => elementRef.current) 70 | 71 | const handleActiveElement = useEvent( 72 | (htmlElement: HTMLElement) => { 73 | if (elementRef.current === htmlElement || elementRef.current?.contains(htmlElement)) { 74 | handleAutoActiveChange(true) 75 | } else { 76 | handleAutoActiveChange(false) 77 | } 78 | } 79 | ) 80 | 81 | const handleActiveElementNextTick = useNextTickCallback( 82 | () => { 83 | if (document.activeElement instanceof HTMLElement) { 84 | handleActiveElement(document.activeElement) 85 | } 86 | } 87 | ) 88 | 89 | useEffect( 90 | () => { 91 | if (!fixedAutoActive) { 92 | return 93 | } 94 | 95 | function handleActive (e: MouseEvent | FocusEvent) { 96 | const targetElement = e.target 97 | 98 | if (targetElement instanceof HTMLElement) { 99 | handleActiveElement(targetElement) 100 | } 101 | } 102 | 103 | function handleKeyDown (e: KeyboardEvent) { 104 | if (e.key === 'Tab') { 105 | return 106 | } 107 | handleActiveElementNextTick() 108 | } 109 | 110 | document.body.addEventListener('click', handleActive) 111 | document.body.addEventListener('focus', handleActive) 112 | document.body.addEventListener('keydown', handleKeyDown) 113 | 114 | 115 | return () => { 116 | document.body.removeEventListener('click', handleActive) 117 | document.body.removeEventListener('focus', handleActive) 118 | document.body.removeEventListener('keydown', handleKeyDown) 119 | } 120 | }, 121 | [elementRef, fixedAutoActive, handleActiveElement, handleActiveElementNextTick, handleAutoActiveChange], 122 | ) 123 | 124 | return 125 | } 126 | )) as Component 127 | 128 | KeyboardNavigatorBoard.displayName = 'KeyboardNavigatorBoard' 129 | -------------------------------------------------------------------------------- /packages/react-keyboard-navigator-example/pages/macOSFinder.scss: -------------------------------------------------------------------------------- 1 | .macOSFinder { 2 | background-image: url(https://9to5mac.com/wp-content/uploads/sites/6/2018/06/mojave-day.jpg); 3 | background-size: cover; 4 | background-repeat: no-repeat; 5 | padding: 72px 0; 6 | 7 | * { 8 | font-family: 'Helvetica', sans-serif; 9 | user-select: none; 10 | } 11 | i.fa-eject { 12 | position: absolute; 13 | right: 5px; 14 | 15 | padding: 5px 6px; 16 | border-radius: 50%; 17 | } 18 | i.fa-eject:hover { 19 | background-color: rgb(100, 100, 100); 20 | color: rgb(240, 240, 240) !important; 21 | } 22 | 23 | ::-webkit-resizer { 24 | z-index: 2; 25 | } 26 | ::-webkit-scrollbar { 27 | width: 10px; 28 | } 29 | ::-webkit-scrollbar-track { 30 | background-color: transparent; 31 | } 32 | ::-webkit-scrollbar-thumb { 33 | background: rgb(200, 200, 200); 34 | border-radius: 5px; 35 | } 36 | #window { 37 | width: 950px; 38 | height: 400px; 39 | margin: 50px auto; 40 | background-color: rgb(240, 240, 240); 41 | position: relative; 42 | 43 | border-radius: 5px; 44 | 45 | box-shadow: 0px 10px 20px rgba(0, 0, 0, 0.3); 46 | 47 | resize: horizontal; 48 | display: block; 49 | } 50 | #sidebar { 51 | /* Account for border */ 52 | width: 190px; 53 | height: calc(100% - 54px); 54 | 55 | background-color: rgb(240, 240, 240); 56 | border-right: 1px solid rgb(216, 216, 216); 57 | float: left; 58 | 59 | overflow: auto; 60 | } 61 | #sidebar ul { 62 | padding: 0; 63 | } 64 | #sidebar ul li { 65 | list-style-type: none; 66 | padding: 5px 30px 5px 20px; 67 | vertical-align: middle; 68 | 69 | white-space: nowrap; 70 | text-overflow: ellipsis; 71 | overflow: hidden; 72 | 73 | position: relative; 74 | outline: none; 75 | } 76 | #sidebar ul li:focus { 77 | background-color: rgb(220, 210, 210); 78 | } 79 | #sidebar ul li.header { 80 | padding-left: 10px; 81 | color: rgb(130, 130, 130); 82 | font-weight: bold; 83 | font-size: 14px; 84 | } 85 | #sidebar ul li i { 86 | font-size: 12px; 87 | margin-left: 6px; 88 | color: rgb(130, 130, 130); 89 | } 90 | #sidebar ul li i:nth-child(1) { 91 | font-size: inherit; 92 | margin-right: 10px; 93 | margin-left: 0px; 94 | } 95 | #sidebar ul li > * { 96 | vertical-align: middle; 97 | } 98 | table { 99 | border-collapse: collapse; 100 | width: 100%; 101 | 102 | margin: 0 auto; 103 | float: right; 104 | } 105 | thead, 106 | tbody { 107 | width: 100%; 108 | position: relative; 109 | } 110 | 111 | tbody tr { 112 | background-color: white; 113 | } 114 | td { 115 | padding: 5px 10px; 116 | } 117 | tbody tr:focus { 118 | background-color: rgb(65, 150, 255) !important; 119 | outline: none; 120 | } 121 | tbody tr:focus td, 122 | tr:focus .icon { 123 | color: white !important; 124 | } 125 | tbody tr:nth-child(odd) { 126 | background-color: rgb(245, 245, 245); 127 | } 128 | 129 | tbody tr td:nth-child(2), 130 | tbody tr td:nth-child(3) { 131 | color: rgb(130, 130, 130); 132 | } 133 | 134 | thead tr { 135 | text-align: left; 136 | background-color: rgb(240, 240, 240); 137 | border-bottom: 1px solid rgb(216, 216, 216); 138 | } 139 | 140 | th { 141 | font-weight: normal; 142 | padding: 5px 10px; 143 | } 144 | .th-long { 145 | width: 60%; 146 | } 147 | .th-short { 148 | width: 20%; 149 | } 150 | th:nth-child(1), 151 | th:nth-child(2) { 152 | border-right: 1px solid rgb(216, 216, 216); 153 | } 154 | 155 | .icon { 156 | color: rgb(130, 130, 130); 157 | padding-right: 10px; 158 | } 159 | 160 | img { 161 | height: 20px; 162 | padding-right: 7px; 163 | transform: translateY(-2px); 164 | } 165 | td > * { 166 | vertical-align: middle; 167 | } 168 | tr.notfolder .icon { 169 | visibility: hidden; 170 | } 171 | 172 | #menubar, 173 | #footer { 174 | width: 100%; 175 | background-image: linear-gradient( 176 | to bottom, 177 | rgb(230, 230, 230), 178 | rgb(210, 210, 210) 179 | ); 180 | 181 | border-radius: 5px 5px 0 0; 182 | 183 | display: flex; 184 | align-items: center; 185 | justify-content: center; 186 | 187 | padding: 5px 0; 188 | 189 | position: relative; 190 | 191 | border-bottom: 1px solid rgb(216, 216, 216); 192 | } 193 | 194 | .circle { 195 | display: inline-block; 196 | border-radius: 50%; 197 | height: 10px; 198 | width: 10px; 199 | margin: 0 1px; 200 | 201 | border: 1px solid; 202 | 203 | display: inline-flex; 204 | align-items: center; 205 | justify-content: center; 206 | 207 | vertical-align: middle; 208 | } 209 | .circle#red { 210 | background-color: rgb(251, 93, 82); 211 | border-color: #d64e45; 212 | } 213 | .circle#yellow { 214 | background-color: #fec63f; 215 | border-color: #d1a334; 216 | } 217 | .circle#green { 218 | background-color: #31d342; 219 | border-color: #28b037; 220 | } 221 | 222 | .circle i { 223 | font-size: 10px; 224 | opacity: 0.5; 225 | visibility: hidden; 226 | } 227 | .circle#green i { 228 | font-size: 8px; 229 | } 230 | #stoplight { 231 | position: absolute; 232 | left: 10px; 233 | } 234 | #stoplight:hover .circle i { 235 | visibility: visible; 236 | } 237 | 238 | #footer { 239 | margin: 0 auto; 240 | border-radius: 0 0 5px 5px; 241 | font-size: 12px; 242 | border-top: 1px solid rgb(216, 216, 216); 243 | border-bottom: none; 244 | 245 | position: absolute; 246 | left: 0; 247 | bottom: 0; 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /packages/react-keyboard-navigator/src/useKeyboardNavigator.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useRef, useState } from 'react' 2 | import { createOpaqueTypeConstructor } from './OpaqueType' 3 | import { useValueGetter } from './hooks/useValueGetter' 4 | import { calculatePositionPoint } from './utils/calculatePositionPoint' 5 | import { groupByDirection } from './utils/groupByDirection' 6 | import { objectMap } from './utils/helper' 7 | import { useEvent } from './hooks/useEvent' 8 | import { DirectionKeyMap, DirectionMap, KeyboardDirection } from './types/type' 9 | import { DirectionMapPresets } from './helpers/DirectionMapPresets' 10 | import { EventCallbackPresets } from './helpers/EventCallbackPresets' 11 | import { StrategiesHelper } from './helpers/StrategiesHelper' 12 | 13 | export type ActiveAction = [active: boolean, handleActiveChange: (active: boolean) => void] 14 | 15 | const createRegistrySymbol = createOpaqueTypeConstructor(() => new Object()) 16 | export type RegistrySymbol = ReturnType 17 | 18 | const elementRegistryWeakMap = new WeakMap< 19 | RegistrySymbol, 20 | { 21 | registerElement: (element: HTMLElement, getActiveAction: () => ActiveAction) => void, 22 | registerBoard: (element: HTMLElement, getActive: () => boolean) => void, 23 | } 24 | >() 25 | 26 | export type UseKeyboardNavigatorOption = { 27 | directionMap?: DirectionKeyMap | DirectionMap 28 | eventCallback?: (e: KeyboardEvent, eventInfo: { fromElement: HTMLElement, toElement: HTMLElement }) => void | false 29 | didChange?: (fromElement: HTMLElement, toElement: HTMLElement) => void 30 | rootContainer?: HTMLElement 31 | } 32 | 33 | export const useKeyboardNavigator = ({ 34 | directionMap = DirectionMapPresets.ArrowDirectionMap.secant, 35 | eventCallback = EventCallbackPresets.stopOnActiveInputElementAndPreventDefault, 36 | didChange = () => {/** pass */}, 37 | rootContainer, 38 | }: UseKeyboardNavigatorOption = {}): { 39 | markRef: RegistrySymbol, 40 | } => { 41 | const [{ elementManageBoardRegistry, activeActionRegistry }] = useState( 42 | () => ({ 43 | elementManageBoardRegistry: new Map boolean>(), 44 | activeActionRegistry: new Map ActiveAction>() 45 | }) 46 | ) 47 | 48 | const [markRef] = useState( 49 | () => { 50 | const symbol = createRegistrySymbol() 51 | 52 | elementRegistryWeakMap.set( 53 | symbol, 54 | { 55 | registerBoard: (element: HTMLElement, getActive: () => boolean) => { 56 | elementManageBoardRegistry.set(element, getActive) 57 | 58 | return () => elementManageBoardRegistry.delete(element) 59 | }, 60 | registerElement: (element: HTMLElement, getActiveAction: () => ActiveAction) => { 61 | activeActionRegistry.set(element, getActiveAction) 62 | 63 | return () => activeActionRegistry.delete(element) 64 | }, 65 | } 66 | ) 67 | 68 | return symbol 69 | } 70 | ) 71 | 72 | const getDirectionMap = useValueGetter(directionMap) 73 | 74 | const handleEventInfoDidUpdate = useEvent(didChange) 75 | 76 | useEffect( 77 | () => { 78 | function handleKeydown (e: KeyboardEvent) { 79 | const key = e.key 80 | 81 | const lookupMap = StrategiesHelper.secant(getDirectionMap(), true) 82 | 83 | const direction = (Object.keys(lookupMap) as KeyboardDirection[]).find(direction => lookupMap[direction]?.key === key) 84 | 85 | switch (direction) { 86 | case 'UP': 87 | case 'DOWN': 88 | case 'LEFT': 89 | case 'RIGHT': 90 | case 'UP_LEFT': 91 | case 'UP_RIGHT': 92 | case 'DOWN_LEFT': { 93 | break 94 | } 95 | default: { 96 | return 97 | } 98 | } 99 | 100 | const activeManagerBoard = Array 101 | .from(elementManageBoardRegistry.entries()) 102 | .filter(([, getActive]) => getActive()) 103 | .map(([element]) => element)[0] ?? rootContainer 104 | 105 | if (!activeManagerBoard) { 106 | return 107 | } 108 | 109 | const elementInfoWithPositionList = Array 110 | .from(activeActionRegistry.entries()) 111 | .filter(([element]) => activeManagerBoard.contains(element)) 112 | .map(([element, getActiveAction]) => { 113 | const [active, setActive] = getActiveAction() 114 | const [x, y] = calculatePositionPoint(element.getBoundingClientRect(), 'center') 115 | 116 | return { 117 | element, 118 | active, 119 | setActive, 120 | x, y, 121 | } 122 | }) 123 | 124 | const fromElementInfoWithPosition = elementInfoWithPositionList.find(element => element.active) 125 | 126 | if (!fromElementInfoWithPosition) { 127 | return 128 | } 129 | 130 | const toElementInfoWithPositionList = elementInfoWithPositionList.filter(element => !element.active) 131 | 132 | const targetElement = groupByDirection( 133 | objectMap(lookupMap, details => details?.strategy), 134 | fromElementInfoWithPosition, 135 | toElementInfoWithPositionList, 136 | ) 137 | 138 | const fromElement = fromElementInfoWithPosition.element 139 | const toElement = targetElement(direction)[0]?.element 140 | 141 | if (!toElement) { 142 | return 143 | } 144 | 145 | const eventInfo = { fromElement, toElement } 146 | 147 | const allowChange = eventCallback?.(e, eventInfo) 148 | 149 | if (allowChange !== false) { 150 | fromElementInfoWithPosition.setActive(false) 151 | targetElement(direction)[0]?.setActive(true) 152 | handleEventInfoDidUpdate(fromElement, toElement) 153 | } 154 | } 155 | 156 | document.body.addEventListener('keydown', handleKeydown) 157 | 158 | return () => { 159 | document.body.removeEventListener('keydown', handleKeydown) 160 | } 161 | }, 162 | [activeActionRegistry, directionMap, elementManageBoardRegistry, eventCallback, getDirectionMap, handleEventInfoDidUpdate, rootContainer], 163 | ) 164 | 165 | return { markRef } 166 | } 167 | 168 | export const useElementRegister = (markRef: RegistrySymbol, activeAction: () => ActiveAction) => { 169 | const { registerElement } = useMemo(() => elementRegistryWeakMap.get(markRef), [markRef]) ?? {} 170 | 171 | const elementRef = useRef() 172 | 173 | useEffect( 174 | () => elementRef.current ? registerElement?.(elementRef.current, activeAction) : undefined, 175 | [activeAction, elementRef, registerElement], 176 | ) 177 | 178 | return elementRef 179 | } 180 | 181 | export const useBoardRegister = (markRef: RegistrySymbol, getActive: () => boolean) => { 182 | const { registerBoard } = useMemo(() => elementRegistryWeakMap.get(markRef), [markRef]) ?? {} 183 | 184 | const elementRef = useRef() 185 | 186 | useEffect( 187 | () => elementRef.current ? registerBoard?.(elementRef.current, getActive) : undefined, 188 | [elementRef, getActive, registerBoard], 189 | ) 190 | 191 | return elementRef 192 | } 193 | -------------------------------------------------------------------------------- /packages/react-keyboard-navigator/src/utils/groupByDirection.ts: -------------------------------------------------------------------------------- 1 | import { mod } from './mod' 2 | import { groupPointsIntoZones } from './groupPointsIntoZones' 3 | import { DistanceStrategy } from '../types/type' 4 | 5 | type Point = { x: number, y: number } 6 | 7 | type PointWithPosition

= [point: P, angleDegree: number, distance: number] 8 | 9 | type Direction = 'UP' | 'DOWN' | 'LEFT' | 'RIGHT' | 'UP_LEFT' | 'UP_RIGHT' | 'DOWN_LEFT' | 'DOWN_RIGHT' 10 | 11 | function calculateDistance (distance: number, angleDegree: number, strategy: DistanceStrategy) { 12 | switch (strategy) { 13 | case 'Distance': { 14 | return distance 15 | } 16 | case 'Secant': { 17 | return Math.abs(distance / Math.cos(Math.PI * angleDegree / 180)) 18 | } 19 | case 'Cosine': { 20 | return Math.abs(distance * Math.cos(Math.PI * angleDegree / 180)) 21 | } 22 | case 'Sine': { 23 | return Math.abs(distance * Math.sin(Math.PI * angleDegree / 180)) 24 | } 25 | case 'Tangent': { 26 | return Math.abs(distance * Math.tan(Math.PI * angleDegree / 180)) 27 | } 28 | default: { 29 | if (typeof strategy === 'function') { 30 | const calculatedDistance = strategy(distance, angleDegree) 31 | 32 | if (typeof calculatedDistance === 'number') { 33 | return calculatedDistance 34 | } 35 | } 36 | 37 | throw new Error('Invalid strategy') 38 | } 39 | } 40 | } 41 | 42 | function extractSortedPoints

( 43 | pickStrategy: DistanceStrategy, 44 | directionDegree: number, 45 | pointWithPositionList: PointWithPosition

[] 46 | ) { 47 | return pointWithPositionList 48 | .map(([point, degree, distance]) => ([point, degree, distance, calculateDistance(distance, degree - directionDegree, pickStrategy)] as [point: P, degree: number, distance: number, calculatedDistance: number])) 49 | .sort( 50 | ([, degreeA, distanceA, calculatedDistanceA], [, degreeB, distanceB, calculatedDistanceB]) => 51 | calculatedDistanceA !== calculatedDistanceB 52 | ? calculatedDistanceA - calculatedDistanceB 53 | : distanceA !== distanceB 54 | ? distanceA - distanceB 55 | : degreeA - degreeB 56 | ) 57 | .map(([point]) => point) 58 | } 59 | 60 | const zoneAliases: Direction[] = [ 61 | // 0, 1, 2, 3 62 | 'UP', 'UP_RIGHT', 'RIGHT', 'DOWN_RIGHT', 63 | // 4, 5, 6, 7 64 | 'DOWN', 'DOWN_LEFT', 'LEFT', 'UP_LEFT', 65 | ] 66 | 67 | const MAX_ZONE_COUNT = 16 68 | const HALF_MAX_ZONE_COUNT = MAX_ZONE_COUNT / 2 69 | const QUARTER_MAX_ZONE_COUNT = MAX_ZONE_COUNT / 4 70 | 71 | const zoneAliasIndices = Array.from({ length: HALF_MAX_ZONE_COUNT }).map((_, index) => index) 72 | 73 | export type DirectionPickStrategies = Partial> 74 | 75 | /** 76 | * @description group points to 16 directions by its axis position 77 | *|----|-------------------------------------------------|---| 78 | *| | zone 15 | zone 16 | zone 1 | zone 2 | | 79 | *|----|-------------------------------------------------|---| 80 | *| z | #...........#..........#..........#...........# | z | 81 | *| o | ..#..........#.........#.........#..........#.. | o | 82 | *| n | ....#.........#........#........#.........#.... | n | 83 | *| e | ......#........#.......#.......#........#...... | e | 84 | *| 14 | ........#.......#......#......#.......#........ | 3 | 85 | *|----| ..........#......#.....#.....#......#.......... |---| 86 | *| z | #...........#.....#....#....#.....#...........# | z | 87 | *| o | ....#.........#....#...#...#....#.........#.... | o | 88 | *| n | ........#.......#...#..#..#...#.......#........ | n | 89 | *| e | ............#.....#..#.#.#..#.....#............ | e | 90 | *| 13 | ................#...#..#..#...#................ | 4 | 91 | *|----| ############################################### |---| 92 | *| z | ................#...#..#..#...#................ | z | 93 | *| o | ............#.....#..#.#.#..#.....#............ | o | 94 | *| n | ........#.......#...#..#..#...#.......#........ | n | 95 | *| e | ....#.........#....#...#...#....#.........#.... | e | 96 | *| 12 | #...........#.....#....#....#.....#...........# | 5 | 97 | *|----| ..........#......#.....#.....#......#.......... |---| 98 | *| z | ........#.......#......#......#.......#........ | z | 99 | *| o | ......#........#.......#.......#........#...... | o | 100 | *| n | ....#.........#........#........#.........#.... | n | 101 | *| e | ..#..........#.........#.........#..........#.. | e | 102 | *| 11 | #...........#..........#..........#...........# | 6 | 103 | *|----|-------------------------------------------------|---| 104 | *| | zone 10 | zone 9 | zone 8 | zone 7 | | 105 | *|----|-------------------------------------------------|---| 106 | */ 107 | export function groupByDirection

( 108 | directionPickStrategies: DirectionPickStrategies, 109 | selfPoint: P, 110 | otherPoints: P[] 111 | ): (directionGroupName: Direction) => P[] { 112 | const getZone = groupPointsIntoZones(selfPoint, otherPoints, MAX_ZONE_COUNT) 113 | 114 | return (directionGroupName: Direction): P[] => { 115 | const aliasIndex = zoneAliases.indexOf(directionGroupName) 116 | 117 | // directionGroupName is not valid 118 | if (aliasIndex === -1) { 119 | throw new Error(`directionGroupName is not valid: ${directionGroupName}`) 120 | } 121 | 122 | // [Alias] -> [Alias Index] -> [Zone Numbers(0 based)] -> [Sibling Zones(from left to right)] 123 | // UP -> 0 -> 12, 13, 14, 15, 0 , 1, 2, 3 -> [5, 6, 7], [6, 7], [7], [], [], [1], [1, 2], [1, 2, 3] 124 | // UP_RIGHT -> 1 -> 14, 15, 0, 1, 2, 3, 4, 5 -> [6, 7, 0], [7, 0], [0], [], [], [2], [2, 3], [2, 3, 4] 125 | // RIGHT -> 2 -> 0, 1, 2, 3, 4, 5, 6, 7 -> [7, 0, 1], [0, 1], [1], [], [], [3], [3, 4], [3, 4, 5] 126 | // DOWN_RIGHT -> 3 -> 2, 3, 4, 5, 6, 7, 8, 9 -> [0, 1, 2], [1, 2], [2], [], [], [4], [4, 5], [4, 5, 6] 127 | // DOWN -> 4 -> 4, 5, 6, 7, 8, 9, 10, 11 -> [1, 2, 3], [2, 3], [3], [], [], [5], [5, 6], [5, 6, 7] 128 | // DOWN_LEFT -> 5 -> 6, 7, 8, 9, 10, 11, 12, 13 -> [2, 3, 4], [3, 4], [4], [], [], [6], [6, 7], [6, 7, 0] 129 | // LEFT -> 6 -> 8, 9, 10, 11, 12, 13, 14, 15 -> [3, 4, 5], [4, 5], [5], [], [], [7], [7, 0], [7, 0, 1] 130 | // UP_LEFT -> 7 -> 10, 11, 12, 13, 14, 15, 0, 1 -> [4, 5, 6], [5, 6], [6], [], [], [0], [0, 1], [0, 1, 2] 131 | // We iterate each zone alias index for get relative temp data to the target zone 132 | const zoneNumbersInRange = zoneAliasIndices 133 | .reduce[]>( 134 | (acc, index) => { 135 | // calculate current the Zone Number of the current zone index 136 | const zoneNumber = mod(aliasIndex * 2 - QUARTER_MAX_ZONE_COUNT + index, 0, MAX_ZONE_COUNT - 1) 137 | // calculate the Zone Numbers Sequence of the current zone index 138 | const siblingZoneIndexSeq = zoneAliasIndices.map(shift => mod(aliasIndex + shift + QUARTER_MAX_ZONE_COUNT + 1, 0, HALF_MAX_ZONE_COUNT - 1)) 139 | // calculate the sibling zones from indices ahead to indices behind 140 | const toTestSiblingZoneIndices = (() => { 141 | const siblingConditionsLength = index < QUARTER_MAX_ZONE_COUNT 142 | ? QUARTER_MAX_ZONE_COUNT - index - 1 143 | : index - QUARTER_MAX_ZONE_COUNT 144 | const zoneChunkStartIndex = Math.min(index, QUARTER_MAX_ZONE_COUNT) 145 | 146 | return siblingZoneIndexSeq.slice(zoneChunkStartIndex, zoneChunkStartIndex + siblingConditionsLength) 147 | })() 148 | // if the sibling zones doesn't in PickStrategy, we count this zone into the target zone 149 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 150 | const shouldZoneBeCounted = !toTestSiblingZoneIndices.some(zoneIndex => directionPickStrategies[zoneAliases[zoneIndex]!]) 151 | 152 | return shouldZoneBeCounted ? acc.concat(getZone(zoneNumber + 1)) : acc 153 | }, 154 | [], 155 | ) 156 | 157 | const directionPickStrategy = directionPickStrategies[directionGroupName] 158 | 159 | const directionDegree = 360 * aliasIndex / zoneAliases.length 160 | 161 | return directionPickStrategy ? extractSortedPoints( 162 | directionPickStrategy, 163 | directionDegree, 164 | zoneNumbersInRange, 165 | ) : [] 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /packages/react-keyboard-navigator-example/pages/macOSFinder$.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @title MacOS Finder 3 | * @order 2 4 | */ 5 | 6 | import React, { useState } from 'react' 7 | import { KeyboardNavigatorBoard, KeyboardNavigatorElement, useKeyboardNavigator } from 'react-keyboard-navigator' 8 | import 'https://kit.fontawesome.com/921bd8fdbd.js' 9 | import './macOSFinder.scss' 10 | import { Badge } from '../components/badge' 11 | 12 | const InterestGallery = () => { 13 | const [activeElementId, setActiveElementId] = useState() 14 | 15 | const handleActive = (event: React.MouseEvent | React.FocusEvent) => setActiveElementId(event.currentTarget.dataset['refId']) 16 | 17 | const { markRef: folderMarkRef } = useKeyboardNavigator({ 18 | eventCallback: ((evt, { toElement }) => { 19 | evt.preventDefault() 20 | setActiveElementId(toElement.dataset['refId']) 21 | toElement.focus() 22 | }) 23 | }) 24 | 25 | const [focusElementId, setFocusedElementId] = useState() 26 | 27 | const handleFocus = (event: React.FocusEvent) => setFocusedElementId(event.currentTarget.dataset['refId']) 28 | 29 | const { markRef: fileMarkRef } = useKeyboardNavigator({ 30 | eventCallback: ((_, { toElement }) => { 31 | setFocusedElementId(toElement.dataset['refId']) 32 | toElement.focus() 33 | }) 34 | }) 35 | 36 | return ( 37 | <> 38 |

39 |
40 | 50 | 63 |
64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 |
NameDate CreatedKind
New FolderTestingFolderQLabBlahApplicationHitFilm ExpressBlahApplicationCode stuffMoo CowFolderResuméBlahDocumentResuméBlahDocumentResuméBlahDocumentResuméBlahDocumentResuméBlahDocumentResuméBlahDocumentResuméBlahDocumentResuméBlahDocumentResuméBlahDocument
140 |
141 | 144 |
145 |
146 | 147 | 148 | ) 149 | } 150 | 151 | export default InterestGallery -------------------------------------------------------------------------------- /packages/react-keyboard-navigator/src/helpers/__tests__/DirectionMapPresets.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from 'vitest' 2 | import { DirectionMapPresets } from '../DirectionMapPresets' 3 | 4 | describe('test DirectionMapPresets data structure', () => { 5 | test('its data structure', () => { 6 | expect(DirectionMapPresets).toMatchInlineSnapshot(` 7 | { 8 | "ArrowDirectionMap": { 9 | "cosine": { 10 | "DOWN": { 11 | "key": "ArrowDown", 12 | "strategy": "Cosine", 13 | }, 14 | "LEFT": { 15 | "key": "ArrowLeft", 16 | "strategy": "Cosine", 17 | }, 18 | "RIGHT": { 19 | "key": "ArrowRight", 20 | "strategy": "Cosine", 21 | }, 22 | "UP": { 23 | "key": "ArrowUp", 24 | "strategy": "Cosine", 25 | }, 26 | }, 27 | "distance": { 28 | "DOWN": { 29 | "key": "ArrowDown", 30 | "strategy": "Distance", 31 | }, 32 | "LEFT": { 33 | "key": "ArrowLeft", 34 | "strategy": "Distance", 35 | }, 36 | "RIGHT": { 37 | "key": "ArrowRight", 38 | "strategy": "Distance", 39 | }, 40 | "UP": { 41 | "key": "ArrowUp", 42 | "strategy": "Distance", 43 | }, 44 | }, 45 | "secant": { 46 | "DOWN": { 47 | "key": "ArrowDown", 48 | "strategy": "Secant", 49 | }, 50 | "LEFT": { 51 | "key": "ArrowLeft", 52 | "strategy": "Secant", 53 | }, 54 | "RIGHT": { 55 | "key": "ArrowRight", 56 | "strategy": "Secant", 57 | }, 58 | "UP": { 59 | "key": "ArrowUp", 60 | "strategy": "Secant", 61 | }, 62 | }, 63 | "sine": { 64 | "DOWN": { 65 | "key": "ArrowDown", 66 | "strategy": "Sine", 67 | }, 68 | "LEFT": { 69 | "key": "ArrowLeft", 70 | "strategy": "Sine", 71 | }, 72 | "RIGHT": { 73 | "key": "ArrowRight", 74 | "strategy": "Sine", 75 | }, 76 | "UP": { 77 | "key": "ArrowUp", 78 | "strategy": "Sine", 79 | }, 80 | }, 81 | "tangent": { 82 | "DOWN": { 83 | "key": "ArrowDown", 84 | "strategy": "Tangent", 85 | }, 86 | "LEFT": { 87 | "key": "ArrowLeft", 88 | "strategy": "Tangent", 89 | }, 90 | "RIGHT": { 91 | "key": "ArrowRight", 92 | "strategy": "Tangent", 93 | }, 94 | "UP": { 95 | "key": "ArrowUp", 96 | "strategy": "Tangent", 97 | }, 98 | }, 99 | }, 100 | "HJKLDirectionMap": { 101 | "cosine": { 102 | "DOWN": { 103 | "key": "J", 104 | "strategy": "Cosine", 105 | }, 106 | "LEFT": { 107 | "key": "H", 108 | "strategy": "Cosine", 109 | }, 110 | "RIGHT": { 111 | "key": "L", 112 | "strategy": "Cosine", 113 | }, 114 | "UP": { 115 | "key": "K", 116 | "strategy": "Cosine", 117 | }, 118 | }, 119 | "distance": { 120 | "DOWN": { 121 | "key": "J", 122 | "strategy": "Distance", 123 | }, 124 | "LEFT": { 125 | "key": "H", 126 | "strategy": "Distance", 127 | }, 128 | "RIGHT": { 129 | "key": "L", 130 | "strategy": "Distance", 131 | }, 132 | "UP": { 133 | "key": "K", 134 | "strategy": "Distance", 135 | }, 136 | }, 137 | "secant": { 138 | "DOWN": { 139 | "key": "J", 140 | "strategy": "Secant", 141 | }, 142 | "LEFT": { 143 | "key": "H", 144 | "strategy": "Secant", 145 | }, 146 | "RIGHT": { 147 | "key": "L", 148 | "strategy": "Secant", 149 | }, 150 | "UP": { 151 | "key": "K", 152 | "strategy": "Secant", 153 | }, 154 | }, 155 | "sine": { 156 | "DOWN": { 157 | "key": "J", 158 | "strategy": "Sine", 159 | }, 160 | "LEFT": { 161 | "key": "H", 162 | "strategy": "Sine", 163 | }, 164 | "RIGHT": { 165 | "key": "L", 166 | "strategy": "Sine", 167 | }, 168 | "UP": { 169 | "key": "K", 170 | "strategy": "Sine", 171 | }, 172 | }, 173 | "tangent": { 174 | "DOWN": { 175 | "key": "J", 176 | "strategy": "Tangent", 177 | }, 178 | "LEFT": { 179 | "key": "H", 180 | "strategy": "Tangent", 181 | }, 182 | "RIGHT": { 183 | "key": "L", 184 | "strategy": "Tangent", 185 | }, 186 | "UP": { 187 | "key": "K", 188 | "strategy": "Tangent", 189 | }, 190 | }, 191 | }, 192 | "IJKLDirectionMap": { 193 | "cosine": { 194 | "DOWN": { 195 | "key": "k", 196 | "strategy": "Cosine", 197 | }, 198 | "LEFT": { 199 | "key": "J", 200 | "strategy": "Cosine", 201 | }, 202 | "RIGHT": { 203 | "key": "L", 204 | "strategy": "Cosine", 205 | }, 206 | "UP": { 207 | "key": "I", 208 | "strategy": "Cosine", 209 | }, 210 | }, 211 | "distance": { 212 | "DOWN": { 213 | "key": "k", 214 | "strategy": "Distance", 215 | }, 216 | "LEFT": { 217 | "key": "J", 218 | "strategy": "Distance", 219 | }, 220 | "RIGHT": { 221 | "key": "L", 222 | "strategy": "Distance", 223 | }, 224 | "UP": { 225 | "key": "I", 226 | "strategy": "Distance", 227 | }, 228 | }, 229 | "secant": { 230 | "DOWN": { 231 | "key": "k", 232 | "strategy": "Secant", 233 | }, 234 | "LEFT": { 235 | "key": "J", 236 | "strategy": "Secant", 237 | }, 238 | "RIGHT": { 239 | "key": "L", 240 | "strategy": "Secant", 241 | }, 242 | "UP": { 243 | "key": "I", 244 | "strategy": "Secant", 245 | }, 246 | }, 247 | "sine": { 248 | "DOWN": { 249 | "key": "k", 250 | "strategy": "Sine", 251 | }, 252 | "LEFT": { 253 | "key": "J", 254 | "strategy": "Sine", 255 | }, 256 | "RIGHT": { 257 | "key": "L", 258 | "strategy": "Sine", 259 | }, 260 | "UP": { 261 | "key": "I", 262 | "strategy": "Sine", 263 | }, 264 | }, 265 | "tangent": { 266 | "DOWN": { 267 | "key": "k", 268 | "strategy": "Tangent", 269 | }, 270 | "LEFT": { 271 | "key": "J", 272 | "strategy": "Tangent", 273 | }, 274 | "RIGHT": { 275 | "key": "L", 276 | "strategy": "Tangent", 277 | }, 278 | "UP": { 279 | "key": "I", 280 | "strategy": "Tangent", 281 | }, 282 | }, 283 | }, 284 | "NumPadDirectionMap": { 285 | "cosine": { 286 | "DOWN": { 287 | "key": "2", 288 | "strategy": "Cosine", 289 | }, 290 | "DOWN_LEFT": { 291 | "key": "1", 292 | "strategy": "Cosine", 293 | }, 294 | "DOWN_RIGHT": { 295 | "key": "3", 296 | "strategy": "Cosine", 297 | }, 298 | "LEFT": { 299 | "key": "4", 300 | "strategy": "Cosine", 301 | }, 302 | "RIGHT": { 303 | "key": "6", 304 | "strategy": "Cosine", 305 | }, 306 | "UP": { 307 | "key": "8", 308 | "strategy": "Cosine", 309 | }, 310 | "UP_LEFT": { 311 | "key": "7", 312 | "strategy": "Cosine", 313 | }, 314 | "UP_RIGHT": { 315 | "key": "9", 316 | "strategy": "Cosine", 317 | }, 318 | }, 319 | "distance": { 320 | "DOWN": { 321 | "key": "2", 322 | "strategy": "Distance", 323 | }, 324 | "DOWN_LEFT": { 325 | "key": "1", 326 | "strategy": "Distance", 327 | }, 328 | "DOWN_RIGHT": { 329 | "key": "3", 330 | "strategy": "Distance", 331 | }, 332 | "LEFT": { 333 | "key": "4", 334 | "strategy": "Distance", 335 | }, 336 | "RIGHT": { 337 | "key": "6", 338 | "strategy": "Distance", 339 | }, 340 | "UP": { 341 | "key": "8", 342 | "strategy": "Distance", 343 | }, 344 | "UP_LEFT": { 345 | "key": "7", 346 | "strategy": "Distance", 347 | }, 348 | "UP_RIGHT": { 349 | "key": "9", 350 | "strategy": "Distance", 351 | }, 352 | }, 353 | "secant": { 354 | "DOWN": { 355 | "key": "2", 356 | "strategy": "Secant", 357 | }, 358 | "DOWN_LEFT": { 359 | "key": "1", 360 | "strategy": "Secant", 361 | }, 362 | "DOWN_RIGHT": { 363 | "key": "3", 364 | "strategy": "Secant", 365 | }, 366 | "LEFT": { 367 | "key": "4", 368 | "strategy": "Secant", 369 | }, 370 | "RIGHT": { 371 | "key": "6", 372 | "strategy": "Secant", 373 | }, 374 | "UP": { 375 | "key": "8", 376 | "strategy": "Secant", 377 | }, 378 | "UP_LEFT": { 379 | "key": "7", 380 | "strategy": "Secant", 381 | }, 382 | "UP_RIGHT": { 383 | "key": "9", 384 | "strategy": "Secant", 385 | }, 386 | }, 387 | "sine": { 388 | "DOWN": { 389 | "key": "2", 390 | "strategy": "Sine", 391 | }, 392 | "DOWN_LEFT": { 393 | "key": "1", 394 | "strategy": "Sine", 395 | }, 396 | "DOWN_RIGHT": { 397 | "key": "3", 398 | "strategy": "Sine", 399 | }, 400 | "LEFT": { 401 | "key": "4", 402 | "strategy": "Sine", 403 | }, 404 | "RIGHT": { 405 | "key": "6", 406 | "strategy": "Sine", 407 | }, 408 | "UP": { 409 | "key": "8", 410 | "strategy": "Sine", 411 | }, 412 | "UP_LEFT": { 413 | "key": "7", 414 | "strategy": "Sine", 415 | }, 416 | "UP_RIGHT": { 417 | "key": "9", 418 | "strategy": "Sine", 419 | }, 420 | }, 421 | "tangent": { 422 | "DOWN": { 423 | "key": "2", 424 | "strategy": "Tangent", 425 | }, 426 | "DOWN_LEFT": { 427 | "key": "1", 428 | "strategy": "Tangent", 429 | }, 430 | "DOWN_RIGHT": { 431 | "key": "3", 432 | "strategy": "Tangent", 433 | }, 434 | "LEFT": { 435 | "key": "4", 436 | "strategy": "Tangent", 437 | }, 438 | "RIGHT": { 439 | "key": "6", 440 | "strategy": "Tangent", 441 | }, 442 | "UP": { 443 | "key": "8", 444 | "strategy": "Tangent", 445 | }, 446 | "UP_LEFT": { 447 | "key": "7", 448 | "strategy": "Tangent", 449 | }, 450 | "UP_RIGHT": { 451 | "key": "9", 452 | "strategy": "Tangent", 453 | }, 454 | }, 455 | }, 456 | "WASDDirectionMap": { 457 | "cosine": { 458 | "DOWN": { 459 | "key": "S", 460 | "strategy": "Cosine", 461 | }, 462 | "LEFT": { 463 | "key": "A", 464 | "strategy": "Cosine", 465 | }, 466 | "RIGHT": { 467 | "key": "D", 468 | "strategy": "Cosine", 469 | }, 470 | "UP": { 471 | "key": "W", 472 | "strategy": "Cosine", 473 | }, 474 | }, 475 | "distance": { 476 | "DOWN": { 477 | "key": "S", 478 | "strategy": "Distance", 479 | }, 480 | "LEFT": { 481 | "key": "A", 482 | "strategy": "Distance", 483 | }, 484 | "RIGHT": { 485 | "key": "D", 486 | "strategy": "Distance", 487 | }, 488 | "UP": { 489 | "key": "W", 490 | "strategy": "Distance", 491 | }, 492 | }, 493 | "secant": { 494 | "DOWN": { 495 | "key": "S", 496 | "strategy": "Secant", 497 | }, 498 | "LEFT": { 499 | "key": "A", 500 | "strategy": "Secant", 501 | }, 502 | "RIGHT": { 503 | "key": "D", 504 | "strategy": "Secant", 505 | }, 506 | "UP": { 507 | "key": "W", 508 | "strategy": "Secant", 509 | }, 510 | }, 511 | "sine": { 512 | "DOWN": { 513 | "key": "S", 514 | "strategy": "Sine", 515 | }, 516 | "LEFT": { 517 | "key": "A", 518 | "strategy": "Sine", 519 | }, 520 | "RIGHT": { 521 | "key": "D", 522 | "strategy": "Sine", 523 | }, 524 | "UP": { 525 | "key": "W", 526 | "strategy": "Sine", 527 | }, 528 | }, 529 | "tangent": { 530 | "DOWN": { 531 | "key": "S", 532 | "strategy": "Tangent", 533 | }, 534 | "LEFT": { 535 | "key": "A", 536 | "strategy": "Tangent", 537 | }, 538 | "RIGHT": { 539 | "key": "D", 540 | "strategy": "Tangent", 541 | }, 542 | "UP": { 543 | "key": "W", 544 | "strategy": "Tangent", 545 | }, 546 | }, 547 | }, 548 | } 549 | `) 550 | }) 551 | }) -------------------------------------------------------------------------------- /packages/react-keyboard-navigator/README.md: -------------------------------------------------------------------------------- 1 | # ⌨️ [React Keyboard Navigator](https://react-keyboard-navigator.zheeeng.me) 2 | 3 | [![NPM](https://nodei.co/npm/react-keyboard-navigator.png?downloads=true&downloadRank=true&stars=true)](https://nodei.co/npm/react-keyboard-navigator/) 4 | 5 | ![publish workflow](https://github.com/zheeeng/react-keyboard-navigator/actions/workflows/publish.yml/badge.svg) 6 | ![pages workflow](https://github.com/zheeeng/react-keyboard-navigator/actions/workflows/pages.yml/badge.svg) 7 | [![npm version](https://img.shields.io/npm/v/react-keyboard-navigator.svg)](https://www.npmjs.com/package/react-keyboard-navigator) 8 | 9 | A suite of React components and hook for selecting sibling components through the keyboard. 10 | 11 | ![react-keyboard-navigator](https://user-images.githubusercontent.com/1303154/176628751-dcff5374-5ed3-4556-9b1c-e13a88246e31.png) 12 | 13 | ## 🧩 Installation 14 | 15 | ```bash 16 | yarn add react-keyboard-navigator (or npm/pnpm) 17 | ``` 18 | 19 | ## 💡 Concept 20 | 21 | ```tsx 22 | import { KeyboardNavigatorBoard, KeyboardNavigatorElement, useKeyboardNavigator } from 'react-keyboard-navigator' 23 | ``` 24 | 25 | This suite contains two polymorphic higher-order component: `KeyboardNavigatorBoard` and `KeyboardNavigatorElement`, the former scopes the control zone, and the latter wraps your selectable component. They both receive a special prop `as`, which indicates what's the component ultimately rendered as. 26 | 27 | There is another necessary React hook in this suite -- `useKeyboardNavigator`. It returns a `marker` which adheres to the `KeyboardNavigatorBoard` and `KeyboardNavigatorElement` for connecting them. 28 | 29 | ```ts 30 | const { markRef } = useKeyboardNavigator() 31 | 32 | ``` 33 | 34 | ```tsx 35 | 36 | {children} 37 | 38 | ``` 39 | 40 | ```tsx 41 | 42 | 43 | {children} 44 | 45 | ``` 46 | 47 | ## 💫 Signature 48 | 49 | `KeyboardNavigatorBoard`'s `active` state can be driven by an external prop or internal automatic detecting. An explicitly passed `active` prop forces this detection disabled. If you let this `active` prop be omitted, the `autoActive` detecting mechanism will be enabled with an initial active state `initialActive`. It is also a polymorphic higher-order component, so you can pass any props which the `as` one takes and the base type definition. 50 | 51 | ```ts 52 | type KeyboardNavigatorBoardProps = { 53 | markRef: RegistrySymbol, 54 | as: React.ElementType, 55 | active?: boolean, 56 | // if we explicitly passed the `active` prop, it means the `active` state of KeyboardNavigatorBoard is controlled by external, the `autoActive` prop is forced to `false`. 57 | // Otherwise, the `autoActive` fallbacks to enabled. 58 | autoActive?: boolean, 59 | onAutoActiveChange?: (active: boolean) => void, 60 | // if the `autoActive` feature is enabled, the initial is used to determine the initial active state, it has the default value of `false` 61 | initialActive?: boolean, 62 | } 63 | ``` 64 | 65 | `KeyboardNavigatorElement` is a active-state-controlled component (see the [controlled component explanation](https://blog.logrocket.com/controlled-vs-uncontrolled-components-in-react/)), and it is polymorphic higher-order-component, also receives any props which the `as` one takes. Therefore it mixes the base type definition with the `as` one's props: 66 | 67 | ```ts 68 | type KeyboardNavigatorElementProps = { 69 | markRef: RegistrySymbol, 70 | active?: boolean, 71 | onActiveChange?: (active: boolean) => void, 72 | as: React.ElementType, 73 | } 74 | ``` 75 | 76 | `useKeyboardNavigator` receives: 77 | 78 | 1. `directionMap` for customize keyboard mapping. See the [Customization](#customization) section for details. 79 | 2. `eventCallback` for catching the active state transition, if the caller explicitly returns a `false` value means manually to prevent this pass-by happening. See the [Signature](#signature-of-customization-stuff) section for more about built-in event callback presets. 80 | 3. `didChange` for catching the next tick of active state pass-by, it is convenient to manipulate the relevant elements, e.g. trigger focus, blur, etc. 81 | 4. `rootContainer` for set a always existed and active `KeyboardNavigatorBoard`, e.g. `document.body`. If this option is provided, you don't have to always mark a selectable element through wrapped itself by `KeyboardNavigatorBoard`. 82 | 83 | ```ts 84 | type UseKeyboardNavigatorOption = { 85 | directionMap?: DirectionKeyMap | DirectionMap 86 | eventCallback?: (e: KeyboardEvent, eventInfo: { fromElement: HTMLElement, toElement?: HTMLElement }) => void | false 87 | didChange?: (fromElement: HTMLElement, toElement: HTMLElement) => void 88 | rootContainer?: HTMLElement 89 | } 90 | ``` 91 | 92 | ## 📎 Example 93 | 94 | ```tsx 95 | import { KeyboardNavigatorBoard, KeyboardNavigatorElement, useKeyboardNavigator } from 'react-keyboard-navigator' 96 | 97 | const Demo = ({ blocks }: Props) => { 98 | const { markRef } = useKeyboardNavigator({ 99 | // prevent the default page scrolling behavior when we are using the keyboard to switch the active state between components 100 | eventCallback: evt => evt.preventDefault() 101 | }) 102 | 103 | const [highlightBlockIndex, setHighlightBockIndex] = useState(0) 104 | 105 | const [boardActive, setBoardActive] = useState(true) 106 | 107 | return ( 108 |
109 |
setBoardActive(!boardActive)} style={{ cursor: 'pointer' }}>Active controlled: {boardActive ? '✅' : '❌'}
110 |
111 | 115 | {blocks.map((word, index) => ( 116 | setHighlightBockIndex(index)} 120 | markRef={markRef} active={index === highlightBlockIndex} onActiveChange={() => setHighlightBockIndex(index)} 121 | > 122 | {word.text}{' '} 123 | 124 | ))} 125 | 126 |
127 | ) 128 | } 129 | ``` 130 | 131 | You can see the live preview here: [Random Placement](https://react-keyboard-navigator.zheeeng.me/#/randomPlacement), and other examples: [Interest Gallery](https://react-keyboard-navigator.zheeeng.me/#/interestGallery), [MacOS Finder](https://react-keyboard-navigator.zheeeng.me/#/macOSFinder) 132 | 133 | ## Customization 👇 134 | 135 | There are two customizable stuff in keyboard navigation: `distance calculation strategy` and `direction mapping`. 136 | 137 | 1. `distance calculation strategy` determines how to calculate the distance between the start point and the specified direction. It support `Distance`、`Secant`、`Cosine`、`Sine`、`Tangent`、custom calculation method `(distance: number, angleDegree: number) => number`. 138 | 139 | ![distance](https://user-images.githubusercontent.com/1303154/177306883-1ffe7039-db9b-4f1b-a503-e2d5048936ef.png) 140 | 141 | 2. `direction mapping` binds the keyboard key to the direction. There are total 8 directions and some built-in direction-keyboard mapping has been defined: 142 | 143 | | Group Name | Direction | Keyboard Key | 144 | | ------------------ | ---------- | ------------ | 145 | | ArrowDirectionMap | UP | ArrowUp | 146 | | | DOWN | ArrowDown | 147 | | | LEFT | ArrowLeft | 148 | | | RIGHT | ArrowRight | 149 | | WASDDirectionMap | UP | W | 150 | | | DOWN | S | 151 | | | LEFT | A | 152 | | | RIGHT | D | 153 | | IJKLDirectionMap | UP | I | 154 | | | DOWN | K | 155 | | | LEFT | J | 156 | | | RIGHT | L | 157 | | HJKLDirectionMap | UP | K | 158 | | | DOWN | H | 159 | | | LEFT | J | 160 | | | RIGHT | L | 161 | | NumPadDirectionMap | UP_LEFT | 7 | 162 | | | UP | 8 | 163 | | | UP_RIGHT | 9 | 164 | | | LEFT | 4 | 165 | | | RIGHT | 6 | 166 | | | DOWN_LEFT | 1 | 167 | | | DOWN | 2 | 168 | | | DOWN_RIGHT | 3 | 169 | 170 | By default we use the `ArrowDirectionMap`. 171 | 172 | An valid custom direction could be mapping from direction to key: 173 | 174 | ```ts 175 | const ArrowDirectionKeyMap: DirectionKeyMap = { 176 | UP: 'ArrowUp', 177 | DOWN: 'ArrowDown', 178 | LEFT: 'ArrowLeft', 179 | RIGHT: 'ArrowRight', 180 | } 181 | ``` 182 | 183 | or mapping from direction to key and strategy: 184 | 185 | ```ts 186 | const ArrowDirectionMap: DirectionMap = { 187 | UP: { 188 | key: 'ArrowUp', 189 | strategy: 'Cosine', 190 | }, 191 | DOWN: { 192 | key: 'ArrowDown', 193 | strategy: 'Cosine', 194 | }, 195 | LEFT: { 196 | key: 'ArrowLeft', 197 | strategy: 'Distance', 198 | }, 199 | RIGHT: { 200 | key: 'ArrowRight', 201 | strategy: 'Distance', 202 | } 203 | } 204 | ``` 205 | 206 | We exported all the built-in direction-keyboard mapping presets. They are grouped by preferences, and there all have subgroups with different strategies. 207 | 208 | ```ts 209 | import { useKeyboardNavigator, DirectionMapPresets } from 'react-keyboard-navigator' 210 | 211 | // execute in A Functional React Component 212 | useKeyboardNavigator({ 213 | directionMap: DirectionMapPresets.WASDDirectionMap.secant 214 | }) 215 | ``` 216 | 217 | ### Create your own direction mapping 218 | 219 | You can create your own mapping with fallback strategy `Cosine`. e.g. 220 | 221 | ```ts 222 | const MyDirectionMapping: DirectionKeyMap = { 223 | UP: 'U', 224 | DOWN: 'D', 225 | LEFT: 'L', 226 | RIGHT: 'R', 227 | } 228 | ``` 229 | 230 | Or use the `StrategiesHelper` to create a `DirectionMap` which defined with specific strategy: 231 | 232 | ```ts 233 | import { StrategiesHelper } from 'react-keyboard-navigator 234 | 235 | const MyDirectionMapping: DirectionMap = StrategiesHelper.secant({ 236 | UP: 'U', 237 | DOWN: 'D', 238 | LEFT: 'L', 239 | RIGHT: 'R', 240 | }) 241 | ``` 242 | 243 | If this `StrategiesHelper` doesn't satisfy your needs, feel free to use your own calculation. 244 | 245 | ```ts 246 | const YourOwnDirection: DirectionMap = { 247 | UP: { 248 | key: 'U', 249 | strategy: 'Cosine', 250 | }, 251 | DOWN: { 252 | key: 'D', 253 | strategy: 'Cosine', 254 | }, 255 | LEFT: { 256 | key: 'L', 257 | strategy: (distance: number, angleDegree: number) => angleDegree < 10 ? 0 : distance * Math.log(angleDegree) / Math.log(10), 258 | }, 259 | RIGHT: { 260 | key: 'R', 261 | strategy: (distance: number, angleDegree: number) => angleDegree < 10 ? 0 : distance * Math.log(angleDegree) / Math.log(10), 262 | } 263 | } 264 | ``` 265 | 266 | ### Signature of Customization stuff 267 | 268 | #### EventCallbackPresets 269 | 270 | This presets includes some common event callbacks. 271 | 272 | ```ts 273 | import { EventCallbackPresets } from 'react-keyboard-navigator' 274 | ``` 275 | 276 | * `DirectionMapPresets.preventDefault`: prevent the default behavior of the event, usually is used for prevent from page scrolling when navigating. 277 | * `DirectionMapPresets.stopPropagation`: stop propagation of the event, usually is used for prevent conflicts with topper DOM's listeners. 278 | * `DirectionMapPresets.stopImmediatePropagation`: same to `stopPropagation`, but stop the event propagation immediately. 279 | * `DirectionMapPresets.stopOnActiveInputElement`: stop navigating when the current active element is an input element. 280 | * `DirectionMapPresets.stopOnActiveInputElementAndPreventDefault`: same to `stopOnActiveInputElement`, but also prevent the default behavior of the event. 281 | 282 | #### DirectionMapPresets 283 | 284 | ```ts 285 | import { DirectionMapPresets } from 'react-keyboard-navigator' 286 | ``` 287 | 288 |
289 | See its structure 290 |

291 | 292 | ```json 293 | Object { 294 | "ArrowDirectionMap": Object { 295 | "cosine": Object { 296 | "DOWN": Object { 297 | "key": "ArrowDown", 298 | "strategy": "Cosine", 299 | }, 300 | "LEFT": Object { 301 | "key": "ArrowLeft", 302 | "strategy": "Cosine", 303 | }, 304 | "RIGHT": Object { 305 | "key": "ArrowRight", 306 | "strategy": "Cosine", 307 | }, 308 | "UP": Object { 309 | "key": "ArrowUp", 310 | "strategy": "Cosine", 311 | }, 312 | }, 313 | "distance": Object { 314 | "DOWN": Object { 315 | "key": "ArrowDown", 316 | "strategy": "Distance", 317 | }, 318 | "LEFT": Object { 319 | "key": "ArrowLeft", 320 | "strategy": "Distance", 321 | }, 322 | "RIGHT": Object { 323 | "key": "ArrowRight", 324 | "strategy": "Distance", 325 | }, 326 | "UP": Object { 327 | "key": "ArrowUp", 328 | "strategy": "Distance", 329 | }, 330 | }, 331 | "secant": Object { 332 | "DOWN": Object { 333 | "key": "ArrowDown", 334 | "strategy": "Secant", 335 | }, 336 | "LEFT": Object { 337 | "key": "ArrowLeft", 338 | "strategy": "Secant", 339 | }, 340 | "RIGHT": Object { 341 | "key": "ArrowRight", 342 | "strategy": "Secant", 343 | }, 344 | "UP": Object { 345 | "key": "ArrowUp", 346 | "strategy": "Secant", 347 | }, 348 | }, 349 | "sine": Object { 350 | "DOWN": Object { 351 | "key": "ArrowDown", 352 | "strategy": "Sine", 353 | }, 354 | "LEFT": Object { 355 | "key": "ArrowLeft", 356 | "strategy": "Sine", 357 | }, 358 | "RIGHT": Object { 359 | "key": "ArrowRight", 360 | "strategy": "Sine", 361 | }, 362 | "UP": Object { 363 | "key": "ArrowUp", 364 | "strategy": "Sine", 365 | }, 366 | }, 367 | "tangent": Object { 368 | "DOWN": Object { 369 | "key": "ArrowDown", 370 | "strategy": "Tangent", 371 | }, 372 | "LEFT": Object { 373 | "key": "ArrowLeft", 374 | "strategy": "Tangent", 375 | }, 376 | "RIGHT": Object { 377 | "key": "ArrowRight", 378 | "strategy": "Tangent", 379 | }, 380 | "UP": Object { 381 | "key": "ArrowUp", 382 | "strategy": "Tangent", 383 | }, 384 | }, 385 | }, 386 | "HJKLDirectionMap": Object { 387 | "cosine": Object { 388 | "DOWN": Object { 389 | "key": "J", 390 | "strategy": "Cosine", 391 | }, 392 | "LEFT": Object { 393 | "key": "H", 394 | "strategy": "Cosine", 395 | }, 396 | "RIGHT": Object { 397 | "key": "L", 398 | "strategy": "Cosine", 399 | }, 400 | "UP": Object { 401 | "key": "K", 402 | "strategy": "Cosine", 403 | }, 404 | }, 405 | "distance": Object { 406 | "DOWN": Object { 407 | "key": "J", 408 | "strategy": "Distance", 409 | }, 410 | "LEFT": Object { 411 | "key": "H", 412 | "strategy": "Distance", 413 | }, 414 | "RIGHT": Object { 415 | "key": "L", 416 | "strategy": "Distance", 417 | }, 418 | "UP": Object { 419 | "key": "K", 420 | "strategy": "Distance", 421 | }, 422 | }, 423 | "secant": Object { 424 | "DOWN": Object { 425 | "key": "J", 426 | "strategy": "Secant", 427 | }, 428 | "LEFT": Object { 429 | "key": "H", 430 | "strategy": "Secant", 431 | }, 432 | "RIGHT": Object { 433 | "key": "L", 434 | "strategy": "Secant", 435 | }, 436 | "UP": Object { 437 | "key": "K", 438 | "strategy": "Secant", 439 | }, 440 | }, 441 | "sine": Object { 442 | "DOWN": Object { 443 | "key": "J", 444 | "strategy": "Sine", 445 | }, 446 | "LEFT": Object { 447 | "key": "H", 448 | "strategy": "Sine", 449 | }, 450 | "RIGHT": Object { 451 | "key": "L", 452 | "strategy": "Sine", 453 | }, 454 | "UP": Object { 455 | "key": "K", 456 | "strategy": "Sine", 457 | }, 458 | }, 459 | "tangent": Object { 460 | "DOWN": Object { 461 | "key": "J", 462 | "strategy": "Tangent", 463 | }, 464 | "LEFT": Object { 465 | "key": "H", 466 | "strategy": "Tangent", 467 | }, 468 | "RIGHT": Object { 469 | "key": "L", 470 | "strategy": "Tangent", 471 | }, 472 | "UP": Object { 473 | "key": "K", 474 | "strategy": "Tangent", 475 | }, 476 | }, 477 | }, 478 | "IJKLDirectionMap": Object { 479 | "cosine": Object { 480 | "DOWN": Object { 481 | "key": "k", 482 | "strategy": "Cosine", 483 | }, 484 | "LEFT": Object { 485 | "key": "J", 486 | "strategy": "Cosine", 487 | }, 488 | "RIGHT": Object { 489 | "key": "L", 490 | "strategy": "Cosine", 491 | }, 492 | "UP": Object { 493 | "key": "I", 494 | "strategy": "Cosine", 495 | }, 496 | }, 497 | "distance": Object { 498 | "DOWN": Object { 499 | "key": "k", 500 | "strategy": "Distance", 501 | }, 502 | "LEFT": Object { 503 | "key": "J", 504 | "strategy": "Distance", 505 | }, 506 | "RIGHT": Object { 507 | "key": "L", 508 | "strategy": "Distance", 509 | }, 510 | "UP": Object { 511 | "key": "I", 512 | "strategy": "Distance", 513 | }, 514 | }, 515 | "secant": Object { 516 | "DOWN": Object { 517 | "key": "k", 518 | "strategy": "Secant", 519 | }, 520 | "LEFT": Object { 521 | "key": "J", 522 | "strategy": "Secant", 523 | }, 524 | "RIGHT": Object { 525 | "key": "L", 526 | "strategy": "Secant", 527 | }, 528 | "UP": Object { 529 | "key": "I", 530 | "strategy": "Secant", 531 | }, 532 | }, 533 | "sine": Object { 534 | "DOWN": Object { 535 | "key": "k", 536 | "strategy": "Sine", 537 | }, 538 | "LEFT": Object { 539 | "key": "J", 540 | "strategy": "Sine", 541 | }, 542 | "RIGHT": Object { 543 | "key": "L", 544 | "strategy": "Sine", 545 | }, 546 | "UP": Object { 547 | "key": "I", 548 | "strategy": "Sine", 549 | }, 550 | }, 551 | "tangent": Object { 552 | "DOWN": Object { 553 | "key": "k", 554 | "strategy": "Tangent", 555 | }, 556 | "LEFT": Object { 557 | "key": "J", 558 | "strategy": "Tangent", 559 | }, 560 | "RIGHT": Object { 561 | "key": "L", 562 | "strategy": "Tangent", 563 | }, 564 | "UP": Object { 565 | "key": "I", 566 | "strategy": "Tangent", 567 | }, 568 | }, 569 | }, 570 | "NumPadDirectionMap": Object { 571 | "cosine": Object { 572 | "DOWN": Object { 573 | "key": "2", 574 | "strategy": "Cosine", 575 | }, 576 | "DOWN_LEFT": Object { 577 | "key": "1", 578 | "strategy": "Cosine", 579 | }, 580 | "DOWN_RIGHT": Object { 581 | "key": "3", 582 | "strategy": "Cosine", 583 | }, 584 | "LEFT": Object { 585 | "key": "4", 586 | "strategy": "Cosine", 587 | }, 588 | "RIGHT": Object { 589 | "key": "6", 590 | "strategy": "Cosine", 591 | }, 592 | "UP": Object { 593 | "key": "8", 594 | "strategy": "Cosine", 595 | }, 596 | "UP_LEFT": Object { 597 | "key": "7", 598 | "strategy": "Cosine", 599 | }, 600 | "UP_RIGHT": Object { 601 | "key": "9", 602 | "strategy": "Cosine", 603 | }, 604 | }, 605 | "distance": Object { 606 | "DOWN": Object { 607 | "key": "2", 608 | "strategy": "Distance", 609 | }, 610 | "DOWN_LEFT": Object { 611 | "key": "1", 612 | "strategy": "Distance", 613 | }, 614 | "DOWN_RIGHT": Object { 615 | "key": "3", 616 | "strategy": "Distance", 617 | }, 618 | "LEFT": Object { 619 | "key": "4", 620 | "strategy": "Distance", 621 | }, 622 | "RIGHT": Object { 623 | "key": "6", 624 | "strategy": "Distance", 625 | }, 626 | "UP": Object { 627 | "key": "8", 628 | "strategy": "Distance", 629 | }, 630 | "UP_LEFT": Object { 631 | "key": "7", 632 | "strategy": "Distance", 633 | }, 634 | "UP_RIGHT": Object { 635 | "key": "9", 636 | "strategy": "Distance", 637 | }, 638 | }, 639 | "secant": Object { 640 | "DOWN": Object { 641 | "key": "2", 642 | "strategy": "Secant", 643 | }, 644 | "DOWN_LEFT": Object { 645 | "key": "1", 646 | "strategy": "Secant", 647 | }, 648 | "DOWN_RIGHT": Object { 649 | "key": "3", 650 | "strategy": "Secant", 651 | }, 652 | "LEFT": Object { 653 | "key": "4", 654 | "strategy": "Secant", 655 | }, 656 | "RIGHT": Object { 657 | "key": "6", 658 | "strategy": "Secant", 659 | }, 660 | "UP": Object { 661 | "key": "8", 662 | "strategy": "Secant", 663 | }, 664 | "UP_LEFT": Object { 665 | "key": "7", 666 | "strategy": "Secant", 667 | }, 668 | "UP_RIGHT": Object { 669 | "key": "9", 670 | "strategy": "Secant", 671 | }, 672 | }, 673 | "sine": Object { 674 | "DOWN": Object { 675 | "key": "2", 676 | "strategy": "Sine", 677 | }, 678 | "DOWN_LEFT": Object { 679 | "key": "1", 680 | "strategy": "Sine", 681 | }, 682 | "DOWN_RIGHT": Object { 683 | "key": "3", 684 | "strategy": "Sine", 685 | }, 686 | "LEFT": Object { 687 | "key": "4", 688 | "strategy": "Sine", 689 | }, 690 | "RIGHT": Object { 691 | "key": "6", 692 | "strategy": "Sine", 693 | }, 694 | "UP": Object { 695 | "key": "8", 696 | "strategy": "Sine", 697 | }, 698 | "UP_LEFT": Object { 699 | "key": "7", 700 | "strategy": "Sine", 701 | }, 702 | "UP_RIGHT": Object { 703 | "key": "9", 704 | "strategy": "Sine", 705 | }, 706 | }, 707 | "tangent": Object { 708 | "DOWN": Object { 709 | "key": "2", 710 | "strategy": "Tangent", 711 | }, 712 | "DOWN_LEFT": Object { 713 | "key": "1", 714 | "strategy": "Tangent", 715 | }, 716 | "DOWN_RIGHT": Object { 717 | "key": "3", 718 | "strategy": "Tangent", 719 | }, 720 | "LEFT": Object { 721 | "key": "4", 722 | "strategy": "Tangent", 723 | }, 724 | "RIGHT": Object { 725 | "key": "6", 726 | "strategy": "Tangent", 727 | }, 728 | "UP": Object { 729 | "key": "8", 730 | "strategy": "Tangent", 731 | }, 732 | "UP_LEFT": Object { 733 | "key": "7", 734 | "strategy": "Tangent", 735 | }, 736 | "UP_RIGHT": Object { 737 | "key": "9", 738 | "strategy": "Tangent", 739 | }, 740 | }, 741 | }, 742 | "WASDDirectionMap": Object { 743 | "cosine": Object { 744 | "DOWN": Object { 745 | "key": "S", 746 | "strategy": "Cosine", 747 | }, 748 | "LEFT": Object { 749 | "key": "A", 750 | "strategy": "Cosine", 751 | }, 752 | "RIGHT": Object { 753 | "key": "D", 754 | "strategy": "Cosine", 755 | }, 756 | "UP": Object { 757 | "key": "W", 758 | "strategy": "Cosine", 759 | }, 760 | }, 761 | "distance": Object { 762 | "DOWN": Object { 763 | "key": "S", 764 | "strategy": "Distance", 765 | }, 766 | "LEFT": Object { 767 | "key": "A", 768 | "strategy": "Distance", 769 | }, 770 | "RIGHT": Object { 771 | "key": "D", 772 | "strategy": "Distance", 773 | }, 774 | "UP": Object { 775 | "key": "W", 776 | "strategy": "Distance", 777 | }, 778 | }, 779 | "secant": Object { 780 | "DOWN": Object { 781 | "key": "S", 782 | "strategy": "Secant", 783 | }, 784 | "LEFT": Object { 785 | "key": "A", 786 | "strategy": "Secant", 787 | }, 788 | "RIGHT": Object { 789 | "key": "D", 790 | "strategy": "Secant", 791 | }, 792 | "UP": Object { 793 | "key": "W", 794 | "strategy": "Secant", 795 | }, 796 | }, 797 | "sine": Object { 798 | "DOWN": Object { 799 | "key": "S", 800 | "strategy": "Sine", 801 | }, 802 | "LEFT": Object { 803 | "key": "A", 804 | "strategy": "Sine", 805 | }, 806 | "RIGHT": Object { 807 | "key": "D", 808 | "strategy": "Sine", 809 | }, 810 | "UP": Object { 811 | "key": "W", 812 | "strategy": "Sine", 813 | }, 814 | }, 815 | "tangent": Object { 816 | "DOWN": Object { 817 | "key": "S", 818 | "strategy": "Tangent", 819 | }, 820 | "LEFT": Object { 821 | "key": "A", 822 | "strategy": "Tangent", 823 | }, 824 | "RIGHT": Object { 825 | "key": "D", 826 | "strategy": "Tangent", 827 | }, 828 | "UP": Object { 829 | "key": "W", 830 | "strategy": "Tangent", 831 | }, 832 | }, 833 | }, 834 | } 835 | ``` 836 | 837 |

838 |
839 | 840 | #### StrategiesHelper 841 | 842 | ```ts 843 | import { StrategiesHelper } from 'react-keyboard-navigator' 844 | ``` 845 | 846 |
847 | See its structure 848 |

849 | 850 | ```ts 851 | { 852 | distance: (directionMap: DirectionKeyMap | DirectionMap, keepOrigin?: boolean) => DirectionMap, 853 | secant: (directionMap: DirectionKeyMap | DirectionMap, keepOrigin?: boolean) => DirectionMap, 854 | cosine: (directionMap: DirectionKeyMap | DirectionMap, keepOrigin?: boolean) => DirectionMap, 855 | sine: (directionMap: DirectionKeyMap | DirectionMap, keepOrigin?: boolean) => DirectionMap, 856 | tangent: (directionMap: DirectionKeyMap | DirectionMap, keepOrigin?: boolean) => DirectionMap, 857 | } 858 | ``` 859 | 860 |

861 |
862 | -------------------------------------------------------------------------------- /packages/react-keyboard-navigator/src/utils/__tests__/groupByDirection.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from 'vitest' 2 | import { groupByDirection } from '../groupByDirection' 3 | import { testPoints, testPointsList } from './points.data' 4 | 5 | const { 6 | zone1StartPoint,zone16StartPoint, zone2StartPoint, zone3StartPoint, 7 | zone5StartPoint, zone4StartPoint, zone6StartPoint, zone7StartPoint, 8 | zone9StartPoint, zone8StartPoint, zone10StartPoint, zone11StartPoint, 9 | zone13StartPoint, zone12StartPoint, zone14StartPoint, zone15StartPoint, 10 | } = testPoints 11 | 12 | describe('test groupByDirection cases not care about the calculation mode', () => { 13 | test('throw error when try get an invalid direction', () => { 14 | const groupBy = groupByDirection( 15 | { 16 | UP: 'Distance', UP_RIGHT: 'Distance', RIGHT: 'Distance', DOWN_RIGHT: 'Distance', 17 | DOWN: 'Cosine', DOWN_LEFT: 'Cosine', LEFT: 'Cosine', UP_LEFT: 'Cosine', 18 | }, 19 | testPoints.centerPoint, 20 | testPointsList, 21 | ) 22 | 23 | // @ts-expect-error: for test 24 | expect(() => groupBy('up')).toThrowError('directionGroupName is not valid: up') 25 | // @ts-expect-error: for test 26 | expect(() => groupBy('Up')).toThrowError('directionGroupName is not valid: Up') 27 | // @ts-expect-error: for test 28 | expect(() => groupBy('TOP')).toThrowError('directionGroupName is not valid: TOP') 29 | // @ts-expect-error: for test 30 | expect(() => groupBy('NORTH')).toThrowError('directionGroupName is not valid: NORTH') 31 | }) 32 | }) 33 | 34 | function createTestInput (groupBy: ReturnType) { 35 | return { 36 | UP: groupBy('UP'), 37 | UP_RIGHT: groupBy('UP_RIGHT'), 38 | RIGHT: groupBy('RIGHT'), 39 | DOWN_RIGHT: groupBy('DOWN_RIGHT'), 40 | DOWN: groupBy('DOWN'), 41 | DOWN_LEFT: groupBy('DOWN_LEFT'), 42 | LEFT: groupBy('LEFT'), 43 | UP_LEFT: groupBy('UP_LEFT'), 44 | } 45 | } 46 | 47 | describe('test groupByDirection in `Distance` calculation mode', () => { 48 | 49 | test('points are groupBy by 8 directions and sorted in ascending distance', () => { 50 | const groupBy = groupByDirection( 51 | { 52 | UP: 'Distance', UP_RIGHT: 'Distance', RIGHT: 'Distance', DOWN_RIGHT: 'Distance', 53 | DOWN: 'Distance', DOWN_LEFT: 'Distance', LEFT: 'Distance', UP_LEFT: 'Distance', 54 | }, 55 | testPoints.centerPoint, 56 | testPointsList, 57 | ) 58 | 59 | expect(createTestInput(groupBy)).toEqual({ 60 | UP: [zone16StartPoint, zone1StartPoint], 61 | UP_RIGHT: [zone3StartPoint, zone2StartPoint], 62 | RIGHT: [zone5StartPoint, zone4StartPoint], 63 | DOWN_RIGHT: [zone7StartPoint, zone6StartPoint], 64 | DOWN: [zone9StartPoint, zone8StartPoint], 65 | DOWN_LEFT: [zone11StartPoint, zone10StartPoint], 66 | LEFT: [zone13StartPoint, zone12StartPoint], 67 | UP_LEFT: [zone15StartPoint, zone14StartPoint], 68 | }) 69 | }) 70 | 71 | test('points are groupBy by 4 directions and sorted in ascending distance', () => { 72 | const groupBy = groupByDirection( 73 | { 74 | UP: 'Distance', RIGHT: 'Distance', 75 | DOWN: 'Distance', LEFT: 'Distance', 76 | }, 77 | testPoints.centerPoint, 78 | testPointsList, 79 | ) 80 | 81 | expect(createTestInput(groupBy)).toEqual({ 82 | UP: [zone16StartPoint, zone15StartPoint, zone2StartPoint, zone1StartPoint], 83 | UP_RIGHT: [], 84 | RIGHT: [zone6StartPoint, zone5StartPoint, zone4StartPoint, zone3StartPoint], 85 | DOWN_RIGHT: [], 86 | DOWN: [zone10StartPoint, zone9StartPoint, zone8StartPoint, zone7StartPoint], 87 | DOWN_LEFT: [], 88 | LEFT: [zone14StartPoint, zone13StartPoint, zone12StartPoint, zone11StartPoint], 89 | UP_LEFT: [], 90 | }) 91 | }) 92 | 93 | test('points are groupBy by 4 directions and sorted in ascending distance (2)', () => { 94 | const groupBy = groupByDirection( 95 | { 96 | UP_RIGHT: 'Distance', DOWN_RIGHT: 'Distance', 97 | DOWN_LEFT: 'Distance', UP_LEFT: 'Distance', 98 | }, 99 | testPoints.centerPoint, 100 | testPointsList, 101 | ) 102 | 103 | expect(createTestInput(groupBy)).toEqual({ 104 | UP: [], 105 | UP_RIGHT: [zone4StartPoint, zone3StartPoint, zone2StartPoint, zone1StartPoint], 106 | RIGHT: [], 107 | DOWN_RIGHT: [zone8StartPoint, zone7StartPoint, zone6StartPoint, zone5StartPoint], 108 | DOWN: [], 109 | DOWN_LEFT: [zone12StartPoint, zone11StartPoint, zone10StartPoint, zone9StartPoint], 110 | LEFT: [], 111 | UP_LEFT: [zone16StartPoint, zone15StartPoint, zone14StartPoint, zone13StartPoint], 112 | }) 113 | }) 114 | 115 | test('points are groupBy by 4 directions and sorted in ascending distance (3)', () => { 116 | const groupBy = groupByDirection( 117 | { 118 | DOWN: 'Distance', DOWN_LEFT: 'Distance', LEFT: 'Distance', UP_LEFT: 'Distance', 119 | }, 120 | testPoints.centerPoint, 121 | testPointsList, 122 | ) 123 | 124 | expect(createTestInput(groupBy)).toEqual({ 125 | UP: [], 126 | UP_RIGHT: [], 127 | RIGHT: [], 128 | DOWN_RIGHT: [], 129 | DOWN: [zone9StartPoint, zone8StartPoint, zone7StartPoint, zone6StartPoint, zone5StartPoint], 130 | DOWN_LEFT: [zone11StartPoint, zone10StartPoint], 131 | LEFT: [zone13StartPoint, zone12StartPoint], 132 | UP_LEFT: [zone16StartPoint, zone15StartPoint, zone14StartPoint, zone2StartPoint, zone1StartPoint], 133 | }) 134 | }) 135 | 136 | test('points are groupBy by 4 directions and sorted in ascending distance (4)', () => { 137 | const groupBy = groupByDirection( 138 | { 139 | UP: 'Distance', UP_RIGHT: 'Distance', 140 | DOWN: 'Distance', DOWN_LEFT: 'Distance', 141 | }, 142 | testPoints.centerPoint, 143 | testPointsList, 144 | ) 145 | 146 | expect(createTestInput(groupBy)).toEqual({ 147 | UP: [zone16StartPoint, zone15StartPoint, zone14StartPoint, zone1StartPoint], 148 | UP_RIGHT: [zone5StartPoint, zone4StartPoint, zone3StartPoint, zone2StartPoint], 149 | RIGHT: [], 150 | DOWN_RIGHT: [], 151 | DOWN: [zone9StartPoint, zone8StartPoint, zone7StartPoint, zone6StartPoint], 152 | DOWN_LEFT: [zone13StartPoint, zone12StartPoint, zone11StartPoint, zone10StartPoint], 153 | LEFT: [], 154 | UP_LEFT: [], 155 | }) 156 | }) 157 | 158 | test('points are groupBy by 2 directions and sorted in ascending distance', () => { 159 | const groupBy = groupByDirection( 160 | { 161 | UP: 'Distance', DOWN: 'Distance', 162 | }, 163 | testPoints.centerPoint, 164 | testPointsList, 165 | ) 166 | 167 | expect(createTestInput(groupBy)).toEqual({ 168 | UP: [zone16StartPoint, zone15StartPoint, zone14StartPoint, zone13StartPoint, zone4StartPoint, zone3StartPoint, zone2StartPoint, zone1StartPoint], 169 | UP_RIGHT: [], 170 | RIGHT: [], 171 | DOWN_RIGHT: [], 172 | DOWN: [zone12StartPoint, zone11StartPoint, zone10StartPoint, zone9StartPoint, zone8StartPoint, zone7StartPoint, zone6StartPoint, zone5StartPoint], 173 | DOWN_LEFT: [], 174 | LEFT: [], 175 | UP_LEFT: [], 176 | }) 177 | }) 178 | 179 | test('points are groupBy by 2 directions and sorted in ascending distance', () => { 180 | const groupBy = groupByDirection( 181 | { 182 | UP: 'Distance', RIGHT: 'Distance', 183 | }, 184 | testPoints.centerPoint, 185 | testPointsList, 186 | ) 187 | 188 | expect(createTestInput(groupBy)).toEqual({ 189 | UP: [zone16StartPoint, zone15StartPoint, zone14StartPoint, zone13StartPoint, zone2StartPoint, zone1StartPoint], 190 | UP_RIGHT: [], 191 | RIGHT: [zone8StartPoint, zone7StartPoint, zone6StartPoint, zone5StartPoint, zone4StartPoint, zone3StartPoint], 192 | DOWN_RIGHT: [], 193 | DOWN: [], 194 | DOWN_LEFT: [], 195 | LEFT: [], 196 | UP_LEFT: [], 197 | }) 198 | }) 199 | 200 | test('points are groupBy by 1 direction and sorted in ascending distance', () => { 201 | const groupBy = groupByDirection( 202 | { 203 | UP: 'Distance', 204 | }, 205 | testPoints.centerPoint, 206 | testPointsList, 207 | ) 208 | 209 | expect(createTestInput(groupBy)).toEqual({ 210 | UP: [zone16StartPoint, zone15StartPoint, zone14StartPoint, zone13StartPoint, zone4StartPoint, zone3StartPoint, zone2StartPoint, zone1StartPoint], 211 | UP_RIGHT: [], 212 | RIGHT: [], 213 | DOWN_RIGHT: [], 214 | DOWN: [], 215 | DOWN_LEFT: [], 216 | LEFT: [], 217 | UP_LEFT: [], 218 | }) 219 | }) 220 | }) 221 | 222 | describe('test groupByDirection in `Secant` calculation mode', () => { 223 | 224 | test('points are groupBy by 8 directions and sorted in ascending distance', () => { 225 | const groupBy = groupByDirection( 226 | { 227 | UP: 'Secant', UP_RIGHT: 'Secant', RIGHT: 'Secant', DOWN_RIGHT: 'Secant', 228 | DOWN: 'Secant', DOWN_LEFT: 'Secant', LEFT: 'Secant', UP_LEFT: 'Secant', 229 | }, 230 | testPoints.centerPoint, 231 | testPointsList, 232 | ) 233 | 234 | expect(createTestInput(groupBy)).toEqual({ 235 | UP: [zone16StartPoint, zone1StartPoint], 236 | UP_RIGHT: [zone3StartPoint, zone2StartPoint], 237 | RIGHT: [zone5StartPoint, zone4StartPoint], 238 | DOWN_RIGHT: [zone7StartPoint, zone6StartPoint], 239 | DOWN: [zone9StartPoint, zone8StartPoint], 240 | DOWN_LEFT: [zone11StartPoint, zone10StartPoint], 241 | LEFT: [zone13StartPoint, zone12StartPoint], 242 | UP_LEFT: [zone15StartPoint, zone14StartPoint], 243 | }) 244 | }) 245 | 246 | test('points are groupBy by 4 directions and sorted in ascending Secant', () => { 247 | const groupBy = groupByDirection( 248 | { 249 | UP: 'Secant', RIGHT: 'Secant', 250 | DOWN: 'Secant', LEFT: 'Secant', 251 | }, 252 | testPoints.centerPoint, 253 | testPointsList, 254 | ) 255 | 256 | expect(createTestInput(groupBy)).toEqual({ 257 | UP: [zone16StartPoint, zone1StartPoint, zone2StartPoint, zone15StartPoint], 258 | UP_RIGHT: [], 259 | RIGHT: [zone5StartPoint, zone6StartPoint, zone4StartPoint, zone3StartPoint], 260 | DOWN_RIGHT: [], 261 | DOWN: [zone9StartPoint, zone10StartPoint, zone8StartPoint, zone7StartPoint], 262 | DOWN_LEFT: [], 263 | LEFT: [zone13StartPoint, zone14StartPoint, zone12StartPoint, zone11StartPoint], 264 | UP_LEFT: [], 265 | }) 266 | }) 267 | 268 | test('points are groupBy by 4 directions and sorted in ascending Secant (2)', () => { 269 | const groupBy = groupByDirection( 270 | { 271 | UP_RIGHT: 'Secant', DOWN_RIGHT: 'Secant', 272 | DOWN_LEFT: 'Secant', UP_LEFT: 'Secant', 273 | }, 274 | testPoints.centerPoint, 275 | testPointsList, 276 | ) 277 | 278 | expect(createTestInput(groupBy)).toEqual({ 279 | UP: [], 280 | UP_RIGHT: [zone3StartPoint, zone4StartPoint, zone2StartPoint, zone1StartPoint], 281 | RIGHT: [], 282 | DOWN_RIGHT: [zone7StartPoint, zone8StartPoint, zone6StartPoint, zone5StartPoint], 283 | DOWN: [], 284 | DOWN_LEFT: [zone11StartPoint, zone12StartPoint, zone10StartPoint, zone9StartPoint], 285 | LEFT: [], 286 | UP_LEFT: [zone15StartPoint, zone16StartPoint, zone14StartPoint, zone13StartPoint], 287 | }) 288 | }) 289 | 290 | test('points are groupBy by 4 directions and sorted in ascending Secant (3)', () => { 291 | const groupBy = groupByDirection( 292 | { 293 | DOWN: 'Secant', DOWN_LEFT: 'Secant', LEFT: 'Secant', UP_LEFT: 'Secant', 294 | }, 295 | testPoints.centerPoint, 296 | testPointsList, 297 | ) 298 | 299 | expect(createTestInput(groupBy)).toEqual({ 300 | UP: [], 301 | UP_RIGHT: [], 302 | RIGHT: [], 303 | DOWN_RIGHT: [], 304 | DOWN: [zone9StartPoint, zone8StartPoint, zone7StartPoint, zone6StartPoint, zone5StartPoint], 305 | DOWN_LEFT: [zone11StartPoint, zone10StartPoint], 306 | LEFT: [zone13StartPoint, zone12StartPoint], 307 | UP_LEFT: [zone15StartPoint, zone16StartPoint, zone14StartPoint, zone1StartPoint, zone2StartPoint], 308 | }) 309 | }) 310 | 311 | test('points are groupBy by 4 directions and sorted in ascending Secant (4)', () => { 312 | const groupBy = groupByDirection( 313 | { 314 | UP: 'Secant', UP_RIGHT: 'Secant', 315 | DOWN: 'Secant', DOWN_LEFT: 'Secant', 316 | }, 317 | testPoints.centerPoint, 318 | testPointsList, 319 | ) 320 | 321 | expect(createTestInput(groupBy)).toEqual({ 322 | UP: [zone16StartPoint, zone1StartPoint, zone15StartPoint, zone14StartPoint], 323 | UP_RIGHT: [zone3StartPoint, zone4StartPoint, zone2StartPoint, zone5StartPoint], 324 | RIGHT: [], 325 | DOWN_RIGHT: [], 326 | DOWN: [zone9StartPoint, zone8StartPoint, zone7StartPoint, zone6StartPoint], 327 | DOWN_LEFT: [zone11StartPoint, zone12StartPoint, zone10StartPoint, zone13StartPoint], 328 | LEFT: [], 329 | UP_LEFT: [], 330 | }) 331 | }) 332 | 333 | test('points are groupBy by 2 directions and sorted in ascending Secant', () => { 334 | const groupBy = groupByDirection( 335 | { 336 | UP: 'Secant', DOWN: 'Secant', 337 | }, 338 | testPoints.centerPoint, 339 | testPointsList, 340 | ) 341 | 342 | expect(createTestInput(groupBy)).toEqual({ 343 | UP: [zone16StartPoint, zone1StartPoint, zone2StartPoint, zone15StartPoint, zone3StartPoint, zone14StartPoint, zone4StartPoint, zone13StartPoint], 344 | UP_RIGHT: [], 345 | RIGHT: [], 346 | DOWN_RIGHT: [], 347 | DOWN: [zone9StartPoint, zone10StartPoint, zone8StartPoint, zone11StartPoint, zone7StartPoint, zone6StartPoint, zone12StartPoint, zone5StartPoint], 348 | DOWN_LEFT: [], 349 | LEFT: [], 350 | UP_LEFT: [], 351 | }) 352 | }) 353 | 354 | test('points are groupBy by 2 directions and sorted in ascending Secant', () => { 355 | const groupBy = groupByDirection( 356 | { 357 | UP: 'Secant', RIGHT: 'Secant', 358 | }, 359 | testPoints.centerPoint, 360 | testPointsList, 361 | ) 362 | 363 | expect(createTestInput(groupBy)).toEqual({ 364 | UP: [zone16StartPoint, zone1StartPoint, zone2StartPoint, zone15StartPoint, zone14StartPoint, zone13StartPoint], 365 | UP_RIGHT: [], 366 | RIGHT: [zone5StartPoint, zone6StartPoint, zone4StartPoint, zone7StartPoint, zone3StartPoint, zone8StartPoint], 367 | DOWN_RIGHT: [], 368 | DOWN: [], 369 | DOWN_LEFT: [], 370 | LEFT: [], 371 | UP_LEFT: [], 372 | }) 373 | }) 374 | 375 | test('points are groupBy by 1 direction and sorted in ascending Secant', () => { 376 | const groupBy = groupByDirection( 377 | { 378 | UP: 'Secant', 379 | }, 380 | testPoints.centerPoint, 381 | testPointsList, 382 | ) 383 | 384 | expect(createTestInput(groupBy)).toEqual({ 385 | UP: [zone16StartPoint, zone1StartPoint, zone2StartPoint, zone15StartPoint, zone3StartPoint, zone14StartPoint, zone4StartPoint, zone13StartPoint], 386 | UP_RIGHT: [], 387 | RIGHT: [], 388 | DOWN_RIGHT: [], 389 | DOWN: [], 390 | DOWN_LEFT: [], 391 | LEFT: [], 392 | UP_LEFT: [], 393 | }) 394 | }) 395 | }) 396 | 397 | describe('test groupByDirection in `Cosine` calculation mode', () => { 398 | 399 | test('points are groupBy by 8 directions and sorted in ascending distance', () => { 400 | const groupBy = groupByDirection( 401 | { 402 | UP: 'Cosine', UP_RIGHT: 'Cosine', RIGHT: 'Cosine', DOWN_RIGHT: 'Cosine', 403 | DOWN: 'Cosine', DOWN_LEFT: 'Cosine', LEFT: 'Cosine', UP_LEFT: 'Cosine', 404 | }, 405 | testPoints.centerPoint, 406 | testPointsList, 407 | ) 408 | 409 | expect(createTestInput(groupBy)).toEqual({ 410 | UP: [zone16StartPoint, zone1StartPoint], 411 | UP_RIGHT: [zone2StartPoint, zone3StartPoint], 412 | RIGHT: [zone4StartPoint, zone5StartPoint], 413 | DOWN_RIGHT: [zone6StartPoint, zone7StartPoint], 414 | DOWN: [zone8StartPoint, zone9StartPoint], 415 | DOWN_LEFT: [zone10StartPoint, zone11StartPoint], 416 | LEFT: [zone12StartPoint, zone13StartPoint], 417 | UP_LEFT: [zone14StartPoint, zone15StartPoint], 418 | }) 419 | }) 420 | 421 | test('points are groupBy by 4 directions and sorted in ascending Cosine', () => { 422 | const groupBy = groupByDirection( 423 | { 424 | UP: 'Cosine', RIGHT: 'Cosine', 425 | DOWN: 'Cosine', LEFT: 'Cosine', 426 | }, 427 | testPoints.centerPoint, 428 | testPointsList, 429 | ) 430 | 431 | expect(createTestInput(groupBy)).toEqual({ 432 | UP: [zone15StartPoint, zone16StartPoint, zone2StartPoint, zone1StartPoint], 433 | UP_RIGHT: [], 434 | RIGHT: [zone3StartPoint, zone6StartPoint, zone4StartPoint, zone5StartPoint], 435 | DOWN_RIGHT: [], 436 | DOWN: [zone7StartPoint, zone10StartPoint, zone8StartPoint, zone9StartPoint], 437 | DOWN_LEFT: [], 438 | LEFT: [zone11StartPoint, zone14StartPoint, zone12StartPoint, zone13StartPoint], 439 | UP_LEFT: [], 440 | }) 441 | }) 442 | 443 | test('points are groupBy by 4 directions and sorted in ascending Cosine (2)', () => { 444 | const groupBy = groupByDirection( 445 | { 446 | UP_RIGHT: 'Cosine', DOWN_RIGHT: 'Cosine', 447 | DOWN_LEFT: 'Cosine', UP_LEFT: 'Cosine', 448 | }, 449 | testPoints.centerPoint, 450 | testPointsList, 451 | ) 452 | 453 | expect(createTestInput(groupBy)).toEqual({ 454 | UP: [], 455 | UP_RIGHT: [zone1StartPoint, zone4StartPoint, zone2StartPoint, zone3StartPoint], 456 | RIGHT: [], 457 | DOWN_RIGHT: [zone5StartPoint, zone8StartPoint, zone6StartPoint, zone7StartPoint], 458 | DOWN: [], 459 | DOWN_LEFT: [zone9StartPoint, zone12StartPoint, zone10StartPoint, zone11StartPoint], 460 | LEFT: [], 461 | UP_LEFT: [zone13StartPoint, zone16StartPoint, zone14StartPoint, zone15StartPoint], 462 | }) 463 | }) 464 | 465 | test('points are groupBy by 4 directions and sorted in ascending Cosine (3)', () => { 466 | const groupBy = groupByDirection( 467 | { 468 | DOWN: 'Cosine', DOWN_LEFT: 'Cosine', LEFT: 'Cosine', UP_LEFT: 'Cosine', 469 | }, 470 | testPoints.centerPoint, 471 | testPointsList, 472 | ) 473 | 474 | expect(createTestInput(groupBy)).toEqual({ 475 | UP: [], 476 | UP_RIGHT: [], 477 | RIGHT: [], 478 | DOWN_RIGHT: [], 479 | DOWN: [zone5StartPoint, zone6StartPoint, zone7StartPoint, zone8StartPoint, zone9StartPoint], 480 | DOWN_LEFT: [zone10StartPoint, zone11StartPoint], 481 | LEFT: [zone12StartPoint, zone13StartPoint], 482 | UP_LEFT: [zone2StartPoint, zone1StartPoint, zone16StartPoint, zone14StartPoint, zone15StartPoint], 483 | }) 484 | }) 485 | 486 | test('points are groupBy by 4 directions and sorted in ascending Cosine (4)', () => { 487 | const groupBy = groupByDirection( 488 | { 489 | UP: 'Cosine', UP_RIGHT: 'Cosine', 490 | DOWN: 'Cosine', DOWN_LEFT: 'Cosine', 491 | }, 492 | testPoints.centerPoint, 493 | testPointsList, 494 | ) 495 | 496 | expect(createTestInput(groupBy)).toEqual({ 497 | UP: [zone14StartPoint, zone15StartPoint, zone16StartPoint, zone1StartPoint], 498 | UP_RIGHT: [zone5StartPoint, zone4StartPoint, zone2StartPoint, zone3StartPoint], 499 | RIGHT: [], 500 | DOWN_RIGHT: [], 501 | DOWN: [zone6StartPoint, zone7StartPoint, zone8StartPoint, zone9StartPoint], 502 | DOWN_LEFT: [zone13StartPoint, zone12StartPoint, zone10StartPoint, zone11StartPoint], 503 | LEFT: [], 504 | UP_LEFT: [], 505 | }) 506 | }) 507 | 508 | test('points are groupBy by 2 directions and sorted in ascending Cosine', () => { 509 | const groupBy = groupByDirection( 510 | { 511 | UP: 'Cosine', DOWN: 'Cosine', 512 | }, 513 | testPoints.centerPoint, 514 | testPointsList, 515 | ) 516 | 517 | expect(createTestInput(groupBy)).toEqual({ 518 | UP: [zone13StartPoint, zone14StartPoint, zone4StartPoint, zone15StartPoint, zone3StartPoint, zone16StartPoint, zone2StartPoint, zone1StartPoint], 519 | UP_RIGHT: [], 520 | RIGHT: [], 521 | DOWN_RIGHT: [], 522 | DOWN: [zone5StartPoint, zone12StartPoint, zone6StartPoint, zone11StartPoint, zone7StartPoint, zone10StartPoint, zone8StartPoint, zone9StartPoint], 523 | DOWN_LEFT: [], 524 | LEFT: [], 525 | UP_LEFT: [], 526 | }) 527 | }) 528 | 529 | test('points are groupBy by 2 directions and sorted in ascending Cosine', () => { 530 | const groupBy = groupByDirection( 531 | { 532 | UP: 'Cosine', RIGHT: 'Cosine', 533 | }, 534 | testPoints.centerPoint, 535 | testPointsList, 536 | ) 537 | 538 | expect(createTestInput(groupBy)).toEqual({ 539 | UP: [zone13StartPoint, zone14StartPoint, zone15StartPoint, zone16StartPoint, zone2StartPoint, zone1StartPoint], 540 | UP_RIGHT: [], 541 | RIGHT: [zone8StartPoint, zone7StartPoint, zone3StartPoint, zone6StartPoint, zone4StartPoint, zone5StartPoint], 542 | DOWN_RIGHT: [], 543 | DOWN: [], 544 | DOWN_LEFT: [], 545 | LEFT: [], 546 | UP_LEFT: [], 547 | }) 548 | }) 549 | 550 | test('points are groupBy by 1 direction and sorted in ascending Cosine', () => { 551 | const groupBy = groupByDirection( 552 | { 553 | UP: 'Cosine', 554 | }, 555 | testPoints.centerPoint, 556 | testPointsList, 557 | ) 558 | 559 | expect(createTestInput(groupBy)).toEqual({ 560 | UP: [zone13StartPoint, zone14StartPoint, zone4StartPoint, zone15StartPoint, zone3StartPoint, zone16StartPoint, zone2StartPoint, zone1StartPoint], 561 | UP_RIGHT: [], 562 | RIGHT: [], 563 | DOWN_RIGHT: [], 564 | DOWN: [], 565 | DOWN_LEFT: [], 566 | LEFT: [], 567 | UP_LEFT: [], 568 | }) 569 | }) 570 | }) 571 | 572 | describe('test groupByDirection in `Sine` calculation mode', () => { 573 | 574 | test('points are groupBy by 8 directions and sorted in ascending distance', () => { 575 | const groupBy = groupByDirection( 576 | { 577 | UP: 'Sine', UP_RIGHT: 'Sine', RIGHT: 'Sine', DOWN_RIGHT: 'Sine', 578 | DOWN: 'Sine', DOWN_LEFT: 'Sine', LEFT: 'Sine', UP_LEFT: 'Sine', 579 | }, 580 | testPoints.centerPoint, 581 | testPointsList, 582 | ) 583 | 584 | expect(createTestInput(groupBy)).toEqual({ 585 | UP: [zone1StartPoint, zone16StartPoint], 586 | UP_RIGHT: [zone3StartPoint, zone2StartPoint], 587 | RIGHT: [zone5StartPoint, zone4StartPoint], 588 | DOWN_RIGHT: [zone7StartPoint, zone6StartPoint], 589 | DOWN: [zone9StartPoint, zone8StartPoint], 590 | DOWN_LEFT: [zone11StartPoint, zone10StartPoint], 591 | LEFT: [zone13StartPoint, zone12StartPoint], 592 | UP_LEFT: [zone15StartPoint, zone14StartPoint], 593 | }) 594 | }) 595 | 596 | test('points are groupBy by 4 directions and sorted in ascending Sine', () => { 597 | const groupBy = groupByDirection( 598 | { 599 | UP: 'Sine', RIGHT: 'Sine', 600 | DOWN: 'Sine', LEFT: 'Sine', 601 | }, 602 | testPoints.centerPoint, 603 | testPointsList, 604 | ) 605 | 606 | expect(createTestInput(groupBy)).toEqual({ 607 | UP: [zone1StartPoint, zone16StartPoint, zone2StartPoint, zone15StartPoint], 608 | UP_RIGHT: [], 609 | RIGHT: [zone5StartPoint, zone4StartPoint, zone6StartPoint, zone3StartPoint], 610 | DOWN_RIGHT: [], 611 | DOWN: [zone9StartPoint, zone8StartPoint, zone10StartPoint, zone7StartPoint], 612 | DOWN_LEFT: [], 613 | LEFT: [zone13StartPoint, zone12StartPoint, zone14StartPoint, zone11StartPoint], 614 | UP_LEFT: [], 615 | }) 616 | }) 617 | 618 | test('points are groupBy by 4 directions and sorted in ascending Sine (2)', () => { 619 | const groupBy = groupByDirection( 620 | { 621 | UP_RIGHT: 'Sine', DOWN_RIGHT: 'Sine', 622 | DOWN_LEFT: 'Sine', UP_LEFT: 'Sine', 623 | }, 624 | testPoints.centerPoint, 625 | testPointsList, 626 | ) 627 | 628 | expect(createTestInput(groupBy)).toEqual({ 629 | UP: [], 630 | UP_RIGHT: [zone3StartPoint, zone2StartPoint, zone4StartPoint, zone1StartPoint], 631 | RIGHT: [], 632 | DOWN_RIGHT: [zone7StartPoint, zone6StartPoint, zone8StartPoint, zone5StartPoint], 633 | DOWN: [], 634 | DOWN_LEFT: [zone11StartPoint, zone10StartPoint, zone12StartPoint, zone9StartPoint], 635 | LEFT: [], 636 | UP_LEFT: [zone15StartPoint, zone14StartPoint, zone16StartPoint, zone13StartPoint], 637 | }) 638 | }) 639 | 640 | test('points are groupBy by 4 directions and sorted in ascending Sine (3)', () => { 641 | const groupBy = groupByDirection( 642 | { 643 | DOWN: 'Sine', DOWN_LEFT: 'Sine', LEFT: 'Sine', UP_LEFT: 'Sine', 644 | }, 645 | testPoints.centerPoint, 646 | testPointsList, 647 | ) 648 | 649 | expect(createTestInput(groupBy)).toEqual({ 650 | UP: [], 651 | UP_RIGHT: [], 652 | RIGHT: [], 653 | DOWN_RIGHT: [], 654 | DOWN: [zone9StartPoint, zone8StartPoint, zone7StartPoint, zone6StartPoint, zone5StartPoint], 655 | DOWN_LEFT: [zone11StartPoint, zone10StartPoint], 656 | LEFT: [zone13StartPoint, zone12StartPoint], 657 | UP_LEFT: [zone15StartPoint, zone14StartPoint, zone16StartPoint, zone1StartPoint, zone2StartPoint], 658 | }) 659 | }) 660 | 661 | test('points are groupBy by 4 directions and sorted in ascending Sine (4)', () => { 662 | const groupBy = groupByDirection( 663 | { 664 | UP: 'Sine', UP_RIGHT: 'Sine', 665 | DOWN: 'Sine', DOWN_LEFT: 'Sine', 666 | }, 667 | testPoints.centerPoint, 668 | testPointsList, 669 | ) 670 | 671 | expect(createTestInput(groupBy)).toEqual({ 672 | UP: [zone1StartPoint, zone16StartPoint, zone15StartPoint, zone14StartPoint], 673 | UP_RIGHT: [zone3StartPoint, zone2StartPoint, zone4StartPoint, zone5StartPoint], 674 | RIGHT: [], 675 | DOWN_RIGHT: [], 676 | DOWN: [zone9StartPoint, zone8StartPoint, zone7StartPoint, zone6StartPoint], 677 | DOWN_LEFT: [zone11StartPoint, zone10StartPoint, zone12StartPoint, zone13StartPoint], 678 | LEFT: [], 679 | UP_LEFT: [], 680 | }) 681 | }) 682 | 683 | test('points are groupBy by 2 directions and sorted in ascending Sine', () => { 684 | const groupBy = groupByDirection( 685 | { 686 | UP: 'Sine', DOWN: 'Sine', 687 | }, 688 | testPoints.centerPoint, 689 | testPointsList, 690 | ) 691 | 692 | expect(createTestInput(groupBy)).toEqual({ 693 | UP: [zone1StartPoint, zone16StartPoint, zone2StartPoint, zone15StartPoint, zone3StartPoint, zone14StartPoint, zone13StartPoint, zone4StartPoint], 694 | UP_RIGHT: [], 695 | RIGHT: [], 696 | DOWN_RIGHT: [], 697 | DOWN: [zone9StartPoint, zone8StartPoint, zone10StartPoint, zone11StartPoint, zone7StartPoint, zone12StartPoint, zone6StartPoint, zone5StartPoint], 698 | DOWN_LEFT: [], 699 | LEFT: [], 700 | UP_LEFT: [], 701 | }) 702 | }) 703 | 704 | test('points are groupBy by 2 directions and sorted in ascending Sine', () => { 705 | const groupBy = groupByDirection( 706 | { 707 | UP: 'Sine', RIGHT: 'Sine', 708 | }, 709 | testPoints.centerPoint, 710 | testPointsList, 711 | ) 712 | 713 | expect(createTestInput(groupBy)).toEqual({ 714 | UP: [zone1StartPoint, zone16StartPoint, zone2StartPoint, zone15StartPoint, zone14StartPoint, zone13StartPoint], 715 | UP_RIGHT: [], 716 | RIGHT: [zone5StartPoint, zone4StartPoint, zone6StartPoint, zone7StartPoint, zone3StartPoint, zone8StartPoint], 717 | DOWN_RIGHT: [], 718 | DOWN: [], 719 | DOWN_LEFT: [], 720 | LEFT: [], 721 | UP_LEFT: [], 722 | }) 723 | }) 724 | 725 | test('points are groupBy by 1 direction and sorted in ascending Sine', () => { 726 | const groupBy = groupByDirection( 727 | { 728 | UP: 'Sine', 729 | }, 730 | testPoints.centerPoint, 731 | testPointsList, 732 | ) 733 | 734 | expect(createTestInput(groupBy)).toEqual({ 735 | UP: [zone1StartPoint, zone16StartPoint, zone2StartPoint, zone15StartPoint, zone3StartPoint, zone14StartPoint, zone13StartPoint, zone4StartPoint], 736 | UP_RIGHT: [], 737 | RIGHT: [], 738 | DOWN_RIGHT: [], 739 | DOWN: [], 740 | DOWN_LEFT: [], 741 | LEFT: [], 742 | UP_LEFT: [], 743 | }) 744 | }) 745 | }) 746 | 747 | describe('test groupByDirection in `Tangent` calculation mode', () => { 748 | 749 | test('points are groupBy by 8 directions and sorted in ascending distance', () => { 750 | const groupBy = groupByDirection( 751 | { 752 | UP: 'Tangent', UP_RIGHT: 'Tangent', RIGHT: 'Tangent', DOWN_RIGHT: 'Tangent', 753 | DOWN: 'Tangent', DOWN_LEFT: 'Tangent', LEFT: 'Tangent', UP_LEFT: 'Tangent', 754 | }, 755 | testPoints.centerPoint, 756 | testPointsList, 757 | ) 758 | 759 | expect(createTestInput(groupBy)).toEqual({ 760 | UP: [zone1StartPoint, zone16StartPoint], 761 | UP_RIGHT: [zone3StartPoint, zone2StartPoint], 762 | RIGHT: [zone5StartPoint, zone4StartPoint], 763 | DOWN_RIGHT: [zone7StartPoint, zone6StartPoint], 764 | DOWN: [zone9StartPoint, zone8StartPoint], 765 | DOWN_LEFT: [zone11StartPoint, zone10StartPoint], 766 | LEFT: [zone13StartPoint, zone12StartPoint], 767 | UP_LEFT: [zone15StartPoint, zone14StartPoint], 768 | }) 769 | }) 770 | 771 | test('points are groupBy by 4 directions and sorted in ascending Tangent', () => { 772 | const groupBy = groupByDirection( 773 | { 774 | UP: 'Tangent', RIGHT: 'Tangent', 775 | DOWN: 'Tangent', LEFT: 'Tangent', 776 | }, 777 | testPoints.centerPoint, 778 | testPointsList, 779 | ) 780 | 781 | expect(createTestInput(groupBy)).toEqual({ 782 | UP: [zone1StartPoint, zone16StartPoint, zone2StartPoint, zone15StartPoint], 783 | UP_RIGHT: [], 784 | RIGHT: [zone5StartPoint, zone4StartPoint, zone6StartPoint, zone3StartPoint], 785 | DOWN_RIGHT: [], 786 | DOWN: [zone9StartPoint, zone8StartPoint, zone10StartPoint, zone7StartPoint], 787 | DOWN_LEFT: [], 788 | LEFT: [zone13StartPoint, zone12StartPoint, zone14StartPoint, zone11StartPoint], 789 | UP_LEFT: [], 790 | }) 791 | }) 792 | 793 | test('points are groupBy by 4 directions and sorted in ascending Tangent (2)', () => { 794 | const groupBy = groupByDirection( 795 | { 796 | UP_RIGHT: 'Tangent', DOWN_RIGHT: 'Tangent', 797 | DOWN_LEFT: 'Tangent', UP_LEFT: 'Tangent', 798 | }, 799 | testPoints.centerPoint, 800 | testPointsList, 801 | ) 802 | 803 | expect(createTestInput(groupBy)).toEqual({ 804 | UP: [], 805 | UP_RIGHT: [zone3StartPoint, zone2StartPoint, zone4StartPoint, zone1StartPoint], 806 | RIGHT: [], 807 | DOWN_RIGHT: [zone7StartPoint, zone6StartPoint, zone8StartPoint, zone5StartPoint], 808 | DOWN: [], 809 | DOWN_LEFT: [zone11StartPoint, zone10StartPoint, zone12StartPoint, zone9StartPoint], 810 | LEFT: [], 811 | UP_LEFT: [zone15StartPoint, zone14StartPoint, zone16StartPoint, zone13StartPoint], 812 | }) 813 | }) 814 | 815 | test('points are groupBy by 4 directions and sorted in ascending Tangent (3)', () => { 816 | const groupBy = groupByDirection( 817 | { 818 | DOWN: 'Tangent', DOWN_LEFT: 'Tangent', LEFT: 'Tangent', UP_LEFT: 'Tangent', 819 | }, 820 | testPoints.centerPoint, 821 | testPointsList, 822 | ) 823 | 824 | expect(createTestInput(groupBy)).toEqual({ 825 | UP: [], 826 | UP_RIGHT: [], 827 | RIGHT: [], 828 | DOWN_RIGHT: [], 829 | DOWN: [zone9StartPoint, zone8StartPoint, zone7StartPoint, zone6StartPoint, zone5StartPoint], 830 | DOWN_LEFT: [zone11StartPoint, zone10StartPoint], 831 | LEFT: [zone13StartPoint, zone12StartPoint], 832 | UP_LEFT: [zone15StartPoint, zone14StartPoint, zone16StartPoint, zone1StartPoint, zone2StartPoint], 833 | }) 834 | }) 835 | 836 | test('points are groupBy by 4 directions and sorted in ascending Tangent (4)', () => { 837 | const groupBy = groupByDirection( 838 | { 839 | UP: 'Tangent', UP_RIGHT: 'Tangent', 840 | DOWN: 'Tangent', DOWN_LEFT: 'Tangent', 841 | }, 842 | testPoints.centerPoint, 843 | testPointsList, 844 | ) 845 | 846 | expect(createTestInput(groupBy)).toEqual({ 847 | UP: [zone1StartPoint, zone16StartPoint, zone15StartPoint, zone14StartPoint], 848 | UP_RIGHT: [zone3StartPoint, zone2StartPoint, zone4StartPoint, zone5StartPoint], 849 | RIGHT: [], 850 | DOWN_RIGHT: [], 851 | DOWN: [zone9StartPoint, zone8StartPoint, zone7StartPoint, zone6StartPoint], 852 | DOWN_LEFT: [zone11StartPoint, zone10StartPoint, zone12StartPoint, zone13StartPoint], 853 | LEFT: [], 854 | UP_LEFT: [], 855 | }) 856 | }) 857 | 858 | test('points are groupBy by 2 directions and sorted in ascending Tangent', () => { 859 | const groupBy = groupByDirection( 860 | { 861 | UP: 'Tangent', DOWN: 'Tangent', 862 | }, 863 | testPoints.centerPoint, 864 | testPointsList, 865 | ) 866 | 867 | expect(createTestInput(groupBy)).toEqual({ 868 | UP: [zone1StartPoint, zone16StartPoint, zone2StartPoint, zone15StartPoint, zone3StartPoint, zone14StartPoint, zone4StartPoint, zone13StartPoint], 869 | UP_RIGHT: [], 870 | RIGHT: [], 871 | DOWN_RIGHT: [], 872 | DOWN: [zone9StartPoint, zone8StartPoint, zone10StartPoint, zone7StartPoint, zone11StartPoint, zone6StartPoint, zone12StartPoint, zone5StartPoint], 873 | DOWN_LEFT: [], 874 | LEFT: [], 875 | UP_LEFT: [], 876 | }) 877 | }) 878 | 879 | test('points are groupBy by 2 directions and sorted in ascending Tangent', () => { 880 | const groupBy = groupByDirection( 881 | { 882 | UP: 'Tangent', RIGHT: 'Tangent', 883 | }, 884 | testPoints.centerPoint, 885 | testPointsList, 886 | ) 887 | 888 | expect(createTestInput(groupBy)).toEqual({ 889 | UP: [zone1StartPoint, zone16StartPoint, zone2StartPoint, zone15StartPoint, zone14StartPoint, zone13StartPoint], 890 | UP_RIGHT: [], 891 | RIGHT: [zone5StartPoint, zone4StartPoint, zone6StartPoint, zone3StartPoint, zone7StartPoint, zone8StartPoint], 892 | DOWN_RIGHT: [], 893 | DOWN: [], 894 | DOWN_LEFT: [], 895 | LEFT: [], 896 | UP_LEFT: [], 897 | }) 898 | }) 899 | 900 | test('points are groupBy by 1 direction and sorted in ascending Tangent', () => { 901 | const groupBy = groupByDirection( 902 | { 903 | UP: 'Tangent', 904 | }, 905 | testPoints.centerPoint, 906 | testPointsList, 907 | ) 908 | 909 | expect(createTestInput(groupBy)).toEqual({ 910 | UP: [zone1StartPoint, zone16StartPoint, zone2StartPoint, zone15StartPoint, zone3StartPoint, zone14StartPoint, zone4StartPoint, zone13StartPoint], 911 | UP_RIGHT: [], 912 | RIGHT: [], 913 | DOWN_RIGHT: [], 914 | DOWN: [], 915 | DOWN_LEFT: [], 916 | LEFT: [], 917 | UP_LEFT: [], 918 | }) 919 | }) 920 | }) 921 | --------------------------------------------------------------------------------