├── .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 | 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 | --------------------------------------------------------------------------------