",
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 |
54 |
55 | {sortedInterests.map(interest => (
56 | setActivePictureName(interest) }
60 | active={interest === activePictureName} onActiveChange={() => setActivePictureName(interest)}
61 | >
62 |
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 |
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 |
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 | [](https://nodei.co/npm/react-keyboard-navigator/)
4 |
5 | 
6 | 
7 | [](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 | 
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 | 
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 |
--------------------------------------------------------------------------------