├── .gitignore
├── README.md
├── index.d.ts
├── index.test.ts
├── index.ts
├── jest.config.js
├── package.json
└── pnpm-lock.yaml
/.gitignore:
--------------------------------------------------------------------------------
1 | # Mac files
2 | .DS_Store
3 |
4 | # NPM
5 | .npm
6 | node_modules/
7 | npm-debug.log*
8 |
9 | # Yarn
10 | .yarn-integrity
11 | yarn-debug.log*
12 | yarn-error.log*
13 |
14 | # General
15 | coverage
16 | dist
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ⌨️ use-roving-index
2 |
3 | Manage an active index that needs to be contained or wrapped.
4 |
5 | ## Install
6 |
7 | ```bash
8 | yarn add use-roving-index
9 | ```
10 |
11 | ```bash
12 | npm install use-roving-index
13 | ```
14 |
15 | ## Usage
16 |
17 | ```jsx
18 | import React from 'react'
19 | import { useRovingIndex } from 'use-roving-index'
20 |
21 | const items = ['Item 1', 'Item 2', 'Item 3']
22 |
23 | function App() {
24 | const {
25 | activeIndex,
26 | moveActiveIndex,
27 | moveBackward,
28 | moveBackwardDisabled,
29 | moveForward,
30 | moveForwardDisabled,
31 | } = useRovingIndex({ maxIndex: items.length - 1 })
32 | return (
33 | <>
34 |
{
37 | const multiplier = event.shiftKey ? 3 : 1
38 | switch (event.key) {
39 | case 'ArrowUp':
40 | case 'ArrowLeft':
41 | moveActiveIndex(-1 * multiplier)
42 | break
43 | case 'ArrowDown':
44 | case 'ArrowRight':
45 | moveActiveIndex(1 * multiplier)
46 | break
47 | }
48 | }}
49 | >
50 | {items.map((title, index) => (
51 | -
55 | {title}
56 |
57 | ))}
58 |
59 |
62 |
65 | >
66 | )
67 | }
68 | ```
69 |
--------------------------------------------------------------------------------
/index.d.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Manage an active index that needs to be contained or wrap.
3 | *
4 | * @example
5 | *
6 | * const {
7 | * activeIndex,
8 | * moveActiveIndex,
9 | * } = useRovingIndex({ maxIndex: items.length - 1 })
10 | */
11 | export declare function useRovingIndex({ defaultIndex, maxIndex, type, }: {
12 | /** The default index used when first mounting. */
13 | defaultIndex?: number;
14 | /** Whether or not to contain the index. */
15 | contain?: boolean;
16 | /** The max index used to know when to contain or wrap. */
17 | maxIndex?: number;
18 | /** How to handle navigation when exceeding minimum and maximum indexes. */
19 | type?: 'contain' | 'wrap' | 'none';
20 | }): {
21 | /** The active index. */
22 | activeIndex: number;
23 | /** The previously set index. */
24 | previousIndex: null | number;
25 | /** Whether the active index can be moved backward. */
26 | moveBackwardDisabled: boolean;
27 | /** Whether the active index can be moved forward. */
28 | moveForwardDisabled: boolean;
29 | /** Move the index backwards. */
30 | moveBackward: () => void;
31 | /** Move the index forwards. */
32 | moveForward: () => void;
33 | /** Move the active index by a positive or negative amount. */
34 | moveActiveIndex: (amount: number) => void;
35 | /** Set any active index. */
36 | setActiveIndex: (nextIndex: number) => void;
37 | };
38 |
--------------------------------------------------------------------------------
/index.test.ts:
--------------------------------------------------------------------------------
1 | import { renderHook, act } from '@testing-library/react'
2 | import { useRovingIndex } from './dist'
3 |
4 | test('index contains by default', () => {
5 | const maxIndex = 3
6 | const { result } = renderHook(() => useRovingIndex({ maxIndex }))
7 |
8 | act(() => {
9 | result.current.setActiveIndex(5)
10 | })
11 |
12 | expect(result.current.activeIndex).toBe(maxIndex)
13 |
14 | act(() => {
15 | result.current.setActiveIndex(-5)
16 | })
17 |
18 | expect(result.current.activeIndex).toBe(0)
19 | })
20 |
21 | test('index overflows properly', () => {
22 | const { result } = renderHook(() => useRovingIndex({ type: 'none' }))
23 |
24 | act(() => {
25 | result.current.moveActiveIndex(-3)
26 | })
27 |
28 | expect(result.current.activeIndex).toBe(-3)
29 | })
30 |
31 | test('index wraps properly', () => {
32 | const { result } = renderHook(() =>
33 | useRovingIndex({ maxIndex: 5, type: 'wrap' })
34 | )
35 |
36 | act(() => {
37 | result.current.moveActiveIndex(-3)
38 | })
39 |
40 | expect(result.current.activeIndex).toBe(5)
41 | })
42 |
43 | test('index moves forward', () => {
44 | const { result } = renderHook(() => useRovingIndex({ maxIndex: 5 }))
45 |
46 | act(() => {
47 | result.current.moveForward()
48 | })
49 |
50 | expect(result.current.activeIndex).toBe(1)
51 | })
52 |
53 | test('index moves backward', () => {
54 | const { result } = renderHook(() =>
55 | useRovingIndex({ maxIndex: 5, type: 'wrap' })
56 | )
57 |
58 | act(() => {
59 | result.current.moveBackward()
60 | })
61 |
62 | expect(result.current.activeIndex).toBe(5)
63 | })
64 |
65 | test('disables moving backwards', () => {
66 | const { result } = renderHook(() => useRovingIndex({ maxIndex: 3 }))
67 |
68 | expect(result.current.moveBackwardDisabled).toBe(true)
69 |
70 | act(() => {
71 | result.current.moveActiveIndex(1)
72 | })
73 |
74 | expect(result.current.moveBackwardDisabled).toBe(false)
75 | })
76 |
77 | test('disables moving forwards', () => {
78 | const { result } = renderHook(() => useRovingIndex({ maxIndex: 3 }))
79 |
80 | expect(result.current.moveForwardDisabled).toBe(false)
81 |
82 | act(() => {
83 | result.current.setActiveIndex(3)
84 | })
85 |
86 | expect(result.current.moveForwardDisabled).toBe(true)
87 | })
88 |
89 | test('tracks previous index', () => {
90 | const { result } = renderHook(() =>
91 | useRovingIndex({ defaultIndex: 5, maxIndex: 5 })
92 | )
93 |
94 | act(() => {
95 | result.current.moveForward()
96 | })
97 |
98 | expect(result.current.previousIndex).toBe(null)
99 |
100 | act(() => {
101 | result.current.moveBackward()
102 | })
103 |
104 | expect(result.current.previousIndex).toBe(5)
105 | })
106 |
--------------------------------------------------------------------------------
/index.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useRef, useState } from 'react'
2 |
3 | /**
4 | * Manage an active index that needs to be contained or wrap.
5 | *
6 | * @example
7 | *
8 | * const {
9 | * activeIndex,
10 | * moveActiveIndex,
11 | * } = useRovingIndex({ maxIndex: items.length - 1 })
12 | */
13 | export function useRovingIndex({
14 | defaultIndex = 0,
15 | maxIndex = Infinity,
16 | type = 'contain',
17 | }: {
18 | /** The default index used when first mounting. */
19 | defaultIndex?: number
20 |
21 | /** Whether or not to contain the index. */
22 | contain?: boolean
23 |
24 | /** The max index used to know when to contain or wrap. */
25 | maxIndex?: number
26 |
27 | /** How to handle navigation when exceeding minimum and maximum indexes. */
28 | type?: 'contain' | 'wrap' | 'none'
29 | }): {
30 | /** The active index. */
31 | activeIndex: number
32 |
33 | /** The previously set index. */
34 | previousIndex: null | number
35 |
36 | /** Whether the active index can be moved backward. */
37 | moveBackwardDisabled: boolean
38 |
39 | /** Whether the active index can be moved forward. */
40 | moveForwardDisabled: boolean
41 |
42 | /** Move the index backwards. */
43 | moveBackward: () => void
44 |
45 | /** Move the index forwards. */
46 | moveForward: () => void
47 |
48 | /** Move the active index by a positive or negative amount. */
49 | moveActiveIndex: (amount: number) => void
50 |
51 | /** Set any active index. */
52 | setActiveIndex: (nextIndex: number) => void
53 | } {
54 | const [activeIndex, setLocalActiveIndex] = useState(defaultIndex)
55 | const previousIndex = useRef(null)
56 | const getNextIndex = useCallback(
57 | (incomingIndex) => {
58 | const exceedsMax = incomingIndex > maxIndex
59 | const exceedsMin = incomingIndex < 0
60 |
61 | switch (type) {
62 | case 'contain':
63 | return exceedsMax ? maxIndex : exceedsMin ? 0 : incomingIndex
64 | case 'wrap':
65 | return exceedsMax ? 0 : exceedsMin ? maxIndex : incomingIndex
66 | default:
67 | return incomingIndex
68 | }
69 | },
70 | [maxIndex, type]
71 | )
72 | const moveActiveIndex = useCallback(
73 | (amountToMove) => {
74 | setLocalActiveIndex((currentIndex) => {
75 | previousIndex.current = currentIndex
76 | return getNextIndex(currentIndex + amountToMove)
77 | })
78 | },
79 | [getNextIndex]
80 | )
81 | const setActiveIndex = useCallback(
82 | (nextIndex) => {
83 | setLocalActiveIndex((currentIndex) => {
84 | previousIndex.current = currentIndex
85 | return getNextIndex(nextIndex)
86 | })
87 | },
88 | [getNextIndex]
89 | )
90 | const moveBackward = useCallback(() => moveActiveIndex(-1), [moveActiveIndex])
91 | const moveForward = useCallback(() => moveActiveIndex(1), [moveActiveIndex])
92 |
93 | return {
94 | activeIndex,
95 | moveActiveIndex,
96 | setActiveIndex,
97 | moveBackward,
98 | moveForward,
99 | moveBackwardDisabled: activeIndex <= 0,
100 | moveForwardDisabled: activeIndex >= maxIndex,
101 | previousIndex: previousIndex.current,
102 | }
103 | }
104 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | testEnvironment: 'jsdom',
3 | transform: { '^.+\\.(t|j)sx?$': ['@swc/jest'] },
4 | }
5 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "use-roving-index",
3 | "version": "2.0.0",
4 | "description": "Manage an active index that needs to be contained or wrapped.",
5 | "keywords": [
6 | "active index",
7 | "roving index",
8 | "manage index",
9 | "index hook",
10 | "index react",
11 | "index"
12 | ],
13 | "author": "Travis Arnold",
14 | "license": "MIT",
15 | "source": "index.js",
16 | "types": "index.d.ts",
17 | "main": "dist/index.js",
18 | "module": "dist/index.module.js",
19 | "unpkg": "dist/index.umd.js",
20 | "scripts": {
21 | "build": "microbundle -i index.ts",
22 | "dev": "microbundle -i index.ts watch",
23 | "test": "pnpm build; jest --watchAll",
24 | "prepublishOnly": "pnpm build"
25 | },
26 | "peerDependencies": {
27 | "react": ">=16.8.0"
28 | },
29 | "devDependencies": {
30 | "@swc/core": "^1.3.8",
31 | "@swc/jest": "^0.2.23",
32 | "@testing-library/react": "^13.4.0",
33 | "@types/jest": "^29.1.2",
34 | "@types/react": "^18.0.21",
35 | "jest": "^29.2.0",
36 | "jest-environment-jsdom": "^29.2.0",
37 | "microbundle": "^0.15.1",
38 | "react": "^18.2.0",
39 | "react-dom": "^18.2.0",
40 | "react-test-renderer": "^18.2.0"
41 | }
42 | }
43 |
--------------------------------------------------------------------------------