2025-09-22 11:14:41 -04:00
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe , it , expect , vi , beforeEach , afterEach } from 'vitest' ;
2025-10-25 14:41:53 -07:00
import { act } from 'react' ;
2025-10-30 11:50:26 -07:00
import { render } from '../../test-utils/render.js' ;
import { waitFor } from '../../test-utils/async.js' ;
2025-09-22 11:14:41 -04:00
import {
useSelectionList ,
type SelectionListItem ,
} from './useSelectionList.js' ;
import { useKeypress } from './useKeypress.js' ;
import type { KeypressHandler , Key } from '../contexts/KeypressContext.js' ;
type UseKeypressMockOptions = { isActive : boolean } ;
vi . mock ( './useKeypress.js' ) ;
let activeKeypressHandler : KeypressHandler | null = null ;
describe ( 'useSelectionList' , ( ) = > {
const mockOnSelect = vi . fn ( ) ;
const mockOnHighlight = vi . fn ( ) ;
const items : Array < SelectionListItem < string > > = [
2025-09-28 14:50:47 -07:00
{ value : 'A' , key : 'A' } ,
{ value : 'B' , disabled : true , key : 'B' } ,
{ value : 'C' , key : 'C' } ,
{ value : 'D' , key : 'D' } ,
2025-09-22 11:14:41 -04:00
] ;
beforeEach ( ( ) = > {
activeKeypressHandler = null ;
vi . mocked ( useKeypress ) . mockImplementation (
( handler : KeypressHandler , options? : UseKeypressMockOptions ) = > {
if ( options ? . isActive ) {
activeKeypressHandler = handler ;
} else {
activeKeypressHandler = null ;
}
} ,
) ;
mockOnSelect . mockClear ( ) ;
mockOnHighlight . mockClear ( ) ;
} ) ;
2025-11-03 16:22:04 -08:00
const pressKey = (
name : string ,
sequence : string = name ,
options : { shift? : boolean ; ctrl? : boolean } = { } ,
) = > {
2025-09-22 11:14:41 -04:00
act ( ( ) = > {
if ( activeKeypressHandler ) {
const key : Key = {
name ,
sequence ,
2025-11-03 16:22:04 -08:00
ctrl : options.ctrl ? ? false ,
2026-01-21 10:13:26 -08:00
cmd : false ,
alt : false ,
2025-11-03 16:22:04 -08:00
shift : options.shift ? ? false ,
2025-11-10 10:56:05 -08:00
insertable : false ,
2025-09-22 11:14:41 -04:00
} ;
activeKeypressHandler ( key ) ;
} else {
throw new Error (
` Test attempted to press key ( ${ name } ) but the keypress handler is not active. Ensure the hook is focused (isFocused=true) and the list is not empty. ` ,
) ;
}
} ) ;
} ;
2025-10-30 11:50:26 -07:00
const renderSelectionListHook = async ( initialProps : {
2025-10-25 14:41:53 -07:00
items : Array < SelectionListItem < string > > ;
onSelect : ( item : string ) = > void ;
onHighlight ? : ( item : string ) = > void ;
initialIndex? : number ;
isFocused? : boolean ;
showNumbers? : boolean ;
2026-01-22 12:54:23 -08:00
wrapAround? : boolean ;
2026-03-08 00:36:54 -08:00
focusKey? : string ;
priority? : boolean ;
2025-10-25 14:41:53 -07:00
} ) = > {
let hookResult : ReturnType < typeof useSelectionList > ;
function TestComponent ( props : typeof initialProps ) {
hookResult = useSelectionList ( props ) ;
return null ;
}
2026-02-18 16:46:50 -08:00
const { rerender , unmount , waitUntilReady } = render (
< TestComponent { ...initialProps } / > ,
) ;
await waitUntilReady ( ) ;
2025-10-25 14:41:53 -07:00
return {
result : {
get current() {
return hookResult ;
} ,
} ,
2025-10-30 11:50:26 -07:00
rerender : async ( newProps : Partial < typeof initialProps > ) = > {
2026-02-18 16:46:50 -08:00
await act ( async ( ) = > {
rerender ( < TestComponent { ...initialProps } { ...newProps } / > ) ;
} ) ;
await waitUntilReady ( ) ;
2025-10-30 11:50:26 -07:00
} ,
unmount : async ( ) = > {
unmount ( ) ;
} ,
2026-02-18 16:46:50 -08:00
waitUntilReady ,
2025-10-25 14:41:53 -07:00
} ;
} ;
2025-09-22 11:14:41 -04:00
describe ( 'Initialization' , ( ) = > {
2025-10-30 11:50:26 -07:00
it ( 'should initialize with the default index (0) if enabled' , async ( ) = > {
const { result } = await renderSelectionListHook ( {
2025-10-25 14:41:53 -07:00
items ,
onSelect : mockOnSelect ,
} ) ;
2025-09-22 11:14:41 -04:00
expect ( result . current . activeIndex ) . toBe ( 0 ) ;
} ) ;
2025-10-30 11:50:26 -07:00
it ( 'should initialize with the provided initialIndex if enabled' , async ( ) = > {
const { result } = await renderSelectionListHook ( {
2025-10-25 14:41:53 -07:00
items ,
initialIndex : 2 ,
onSelect : mockOnSelect ,
} ) ;
2025-09-22 11:14:41 -04:00
expect ( result . current . activeIndex ) . toBe ( 2 ) ;
} ) ;
2025-10-30 11:50:26 -07:00
it ( 'should handle an empty list gracefully' , async ( ) = > {
const { result } = await renderSelectionListHook ( {
2025-10-25 14:41:53 -07:00
items : [ ] ,
onSelect : mockOnSelect ,
} ) ;
2025-09-22 11:14:41 -04:00
expect ( result . current . activeIndex ) . toBe ( 0 ) ;
} ) ;
2025-10-30 11:50:26 -07:00
it ( 'should find the next enabled item (downwards) if initialIndex is disabled' , async ( ) = > {
const { result } = await renderSelectionListHook ( {
2025-10-25 14:41:53 -07:00
items ,
initialIndex : 1 ,
onSelect : mockOnSelect ,
} ) ;
2025-09-22 11:14:41 -04:00
expect ( result . current . activeIndex ) . toBe ( 2 ) ;
} ) ;
2025-10-30 11:50:26 -07:00
it ( 'should wrap around to find the next enabled item if initialIndex is disabled' , async ( ) = > {
2025-09-22 11:14:41 -04:00
const wrappingItems = [
2025-09-28 14:50:47 -07:00
{ value : 'A' , key : 'A' } ,
{ value : 'B' , disabled : true , key : 'B' } ,
{ value : 'C' , disabled : true , key : 'C' } ,
2025-09-22 11:14:41 -04:00
] ;
2025-10-30 11:50:26 -07:00
const { result } = await renderSelectionListHook ( {
2025-10-25 14:41:53 -07:00
items : wrappingItems ,
initialIndex : 2 ,
onSelect : mockOnSelect ,
} ) ;
2025-09-22 11:14:41 -04:00
expect ( result . current . activeIndex ) . toBe ( 0 ) ;
} ) ;
2025-10-30 11:50:26 -07:00
it ( 'should default to 0 if initialIndex is out of bounds' , async ( ) = > {
const { result } = await renderSelectionListHook ( {
2025-10-25 14:41:53 -07:00
items ,
initialIndex : 10 ,
onSelect : mockOnSelect ,
} ) ;
2025-09-22 11:14:41 -04:00
expect ( result . current . activeIndex ) . toBe ( 0 ) ;
2025-10-30 11:50:26 -07:00
const { result : resultNeg } = await renderSelectionListHook ( {
2025-10-25 14:41:53 -07:00
items ,
initialIndex : - 1 ,
onSelect : mockOnSelect ,
} ) ;
2025-09-22 11:14:41 -04:00
expect ( resultNeg . current . activeIndex ) . toBe ( 0 ) ;
} ) ;
2025-10-30 11:50:26 -07:00
it ( 'should stick to the initial index if all items are disabled' , async ( ) = > {
2025-09-22 11:14:41 -04:00
const allDisabled = [
2025-09-28 14:50:47 -07:00
{ value : 'A' , disabled : true , key : 'A' } ,
{ value : 'B' , disabled : true , key : 'B' } ,
2025-09-22 11:14:41 -04:00
] ;
2025-10-30 11:50:26 -07:00
const { result } = await renderSelectionListHook ( {
2025-10-25 14:41:53 -07:00
items : allDisabled ,
initialIndex : 1 ,
onSelect : mockOnSelect ,
} ) ;
2025-09-22 11:14:41 -04:00
expect ( result . current . activeIndex ) . toBe ( 1 ) ;
} ) ;
} ) ;
describe ( 'Keyboard Navigation (Up/Down/J/K)' , ( ) = > {
2025-10-30 11:50:26 -07:00
it ( 'should move down with "j" and "down" keys, skipping disabled items' , async ( ) = > {
2026-02-18 16:46:50 -08:00
const { result , waitUntilReady } = await renderSelectionListHook ( {
2025-10-25 14:41:53 -07:00
items ,
onSelect : mockOnSelect ,
} ) ;
2025-09-22 11:14:41 -04:00
expect ( result . current . activeIndex ) . toBe ( 0 ) ;
pressKey ( 'j' ) ;
2026-02-18 16:46:50 -08:00
await waitUntilReady ( ) ;
2025-09-22 11:14:41 -04:00
expect ( result . current . activeIndex ) . toBe ( 2 ) ;
pressKey ( 'down' ) ;
2026-02-18 16:46:50 -08:00
await waitUntilReady ( ) ;
2025-09-22 11:14:41 -04:00
expect ( result . current . activeIndex ) . toBe ( 3 ) ;
} ) ;
2025-10-30 11:50:26 -07:00
it ( 'should move up with "k" and "up" keys, skipping disabled items' , async ( ) = > {
2026-02-18 16:46:50 -08:00
const { result , waitUntilReady } = await renderSelectionListHook ( {
2025-10-25 14:41:53 -07:00
items ,
initialIndex : 3 ,
onSelect : mockOnSelect ,
} ) ;
2025-09-22 11:14:41 -04:00
expect ( result . current . activeIndex ) . toBe ( 3 ) ;
pressKey ( 'k' ) ;
2026-02-18 16:46:50 -08:00
await waitUntilReady ( ) ;
2025-09-22 11:14:41 -04:00
expect ( result . current . activeIndex ) . toBe ( 2 ) ;
pressKey ( 'up' ) ;
2026-02-18 16:46:50 -08:00
await waitUntilReady ( ) ;
2025-09-22 11:14:41 -04:00
expect ( result . current . activeIndex ) . toBe ( 0 ) ;
} ) ;
2025-11-03 16:22:04 -08:00
it ( 'should ignore navigation keys when shift is pressed' , async ( ) = > {
2026-02-18 16:46:50 -08:00
const { result , waitUntilReady } = await renderSelectionListHook ( {
2025-11-03 16:22:04 -08:00
items ,
initialIndex : 2 , // Start at middle item 'C'
onSelect : mockOnSelect ,
} ) ;
expect ( result . current . activeIndex ) . toBe ( 2 ) ;
// Shift+Down / Shift+J should not move down
pressKey ( 'down' , undefined , { shift : true } ) ;
2026-02-18 16:46:50 -08:00
await waitUntilReady ( ) ;
2025-11-03 16:22:04 -08:00
expect ( result . current . activeIndex ) . toBe ( 2 ) ;
pressKey ( 'j' , undefined , { shift : true } ) ;
2026-02-18 16:46:50 -08:00
await waitUntilReady ( ) ;
2025-11-03 16:22:04 -08:00
expect ( result . current . activeIndex ) . toBe ( 2 ) ;
// Shift+Up / Shift+K should not move up
pressKey ( 'up' , undefined , { shift : true } ) ;
2026-02-18 16:46:50 -08:00
await waitUntilReady ( ) ;
2025-11-03 16:22:04 -08:00
expect ( result . current . activeIndex ) . toBe ( 2 ) ;
pressKey ( 'k' , undefined , { shift : true } ) ;
2026-02-18 16:46:50 -08:00
await waitUntilReady ( ) ;
2025-11-03 16:22:04 -08:00
expect ( result . current . activeIndex ) . toBe ( 2 ) ;
// Verify normal navigation still works
pressKey ( 'down' ) ;
2026-02-18 16:46:50 -08:00
await waitUntilReady ( ) ;
2025-11-03 16:22:04 -08:00
expect ( result . current . activeIndex ) . toBe ( 3 ) ;
} ) ;
2025-10-30 11:50:26 -07:00
it ( 'should wrap navigation correctly' , async ( ) = > {
2026-02-18 16:46:50 -08:00
const { result , waitUntilReady } = await renderSelectionListHook ( {
2025-10-25 14:41:53 -07:00
items ,
initialIndex : items.length - 1 ,
onSelect : mockOnSelect ,
} ) ;
2025-09-22 11:14:41 -04:00
expect ( result . current . activeIndex ) . toBe ( 3 ) ;
pressKey ( 'down' ) ;
2026-02-18 16:46:50 -08:00
await waitUntilReady ( ) ;
2025-09-22 11:14:41 -04:00
expect ( result . current . activeIndex ) . toBe ( 0 ) ;
pressKey ( 'up' ) ;
2026-02-18 16:46:50 -08:00
await waitUntilReady ( ) ;
2025-09-22 11:14:41 -04:00
expect ( result . current . activeIndex ) . toBe ( 3 ) ;
} ) ;
2025-10-30 11:50:26 -07:00
it ( 'should call onHighlight when index changes' , async ( ) = > {
2026-02-18 16:46:50 -08:00
const { waitUntilReady } = await renderSelectionListHook ( {
2025-10-25 14:41:53 -07:00
items ,
onSelect : mockOnSelect ,
onHighlight : mockOnHighlight ,
} ) ;
2025-09-22 11:14:41 -04:00
pressKey ( 'down' ) ;
2026-02-18 16:46:50 -08:00
await waitUntilReady ( ) ;
2025-09-22 11:14:41 -04:00
expect ( mockOnHighlight ) . toHaveBeenCalledTimes ( 1 ) ;
expect ( mockOnHighlight ) . toHaveBeenCalledWith ( 'C' ) ;
} ) ;
2025-10-30 11:50:26 -07:00
it ( 'should not move or call onHighlight if navigation results in the same index (e.g., single item)' , async ( ) = > {
2025-09-28 14:50:47 -07:00
const singleItem = [ { value : 'A' , key : 'A' } ] ;
2026-02-18 16:46:50 -08:00
const { result , waitUntilReady } = await renderSelectionListHook ( {
2025-10-25 14:41:53 -07:00
items : singleItem ,
onSelect : mockOnSelect ,
onHighlight : mockOnHighlight ,
} ) ;
2025-09-22 11:14:41 -04:00
pressKey ( 'down' ) ;
2026-02-18 16:46:50 -08:00
await waitUntilReady ( ) ;
2025-09-22 11:14:41 -04:00
expect ( result . current . activeIndex ) . toBe ( 0 ) ;
expect ( mockOnHighlight ) . not . toHaveBeenCalled ( ) ;
} ) ;
2025-10-30 11:50:26 -07:00
it ( 'should not move or call onHighlight if all items are disabled' , async ( ) = > {
2025-09-22 11:14:41 -04:00
const allDisabled = [
2025-09-28 14:50:47 -07:00
{ value : 'A' , disabled : true , key : 'A' } ,
{ value : 'B' , disabled : true , key : 'B' } ,
2025-09-22 11:14:41 -04:00
] ;
2026-02-18 16:46:50 -08:00
const { result , waitUntilReady } = await renderSelectionListHook ( {
2025-10-25 14:41:53 -07:00
items : allDisabled ,
onSelect : mockOnSelect ,
onHighlight : mockOnHighlight ,
} ) ;
2025-09-22 11:14:41 -04:00
const initialIndex = result . current . activeIndex ;
pressKey ( 'down' ) ;
2026-02-18 16:46:50 -08:00
await waitUntilReady ( ) ;
2025-09-22 11:14:41 -04:00
expect ( result . current . activeIndex ) . toBe ( initialIndex ) ;
expect ( mockOnHighlight ) . not . toHaveBeenCalled ( ) ;
} ) ;
} ) ;
2026-01-22 12:54:23 -08:00
describe ( 'Wrapping (wrapAround)' , ( ) = > {
it ( 'should wrap by default (wrapAround=true)' , async ( ) = > {
2026-02-18 16:46:50 -08:00
const { result , waitUntilReady } = await renderSelectionListHook ( {
2026-01-22 12:54:23 -08:00
items ,
initialIndex : items.length - 1 ,
onSelect : mockOnSelect ,
} ) ;
expect ( result . current . activeIndex ) . toBe ( 3 ) ;
pressKey ( 'down' ) ;
2026-02-18 16:46:50 -08:00
await waitUntilReady ( ) ;
2026-01-22 12:54:23 -08:00
expect ( result . current . activeIndex ) . toBe ( 0 ) ;
pressKey ( 'up' ) ;
2026-02-18 16:46:50 -08:00
await waitUntilReady ( ) ;
2026-01-22 12:54:23 -08:00
expect ( result . current . activeIndex ) . toBe ( 3 ) ;
} ) ;
it ( 'should not wrap when wrapAround is false' , async ( ) = > {
2026-02-18 16:46:50 -08:00
const { result , waitUntilReady } = await renderSelectionListHook ( {
2026-01-22 12:54:23 -08:00
items ,
initialIndex : items.length - 1 ,
onSelect : mockOnSelect ,
wrapAround : false ,
} ) ;
expect ( result . current . activeIndex ) . toBe ( 3 ) ;
pressKey ( 'down' ) ;
2026-02-18 16:46:50 -08:00
await waitUntilReady ( ) ;
2026-01-22 12:54:23 -08:00
expect ( result . current . activeIndex ) . toBe ( 3 ) ; // Should stay at bottom
act ( ( ) = > result . current . setActiveIndex ( 0 ) ) ;
2026-02-18 16:46:50 -08:00
await waitUntilReady ( ) ;
2026-01-22 12:54:23 -08:00
expect ( result . current . activeIndex ) . toBe ( 0 ) ;
pressKey ( 'up' ) ;
2026-02-18 16:46:50 -08:00
await waitUntilReady ( ) ;
2026-01-22 12:54:23 -08:00
expect ( result . current . activeIndex ) . toBe ( 0 ) ; // Should stay at top
} ) ;
} ) ;
2025-09-22 11:14:41 -04:00
describe ( 'Selection (Enter)' , ( ) = > {
2025-10-30 11:50:26 -07:00
it ( 'should call onSelect when "return" is pressed on enabled item' , async ( ) = > {
2026-02-18 16:46:50 -08:00
const { waitUntilReady } = await renderSelectionListHook ( {
2025-10-25 14:41:53 -07:00
items ,
initialIndex : 2 ,
onSelect : mockOnSelect ,
} ) ;
2025-09-22 11:14:41 -04:00
pressKey ( 'return' ) ;
2026-02-18 16:46:50 -08:00
await waitUntilReady ( ) ;
2025-09-22 11:14:41 -04:00
expect ( mockOnSelect ) . toHaveBeenCalledTimes ( 1 ) ;
expect ( mockOnSelect ) . toHaveBeenCalledWith ( 'C' ) ;
} ) ;
2025-10-30 11:50:26 -07:00
it ( 'should not call onSelect if the active item is disabled' , async ( ) = > {
2026-02-18 16:46:50 -08:00
const { result , waitUntilReady } = await renderSelectionListHook ( {
2025-10-25 14:41:53 -07:00
items ,
onSelect : mockOnSelect ,
} ) ;
2025-09-22 11:14:41 -04:00
act ( ( ) = > result . current . setActiveIndex ( 1 ) ) ;
2026-02-18 16:46:50 -08:00
await waitUntilReady ( ) ;
2025-09-22 11:14:41 -04:00
pressKey ( 'return' ) ;
2026-02-18 16:46:50 -08:00
await waitUntilReady ( ) ;
2025-09-22 11:14:41 -04:00
expect ( mockOnSelect ) . not . toHaveBeenCalled ( ) ;
} ) ;
} ) ;
describe ( 'Keyboard Navigation Robustness (Rapid Input)' , ( ) = > {
2025-10-30 11:50:26 -07:00
it ( 'should handle rapid navigation and selection robustly (avoiding stale state)' , async ( ) = > {
2026-02-18 16:46:50 -08:00
const { result , waitUntilReady } = await renderSelectionListHook ( {
2025-10-25 14:41:53 -07:00
items , // A, B(disabled), C, D. Initial index 0 (A).
onSelect : mockOnSelect ,
onHighlight : mockOnHighlight ,
} ) ;
2025-09-22 11:14:41 -04:00
// Simulate rapid inputs with separate act blocks to allow effects to run
if ( ! activeKeypressHandler ) throw new Error ( 'Handler not active' ) ;
const handler = activeKeypressHandler ;
const press = ( name : string ) = > {
const key : Key = {
name ,
sequence : name ,
ctrl : false ,
2026-01-21 10:13:26 -08:00
cmd : false ,
alt : false ,
2025-09-22 11:14:41 -04:00
shift : false ,
2025-11-10 10:56:05 -08:00
insertable : true ,
2025-09-22 11:14:41 -04:00
} ;
handler ( key ) ;
} ;
// 1. Press Down. Should move 0 (A) -> 2 (C).
act ( ( ) = > {
press ( 'down' ) ;
} ) ;
2026-02-18 16:46:50 -08:00
await waitUntilReady ( ) ;
2025-09-22 11:14:41 -04:00
// 2. Press Down again. Should move 2 (C) -> 3 (D).
act ( ( ) = > {
press ( 'down' ) ;
} ) ;
2026-02-18 16:46:50 -08:00
await waitUntilReady ( ) ;
2025-09-22 11:14:41 -04:00
// 3. Press Enter. Should select D.
act ( ( ) = > {
press ( 'return' ) ;
} ) ;
2026-02-18 16:46:50 -08:00
await waitUntilReady ( ) ;
2025-09-22 11:14:41 -04:00
expect ( result . current . activeIndex ) . toBe ( 3 ) ;
expect ( mockOnHighlight ) . toHaveBeenCalledTimes ( 2 ) ;
expect ( mockOnHighlight ) . toHaveBeenNthCalledWith ( 1 , 'C' ) ;
expect ( mockOnHighlight ) . toHaveBeenNthCalledWith ( 2 , 'D' ) ;
expect ( mockOnSelect ) . toHaveBeenCalledTimes ( 1 ) ;
expect ( mockOnSelect ) . toHaveBeenCalledWith ( 'D' ) ;
expect ( mockOnSelect ) . not . toHaveBeenCalledWith ( 'A' ) ;
} ) ;
2025-10-30 11:50:26 -07:00
it ( 'should handle ultra-rapid input (multiple presses in single act) without stale state' , async ( ) = > {
2026-02-18 16:46:50 -08:00
const { result , waitUntilReady } = await renderSelectionListHook ( {
2025-10-25 14:41:53 -07:00
items , // A, B(disabled), C, D. Initial index 0 (A).
onSelect : mockOnSelect ,
onHighlight : mockOnHighlight ,
} ) ;
2025-09-22 11:14:41 -04:00
// Simulate ultra-rapid inputs where all keypresses happen faster than React can re-render
act ( ( ) = > {
if ( ! activeKeypressHandler ) throw new Error ( 'Handler not active' ) ;
const handler = activeKeypressHandler ;
const press = ( name : string ) = > {
const key : Key = {
name ,
sequence : name ,
ctrl : false ,
2026-01-21 10:13:26 -08:00
cmd : false ,
alt : false ,
2025-09-22 11:14:41 -04:00
shift : false ,
2025-11-10 10:56:05 -08:00
insertable : false ,
2025-09-22 11:14:41 -04:00
} ;
handler ( key ) ;
} ;
// All presses happen in same render cycle - React batches the state updates
press ( 'down' ) ; // Should move 0 (A) -> 2 (C)
press ( 'down' ) ; // Should move 2 (C) -> 3 (D)
press ( 'return' ) ; // Should select D
} ) ;
2026-02-18 16:46:50 -08:00
await waitUntilReady ( ) ;
2025-09-22 11:14:41 -04:00
expect ( result . current . activeIndex ) . toBe ( 3 ) ;
expect ( mockOnHighlight ) . toHaveBeenCalledWith ( 'D' ) ;
expect ( mockOnSelect ) . toHaveBeenCalledTimes ( 1 ) ;
expect ( mockOnSelect ) . toHaveBeenCalledWith ( 'D' ) ;
} ) ;
} ) ;
describe ( 'Focus Management (isFocused)' , ( ) = > {
2025-10-30 11:50:26 -07:00
it ( 'should activate the keypress handler when focused (default) and items exist' , async ( ) = > {
2026-02-18 16:46:50 -08:00
const { result , waitUntilReady } = await renderSelectionListHook ( {
2025-10-25 14:41:53 -07:00
items ,
onSelect : mockOnSelect ,
} ) ;
2025-09-22 11:14:41 -04:00
expect ( activeKeypressHandler ) . not . toBeNull ( ) ;
pressKey ( 'down' ) ;
2026-02-18 16:46:50 -08:00
await waitUntilReady ( ) ;
2025-09-22 11:14:41 -04:00
expect ( result . current . activeIndex ) . toBe ( 2 ) ;
} ) ;
2025-10-30 11:50:26 -07:00
it ( 'should not activate the keypress handler when isFocused is false' , async ( ) = > {
await renderSelectionListHook ( {
2025-10-25 14:41:53 -07:00
items ,
onSelect : mockOnSelect ,
isFocused : false ,
} ) ;
2025-09-22 11:14:41 -04:00
expect ( activeKeypressHandler ) . toBeNull ( ) ;
expect ( ( ) = > pressKey ( 'down' ) ) . toThrow ( /keypress handler is not active/ ) ;
} ) ;
2025-10-30 11:50:26 -07:00
it ( 'should not activate the keypress handler when items list is empty' , async ( ) = > {
await renderSelectionListHook ( {
2025-10-25 14:41:53 -07:00
items : [ ] ,
onSelect : mockOnSelect ,
isFocused : true ,
} ) ;
2025-09-22 11:14:41 -04:00
expect ( activeKeypressHandler ) . toBeNull ( ) ;
expect ( ( ) = > pressKey ( 'down' ) ) . toThrow ( /keypress handler is not active/ ) ;
} ) ;
2025-10-30 11:50:26 -07:00
it ( 'should activate/deactivate when isFocused prop changes' , async ( ) = > {
2026-02-18 16:46:50 -08:00
const { result , rerender , waitUntilReady } =
await renderSelectionListHook ( {
items ,
onSelect : mockOnSelect ,
isFocused : false ,
} ) ;
2025-09-22 11:14:41 -04:00
expect ( activeKeypressHandler ) . toBeNull ( ) ;
2025-10-30 11:50:26 -07:00
await rerender ( { isFocused : true } ) ;
2025-09-22 11:14:41 -04:00
expect ( activeKeypressHandler ) . not . toBeNull ( ) ;
pressKey ( 'down' ) ;
2026-02-18 16:46:50 -08:00
await waitUntilReady ( ) ;
2025-09-22 11:14:41 -04:00
expect ( result . current . activeIndex ) . toBe ( 2 ) ;
2025-10-30 11:50:26 -07:00
await rerender ( { isFocused : false } ) ;
2025-09-22 11:14:41 -04:00
expect ( activeKeypressHandler ) . toBeNull ( ) ;
expect ( ( ) = > pressKey ( 'down' ) ) . toThrow ( /keypress handler is not active/ ) ;
} ) ;
} ) ;
describe ( 'Numeric Quick Selection (showNumbers=true)' , ( ) = > {
beforeEach ( ( ) = > {
vi . useFakeTimers ( ) ;
} ) ;
afterEach ( ( ) = > {
vi . useRealTimers ( ) ;
} ) ;
const shortList = items ;
const longList : Array < SelectionListItem < string > > = Array . from (
{ length : 15 } ,
2025-09-28 14:50:47 -07:00
( _ , i ) = > ( { value : ` Item ${ i + 1 } ` , key : ` Item ${ i + 1 } ` } ) ,
2025-09-22 11:14:41 -04:00
) ;
const pressNumber = ( num : string ) = > pressKey ( num , num ) ;
2025-10-30 11:50:26 -07:00
it ( 'should not respond to numbers if showNumbers is false (default)' , async ( ) = > {
2026-02-18 16:46:50 -08:00
const { result , waitUntilReady } = await renderSelectionListHook ( {
2025-10-25 14:41:53 -07:00
items : shortList ,
onSelect : mockOnSelect ,
} ) ;
2025-09-22 11:14:41 -04:00
pressNumber ( '1' ) ;
2026-02-18 16:46:50 -08:00
await waitUntilReady ( ) ;
2025-09-22 11:14:41 -04:00
expect ( result . current . activeIndex ) . toBe ( 0 ) ;
expect ( mockOnSelect ) . not . toHaveBeenCalled ( ) ;
} ) ;
2025-10-30 11:50:26 -07:00
it ( 'should select item immediately if the number cannot be extended (unambiguous)' , async ( ) = > {
2026-02-18 16:46:50 -08:00
const { result , waitUntilReady } = await renderSelectionListHook ( {
2025-10-25 14:41:53 -07:00
items : shortList ,
onSelect : mockOnSelect ,
onHighlight : mockOnHighlight ,
showNumbers : true ,
} ) ;
2025-09-22 11:14:41 -04:00
pressNumber ( '3' ) ;
2026-02-18 16:46:50 -08:00
await waitUntilReady ( ) ;
2025-09-22 11:14:41 -04:00
expect ( result . current . activeIndex ) . toBe ( 2 ) ;
expect ( mockOnHighlight ) . toHaveBeenCalledWith ( 'C' ) ;
expect ( mockOnSelect ) . toHaveBeenCalledTimes ( 1 ) ;
expect ( mockOnSelect ) . toHaveBeenCalledWith ( 'C' ) ;
expect ( vi . getTimerCount ( ) ) . toBe ( 0 ) ;
} ) ;
2025-10-30 11:50:26 -07:00
it ( 'should highlight and wait for timeout if the number can be extended (ambiguous)' , async ( ) = > {
2026-02-18 16:46:50 -08:00
const { result , waitUntilReady } = await renderSelectionListHook ( {
2025-10-25 14:41:53 -07:00
items : longList ,
initialIndex : 1 , // Start at index 1 so pressing "1" (index 0) causes a change
onSelect : mockOnSelect ,
onHighlight : mockOnHighlight ,
showNumbers : true ,
} ) ;
2025-09-22 11:14:41 -04:00
pressNumber ( '1' ) ;
2026-02-18 16:46:50 -08:00
await waitUntilReady ( ) ;
2025-09-22 11:14:41 -04:00
expect ( result . current . activeIndex ) . toBe ( 0 ) ;
expect ( mockOnHighlight ) . toHaveBeenCalledWith ( 'Item 1' ) ;
expect ( mockOnSelect ) . not . toHaveBeenCalled ( ) ;
expect ( vi . getTimerCount ( ) ) . toBe ( 1 ) ;
2026-02-18 16:46:50 -08:00
await act ( async ( ) = > {
2025-09-22 11:14:41 -04:00
vi . advanceTimersByTime ( 1000 ) ;
} ) ;
2026-02-18 16:46:50 -08:00
await waitUntilReady ( ) ;
2025-09-22 11:14:41 -04:00
expect ( mockOnSelect ) . toHaveBeenCalledTimes ( 1 ) ;
expect ( mockOnSelect ) . toHaveBeenCalledWith ( 'Item 1' ) ;
} ) ;
2025-10-30 11:50:26 -07:00
it ( 'should handle multi-digit input correctly' , async ( ) = > {
2026-02-18 16:46:50 -08:00
const { result , waitUntilReady } = await renderSelectionListHook ( {
2025-10-25 14:41:53 -07:00
items : longList ,
onSelect : mockOnSelect ,
showNumbers : true ,
} ) ;
2025-09-22 11:14:41 -04:00
pressNumber ( '1' ) ;
2026-02-18 16:46:50 -08:00
await waitUntilReady ( ) ;
2025-09-22 11:14:41 -04:00
expect ( mockOnSelect ) . not . toHaveBeenCalled ( ) ;
pressNumber ( '2' ) ;
2026-02-18 16:46:50 -08:00
await waitUntilReady ( ) ;
2025-09-22 11:14:41 -04:00
expect ( result . current . activeIndex ) . toBe ( 11 ) ;
expect ( mockOnSelect ) . toHaveBeenCalledTimes ( 1 ) ;
expect ( mockOnSelect ) . toHaveBeenCalledWith ( 'Item 12' ) ;
} ) ;
2025-10-30 11:50:26 -07:00
it ( 'should reset buffer if input becomes invalid (out of bounds)' , async ( ) = > {
2026-02-18 16:46:50 -08:00
const { result , waitUntilReady } = await renderSelectionListHook ( {
2025-10-25 14:41:53 -07:00
items : shortList ,
onSelect : mockOnSelect ,
showNumbers : true ,
} ) ;
2025-09-22 11:14:41 -04:00
pressNumber ( '5' ) ;
2026-02-18 16:46:50 -08:00
await waitUntilReady ( ) ;
2025-09-22 11:14:41 -04:00
expect ( result . current . activeIndex ) . toBe ( 0 ) ;
expect ( mockOnSelect ) . not . toHaveBeenCalled ( ) ;
pressNumber ( '3' ) ;
2026-02-18 16:46:50 -08:00
await waitUntilReady ( ) ;
2025-09-22 11:14:41 -04:00
expect ( result . current . activeIndex ) . toBe ( 2 ) ;
expect ( mockOnSelect ) . toHaveBeenCalledWith ( 'C' ) ;
} ) ;
2025-10-30 11:50:26 -07:00
it ( 'should allow "0" as subsequent digit, but ignore as first digit' , async ( ) = > {
2026-02-18 16:46:50 -08:00
const { result , waitUntilReady } = await renderSelectionListHook ( {
2025-10-25 14:41:53 -07:00
items : longList ,
onSelect : mockOnSelect ,
showNumbers : true ,
} ) ;
2025-09-22 11:14:41 -04:00
pressNumber ( '0' ) ;
2026-02-18 16:46:50 -08:00
await waitUntilReady ( ) ;
2025-09-22 11:14:41 -04:00
expect ( result . current . activeIndex ) . toBe ( 0 ) ;
expect ( mockOnSelect ) . not . toHaveBeenCalled ( ) ;
// Timer should be running to clear the '0' input buffer
expect ( vi . getTimerCount ( ) ) . toBe ( 1 ) ;
// Press '1', then '0' (Item 10, index 9)
pressNumber ( '1' ) ;
2026-02-18 16:46:50 -08:00
await waitUntilReady ( ) ;
2025-09-22 11:14:41 -04:00
pressNumber ( '0' ) ;
2026-02-18 16:46:50 -08:00
await waitUntilReady ( ) ;
2025-09-22 11:14:41 -04:00
expect ( result . current . activeIndex ) . toBe ( 9 ) ;
expect ( mockOnSelect ) . toHaveBeenCalledWith ( 'Item 10' ) ;
} ) ;
2025-10-30 11:50:26 -07:00
it ( 'should clear the initial "0" input after timeout' , async ( ) = > {
2026-02-18 16:46:50 -08:00
const { waitUntilReady } = await renderSelectionListHook ( {
2025-10-25 14:41:53 -07:00
items : longList ,
onSelect : mockOnSelect ,
showNumbers : true ,
} ) ;
2025-09-22 11:14:41 -04:00
pressNumber ( '0' ) ;
2026-02-18 16:46:50 -08:00
await waitUntilReady ( ) ;
await act ( async ( ) = > vi . advanceTimersByTime ( 1000 ) ) ; // Timeout the '0' input
await waitUntilReady ( ) ;
2025-09-22 11:14:41 -04:00
pressNumber ( '1' ) ;
2026-02-18 16:46:50 -08:00
await waitUntilReady ( ) ;
2025-09-22 11:14:41 -04:00
expect ( mockOnSelect ) . not . toHaveBeenCalled ( ) ; // Should be waiting for second digit
2026-02-18 16:46:50 -08:00
await act ( async ( ) = > vi . advanceTimersByTime ( 1000 ) ) ; // Timeout '1'
await waitUntilReady ( ) ;
2025-09-22 11:14:41 -04:00
expect ( mockOnSelect ) . toHaveBeenCalledWith ( 'Item 1' ) ;
} ) ;
2025-10-30 11:50:26 -07:00
it ( 'should highlight but not select a disabled item (immediate selection case)' , async ( ) = > {
2026-02-18 16:46:50 -08:00
const { result , waitUntilReady } = await renderSelectionListHook ( {
2025-10-25 14:41:53 -07:00
items : shortList , // B (index 1, number 2) is disabled
onSelect : mockOnSelect ,
onHighlight : mockOnHighlight ,
showNumbers : true ,
} ) ;
2025-09-22 11:14:41 -04:00
pressNumber ( '2' ) ;
2026-02-18 16:46:50 -08:00
await waitUntilReady ( ) ;
2025-09-22 11:14:41 -04:00
expect ( result . current . activeIndex ) . toBe ( 1 ) ;
expect ( mockOnHighlight ) . toHaveBeenCalledWith ( 'B' ) ;
// Should not select immediately, even though 20 > 4
expect ( mockOnSelect ) . not . toHaveBeenCalled ( ) ;
} ) ;
2025-10-30 11:50:26 -07:00
it ( 'should highlight but not select a disabled item (timeout case)' , async ( ) = > {
2025-09-22 11:14:41 -04:00
// Create a list where the ambiguous prefix points to a disabled item
const disabledAmbiguousList = [
2025-09-28 14:50:47 -07:00
{ value : 'Item 1 Disabled' , disabled : true , key : 'Item 1 Disabled' } ,
2025-09-22 11:14:41 -04:00
. . . longList . slice ( 1 ) ,
] ;
2026-02-18 16:46:50 -08:00
const { result , waitUntilReady } = await renderSelectionListHook ( {
2025-10-25 14:41:53 -07:00
items : disabledAmbiguousList ,
onSelect : mockOnSelect ,
showNumbers : true ,
} ) ;
2025-09-22 11:14:41 -04:00
pressNumber ( '1' ) ;
2026-02-18 16:46:50 -08:00
await waitUntilReady ( ) ;
2025-09-22 11:14:41 -04:00
expect ( result . current . activeIndex ) . toBe ( 0 ) ;
expect ( vi . getTimerCount ( ) ) . toBe ( 1 ) ;
2026-02-18 16:46:50 -08:00
await act ( async ( ) = > {
2025-09-22 11:14:41 -04:00
vi . advanceTimersByTime ( 1000 ) ;
} ) ;
2026-02-18 16:46:50 -08:00
await waitUntilReady ( ) ;
2025-09-22 11:14:41 -04:00
// Should not select after timeout
expect ( mockOnSelect ) . not . toHaveBeenCalled ( ) ;
} ) ;
2025-10-30 11:50:26 -07:00
it ( 'should clear the number buffer if a non-numeric key (e.g., navigation) is pressed' , async ( ) = > {
2026-02-18 16:46:50 -08:00
const { result , waitUntilReady } = await renderSelectionListHook ( {
2025-10-25 14:41:53 -07:00
items : longList ,
onSelect : mockOnSelect ,
showNumbers : true ,
} ) ;
2025-09-22 11:14:41 -04:00
pressNumber ( '1' ) ;
2026-02-18 16:46:50 -08:00
await waitUntilReady ( ) ;
2025-09-22 11:14:41 -04:00
expect ( vi . getTimerCount ( ) ) . toBe ( 1 ) ;
pressKey ( 'down' ) ;
2026-02-18 16:46:50 -08:00
await waitUntilReady ( ) ;
2025-09-22 11:14:41 -04:00
expect ( result . current . activeIndex ) . toBe ( 1 ) ;
expect ( vi . getTimerCount ( ) ) . toBe ( 0 ) ;
pressNumber ( '3' ) ;
2026-02-18 16:46:50 -08:00
await waitUntilReady ( ) ;
2025-09-22 11:14:41 -04:00
// Should select '3', not '13'
expect ( result . current . activeIndex ) . toBe ( 2 ) ;
} ) ;
2025-10-30 11:50:26 -07:00
it ( 'should clear the number buffer if "return" is pressed' , async ( ) = > {
2026-02-18 16:46:50 -08:00
const { waitUntilReady } = await renderSelectionListHook ( {
2025-10-25 14:41:53 -07:00
items : longList ,
onSelect : mockOnSelect ,
showNumbers : true ,
} ) ;
2025-09-22 11:14:41 -04:00
pressNumber ( '1' ) ;
2026-02-18 16:46:50 -08:00
await waitUntilReady ( ) ;
2025-09-22 11:14:41 -04:00
pressKey ( 'return' ) ;
2026-02-18 16:46:50 -08:00
await waitUntilReady ( ) ;
2025-09-22 11:14:41 -04:00
expect ( mockOnSelect ) . toHaveBeenCalledTimes ( 1 ) ;
expect ( vi . getTimerCount ( ) ) . toBe ( 0 ) ;
2026-02-18 16:46:50 -08:00
await act ( async ( ) = > {
2025-09-22 11:14:41 -04:00
vi . advanceTimersByTime ( 1000 ) ;
} ) ;
2026-02-18 16:46:50 -08:00
await waitUntilReady ( ) ;
2025-09-22 11:14:41 -04:00
expect ( mockOnSelect ) . toHaveBeenCalledTimes ( 1 ) ;
} ) ;
} ) ;
2026-03-08 00:36:54 -08:00
describe ( 'Programmatic Focus (focusKey)' , ( ) = > {
it ( 'should change the activeIndex when a valid focusKey is provided' , async ( ) = > {
const { result , rerender , waitUntilReady } =
await renderSelectionListHook ( {
items ,
onSelect : mockOnSelect ,
} ) ;
expect ( result . current . activeIndex ) . toBe ( 0 ) ;
await rerender ( { focusKey : 'C' } ) ;
await waitUntilReady ( ) ;
expect ( result . current . activeIndex ) . toBe ( 2 ) ;
} ) ;
it ( 'should ignore a focusKey that does not exist' , async ( ) = > {
const { result , rerender , waitUntilReady } =
await renderSelectionListHook ( {
items ,
onSelect : mockOnSelect ,
} ) ;
expect ( result . current . activeIndex ) . toBe ( 0 ) ;
await rerender ( { focusKey : 'UNKNOWN' } ) ;
await waitUntilReady ( ) ;
expect ( result . current . activeIndex ) . toBe ( 0 ) ;
} ) ;
it ( 'should ignore a focusKey that points to a disabled item' , async ( ) = > {
const { result , rerender , waitUntilReady } =
await renderSelectionListHook ( {
items , // B is disabled
onSelect : mockOnSelect ,
} ) ;
expect ( result . current . activeIndex ) . toBe ( 0 ) ;
await rerender ( { focusKey : 'B' } ) ;
await waitUntilReady ( ) ;
expect ( result . current . activeIndex ) . toBe ( 0 ) ;
} ) ;
it ( 'should handle clearing the focusKey' , async ( ) = > {
const { result , rerender , waitUntilReady } =
await renderSelectionListHook ( {
items ,
onSelect : mockOnSelect ,
focusKey : 'C' ,
} ) ;
expect ( result . current . activeIndex ) . toBe ( 2 ) ;
await rerender ( { focusKey : undefined } ) ;
await waitUntilReady ( ) ;
// Should remain at 2
expect ( result . current . activeIndex ) . toBe ( 2 ) ;
// We can then change it again to something else
await rerender ( { focusKey : 'D' } ) ;
await waitUntilReady ( ) ;
expect ( result . current . activeIndex ) . toBe ( 3 ) ;
} ) ;
} ) ;
2025-09-22 11:14:41 -04:00
describe ( 'Reactivity (Dynamic Updates)' , ( ) = > {
2025-10-25 14:41:53 -07:00
it ( 'should update activeIndex when initialIndex prop changes' , async ( ) = > {
2025-10-30 11:50:26 -07:00
const { result , rerender } = await renderSelectionListHook ( {
2025-10-25 14:41:53 -07:00
items ,
onSelect : mockOnSelect ,
initialIndex : 0 ,
} ) ;
2025-09-22 11:14:41 -04:00
2025-10-30 11:50:26 -07:00
await rerender ( { initialIndex : 2 } ) ;
await waitFor ( ( ) = > {
2025-10-25 14:41:53 -07:00
expect ( result . current . activeIndex ) . toBe ( 2 ) ;
} ) ;
2025-09-22 11:14:41 -04:00
} ) ;
2025-10-25 14:41:53 -07:00
it ( 'should respect a new initialIndex even after user interaction' , async ( ) = > {
2026-02-18 16:46:50 -08:00
const { result , rerender , waitUntilReady } =
await renderSelectionListHook ( {
items ,
onSelect : mockOnSelect ,
initialIndex : 0 ,
} ) ;
2025-09-28 14:50:47 -07:00
// User navigates, changing the active index
pressKey ( 'down' ) ;
2026-02-18 16:46:50 -08:00
await waitUntilReady ( ) ;
2025-09-28 14:50:47 -07:00
expect ( result . current . activeIndex ) . toBe ( 2 ) ;
// The component re-renders with a new initial index
2025-10-30 11:50:26 -07:00
await rerender ( { initialIndex : 3 } ) ;
2025-09-28 14:50:47 -07:00
// The hook should now respect the new initial index
2025-10-30 11:50:26 -07:00
await waitFor ( ( ) = > {
2025-10-25 14:41:53 -07:00
expect ( result . current . activeIndex ) . toBe ( 3 ) ;
} ) ;
2025-09-28 14:50:47 -07:00
} ) ;
2025-10-25 14:41:53 -07:00
it ( 'should validate index when initialIndex prop changes to a disabled item' , async ( ) = > {
2025-10-30 11:50:26 -07:00
const { result , rerender } = await renderSelectionListHook ( {
2025-10-25 14:41:53 -07:00
items ,
onSelect : mockOnSelect ,
initialIndex : 0 ,
} ) ;
2025-09-22 11:14:41 -04:00
2025-10-30 11:50:26 -07:00
await rerender ( { initialIndex : 1 } ) ;
2025-09-22 11:14:41 -04:00
2025-10-30 11:50:26 -07:00
await waitFor ( ( ) = > {
2025-10-25 14:41:53 -07:00
expect ( result . current . activeIndex ) . toBe ( 2 ) ;
} ) ;
2025-09-22 11:14:41 -04:00
} ) ;
2025-10-25 14:41:53 -07:00
it ( 'should adjust activeIndex if items change and the initialIndex is now out of bounds' , async ( ) = > {
2025-10-30 11:50:26 -07:00
const { result , rerender } = await renderSelectionListHook ( {
2025-10-25 14:41:53 -07:00
onSelect : mockOnSelect ,
initialIndex : 3 ,
items ,
} ) ;
2025-09-22 11:14:41 -04:00
expect ( result . current . activeIndex ) . toBe ( 3 ) ;
2025-09-28 14:50:47 -07:00
const shorterItems = [
{ value : 'X' , key : 'X' } ,
{ value : 'Y' , key : 'Y' } ,
] ;
2025-10-30 11:50:26 -07:00
await rerender ( { items : shorterItems } ) ; // Length 2
2025-09-22 11:14:41 -04:00
// The useEffect syncs based on the initialIndex (3) which is now out of bounds. It defaults to 0.
2025-10-30 11:50:26 -07:00
await waitFor ( ( ) = > {
2025-10-25 14:41:53 -07:00
expect ( result . current . activeIndex ) . toBe ( 0 ) ;
} ) ;
2025-09-22 11:14:41 -04:00
} ) ;
2025-10-25 14:41:53 -07:00
it ( 'should adjust activeIndex if items change and the initialIndex becomes disabled' , async ( ) = > {
2025-09-28 14:50:47 -07:00
const initialItems = [
{ value : 'A' , key : 'A' } ,
{ value : 'B' , key : 'B' } ,
{ value : 'C' , key : 'C' } ,
] ;
2025-10-30 11:50:26 -07:00
const { result , rerender } = await renderSelectionListHook ( {
2025-10-25 14:41:53 -07:00
onSelect : mockOnSelect ,
initialIndex : 1 ,
items : initialItems ,
} ) ;
2025-09-22 11:14:41 -04:00
expect ( result . current . activeIndex ) . toBe ( 1 ) ;
const newItems = [
2025-09-28 14:50:47 -07:00
{ value : 'A' , key : 'A' } ,
{ value : 'B' , disabled : true , key : 'B' } ,
{ value : 'C' , key : 'C' } ,
2025-09-22 11:14:41 -04:00
] ;
2025-10-30 11:50:26 -07:00
await rerender ( { items : newItems } ) ;
2025-09-22 11:14:41 -04:00
2025-10-30 11:50:26 -07:00
await waitFor ( ( ) = > {
2025-10-25 14:41:53 -07:00
expect ( result . current . activeIndex ) . toBe ( 2 ) ;
} ) ;
2025-09-22 11:14:41 -04:00
} ) ;
2025-10-25 14:41:53 -07:00
it ( 'should reset to 0 if items change to an empty list' , async ( ) = > {
2025-10-30 11:50:26 -07:00
const { result , rerender } = await renderSelectionListHook ( {
2025-10-25 14:41:53 -07:00
onSelect : mockOnSelect ,
initialIndex : 2 ,
items ,
} ) ;
2025-09-22 11:14:41 -04:00
2025-10-30 11:50:26 -07:00
await rerender ( { items : [ ] } ) ;
await waitFor ( ( ) = > {
2025-10-25 14:41:53 -07:00
expect ( result . current . activeIndex ) . toBe ( 0 ) ;
} ) ;
2025-09-22 11:14:41 -04:00
} ) ;
2025-09-22 21:31:39 -07:00
2025-10-25 14:41:53 -07:00
it ( 'should not reset activeIndex when items are deeply equal' , async ( ) = > {
2025-09-22 21:31:39 -07:00
const initialItems = [
2025-09-28 14:50:47 -07:00
{ value : 'A' , key : 'A' } ,
{ value : 'B' , disabled : true , key : 'B' } ,
{ value : 'C' , key : 'C' } ,
{ value : 'D' , key : 'D' } ,
2025-09-22 21:31:39 -07:00
] ;
2026-02-18 16:46:50 -08:00
const { result , rerender , waitUntilReady } =
await renderSelectionListHook ( {
onSelect : mockOnSelect ,
onHighlight : mockOnHighlight ,
initialIndex : 2 ,
items : initialItems ,
} ) ;
2025-09-22 21:31:39 -07:00
expect ( result . current . activeIndex ) . toBe ( 2 ) ;
2026-02-18 16:46:50 -08:00
await act ( async ( ) = > {
2025-09-22 21:31:39 -07:00
result . current . setActiveIndex ( 3 ) ;
} ) ;
2026-02-18 16:46:50 -08:00
await waitUntilReady ( ) ;
2025-09-22 21:31:39 -07:00
expect ( result . current . activeIndex ) . toBe ( 3 ) ;
mockOnHighlight . mockClear ( ) ;
// Create new array with same content (deeply equal but not identical)
const newItems = [
2025-09-28 14:50:47 -07:00
{ value : 'A' , key : 'A' } ,
{ value : 'B' , disabled : true , key : 'B' } ,
{ value : 'C' , key : 'C' } ,
{ value : 'D' , key : 'D' } ,
2025-09-22 21:31:39 -07:00
] ;
2025-10-30 11:50:26 -07:00
await rerender ( { items : newItems } ) ;
2025-09-22 21:31:39 -07:00
// Active index should remain the same since items are deeply equal
2025-10-30 11:50:26 -07:00
await waitFor ( ( ) = > {
2025-10-25 14:41:53 -07:00
expect ( result . current . activeIndex ) . toBe ( 3 ) ;
} ) ;
2025-09-22 21:31:39 -07:00
// onHighlight should NOT be called since the index didn't change
expect ( mockOnHighlight ) . not . toHaveBeenCalled ( ) ;
} ) ;
2025-10-25 14:41:53 -07:00
it ( 'should update activeIndex when items change structurally' , async ( ) = > {
2025-09-22 21:31:39 -07:00
const initialItems = [
2025-09-28 14:50:47 -07:00
{ value : 'A' , key : 'A' } ,
{ value : 'B' , disabled : true , key : 'B' } ,
{ value : 'C' , key : 'C' } ,
{ value : 'D' , key : 'D' } ,
2025-09-22 21:31:39 -07:00
] ;
2025-10-30 11:50:26 -07:00
const { result , rerender } = await renderSelectionListHook ( {
2025-10-25 14:41:53 -07:00
onSelect : mockOnSelect ,
onHighlight : mockOnHighlight ,
initialIndex : 3 ,
items : initialItems ,
} ) ;
2025-09-22 21:31:39 -07:00
expect ( result . current . activeIndex ) . toBe ( 3 ) ;
mockOnHighlight . mockClear ( ) ;
// Change item values (not deeply equal)
2025-09-28 14:50:47 -07:00
const newItems = [
{ value : 'X' , key : 'X' } ,
{ value : 'Y' , key : 'Y' } ,
{ value : 'Z' , key : 'Z' } ,
] ;
2025-09-22 21:31:39 -07:00
2025-10-30 11:50:26 -07:00
await rerender ( { items : newItems } ) ;
2025-09-22 21:31:39 -07:00
// Active index should update based on initialIndex and new items
2025-10-30 11:50:26 -07:00
await waitFor ( ( ) = > {
2025-10-25 14:41:53 -07:00
expect ( result . current . activeIndex ) . toBe ( 0 ) ;
} ) ;
2025-09-22 21:31:39 -07:00
} ) ;
2025-10-25 14:41:53 -07:00
it ( 'should handle partial changes in items array' , async ( ) = > {
2025-09-28 14:50:47 -07:00
const initialItems = [
{ value : 'A' , key : 'A' } ,
{ value : 'B' , key : 'B' } ,
{ value : 'C' , key : 'C' } ,
] ;
2025-09-22 21:31:39 -07:00
2025-10-30 11:50:26 -07:00
const { result , rerender } = await renderSelectionListHook ( {
2025-10-25 14:41:53 -07:00
onSelect : mockOnSelect ,
initialIndex : 1 ,
items : initialItems ,
} ) ;
2025-09-22 21:31:39 -07:00
expect ( result . current . activeIndex ) . toBe ( 1 ) ;
// Change only one item's disabled status
const newItems = [
2025-09-28 14:50:47 -07:00
{ value : 'A' , key : 'A' } ,
{ value : 'B' , disabled : true , key : 'B' } ,
{ value : 'C' , key : 'C' } ,
2025-09-22 21:31:39 -07:00
] ;
2025-10-30 11:50:26 -07:00
await rerender ( { items : newItems } ) ;
2025-09-22 21:31:39 -07:00
// Should find next valid index since current became disabled
2025-10-30 11:50:26 -07:00
await waitFor ( ( ) = > {
2025-10-25 14:41:53 -07:00
expect ( result . current . activeIndex ) . toBe ( 2 ) ;
} ) ;
2025-09-22 21:31:39 -07:00
} ) ;
2025-09-28 14:50:47 -07:00
2025-10-25 14:41:53 -07:00
it ( 'should update selection when a new item is added to the start of the list' , async ( ) = > {
2025-09-28 14:50:47 -07:00
const initialItems = [
{ value : 'A' , key : 'A' } ,
{ value : 'B' , key : 'B' } ,
{ value : 'C' , key : 'C' } ,
] ;
2026-02-18 16:46:50 -08:00
const { result , rerender , waitUntilReady } =
await renderSelectionListHook ( {
onSelect : mockOnSelect ,
items : initialItems ,
} ) ;
2025-09-28 14:50:47 -07:00
pressKey ( 'down' ) ;
2026-02-18 16:46:50 -08:00
await waitUntilReady ( ) ;
2025-09-28 14:50:47 -07:00
expect ( result . current . activeIndex ) . toBe ( 1 ) ;
const newItems = [
{ value : 'D' , key : 'D' } ,
{ value : 'A' , key : 'A' } ,
{ value : 'B' , key : 'B' } ,
{ value : 'C' , key : 'C' } ,
] ;
2025-10-30 11:50:26 -07:00
await rerender ( { items : newItems } ) ;
2025-09-28 14:50:47 -07:00
2025-10-30 11:50:26 -07:00
await waitFor ( ( ) = > {
2025-10-25 14:41:53 -07:00
expect ( result . current . activeIndex ) . toBe ( 2 ) ;
} ) ;
2025-09-28 14:50:47 -07:00
} ) ;
2025-10-01 14:25:54 -07:00
2025-10-30 11:50:26 -07:00
it ( 'should not re-initialize when items have identical keys but are different objects' , async ( ) = > {
2025-10-01 14:25:54 -07:00
const initialItems = [
{ value : 'A' , key : 'A' } ,
{ value : 'B' , key : 'B' } ,
] ;
let renderCount = 0 ;
2025-10-30 11:50:26 -07:00
const renderHookWithCount = async ( initialProps : {
2025-10-25 14:41:53 -07:00
items : Array < SelectionListItem < string > > ;
} ) = > {
function TestComponent ( props : typeof initialProps ) {
2025-10-01 14:25:54 -07:00
renderCount ++ ;
2025-10-25 14:41:53 -07:00
useSelectionList ( {
2025-10-01 14:25:54 -07:00
onSelect : mockOnSelect ,
onHighlight : mockOnHighlight ,
2025-10-25 14:41:53 -07:00
items : props.items ,
2025-10-01 14:25:54 -07:00
} ) ;
2025-10-25 14:41:53 -07:00
return null ;
}
2026-02-18 16:46:50 -08:00
const { rerender , waitUntilReady } = render (
< TestComponent { ...initialProps } / > ,
) ;
await waitUntilReady ( ) ;
2025-10-25 14:41:53 -07:00
return {
2025-10-30 11:50:26 -07:00
rerender : async ( newProps : Partial < typeof initialProps > ) = > {
2026-02-18 16:46:50 -08:00
await act ( async ( ) = > {
rerender ( < TestComponent { ...initialProps } { ...newProps } / > ) ;
} ) ;
await waitUntilReady ( ) ;
2025-10-30 11:50:26 -07:00
} ,
2025-10-25 14:41:53 -07:00
} ;
} ;
2025-10-30 11:50:26 -07:00
const { rerender } = await renderHookWithCount ( { items : initialItems } ) ;
2025-10-01 14:25:54 -07:00
// Initial render
expect ( renderCount ) . toBe ( 1 ) ;
// Create new items with the same keys but different object references
const newItems = [
{ value : 'A' , key : 'A' } ,
{ value : 'B' , key : 'B' } ,
] ;
2025-10-30 11:50:26 -07:00
await rerender ( { items : newItems } ) ;
2025-10-01 14:25:54 -07:00
expect ( renderCount ) . toBe ( 2 ) ;
} ) ;
2025-09-22 11:14:41 -04:00
} ) ;
describe ( 'Cleanup' , ( ) = > {
beforeEach ( ( ) = > {
vi . useFakeTimers ( ) ;
} ) ;
afterEach ( ( ) = > {
vi . useRealTimers ( ) ;
} ) ;
2025-10-30 11:50:26 -07:00
it ( 'should clear timeout on unmount when timer is active' , async ( ) = > {
2025-09-22 11:14:41 -04:00
const longList : Array < SelectionListItem < string > > = Array . from (
{ length : 15 } ,
2025-09-28 14:50:47 -07:00
( _ , i ) = > ( { value : ` Item ${ i + 1 } ` , key : ` Item ${ i + 1 } ` } ) ,
2025-09-22 11:14:41 -04:00
) ;
2026-02-18 16:46:50 -08:00
const { unmount , waitUntilReady } = await renderSelectionListHook ( {
2025-10-25 14:41:53 -07:00
items : longList ,
onSelect : mockOnSelect ,
showNumbers : true ,
} ) ;
2025-09-22 11:14:41 -04:00
pressKey ( '1' , '1' ) ;
2026-02-18 16:46:50 -08:00
await waitUntilReady ( ) ;
2025-09-22 11:14:41 -04:00
expect ( vi . getTimerCount ( ) ) . toBe ( 1 ) ;
2026-02-18 16:46:50 -08:00
await act ( async ( ) = > {
2025-09-22 11:14:41 -04:00
vi . advanceTimersByTime ( 500 ) ;
} ) ;
2026-02-18 16:46:50 -08:00
await waitUntilReady ( ) ;
2025-09-22 11:14:41 -04:00
expect ( mockOnSelect ) . not . toHaveBeenCalled ( ) ;
2026-02-18 16:46:50 -08:00
const clearTimeoutSpy = vi . spyOn ( global , 'clearTimeout' ) ;
2025-10-30 11:50:26 -07:00
await unmount ( ) ;
2025-09-22 11:14:41 -04:00
2026-02-18 16:46:50 -08:00
expect ( clearTimeoutSpy ) . toHaveBeenCalled ( ) ;
2025-09-22 11:14:41 -04:00
2026-02-18 16:46:50 -08:00
await act ( async ( ) = > {
2025-09-22 11:14:41 -04:00
vi . advanceTimersByTime ( 1000 ) ;
} ) ;
2026-02-18 16:46:50 -08:00
// No waitUntilReady here as component is unmounted
2025-09-22 11:14:41 -04:00
expect ( mockOnSelect ) . not . toHaveBeenCalled ( ) ;
} ) ;
} ) ;
} ) ;