import { ComponentType, forwardRef, Fragment, memo, ReactNode, RefObject, useCallback, useEffect, useImperativeHandle, useMemo, useRef, } from 'react'; import type { FlatList } from 'react-native'; import type { SectionList } from 'react-native'; import { Platform } from 'react-native'; import { createFastContext } from '../fast-context'; import useLatest from '../hooks/useLatest'; export type Store = { currentIndex: number | null; }; const defaultValue = { currentIndex: null }; const { useStoreRef, Provider, useContext: useFastContext, useContextValue, } = createFastContext(); const withKeyboardAccessibility =

>( Component: ComponentType

, key: string = 'records', isFlat: boolean = false, ) => // @ts-expect-error // TODO - fix ts issues forwardRef(({ enableKeyboardNavigation, onCancel, ...props }: P, ref: any) => { const componentRef = useRef(null); const records = props[key] as any[]; const recordsIndexMap = useMemo(() => { if (!records) return {}; const flattenRecords = isFlat ? records : records.reduce((acc, item, sectionIndex) => { return acc.concat( (item.data || []).map((t: any) => ({ ...t, sectionIndex })), ); }, [] as any[]); return flattenRecords.reduce((acc: Record, item: any, i: number) => { acc[i] = item; return acc; }, {} as Record); }, [records]); const length = useMemo(() => { if (!isFlat) { return records.reduce((acc, item) => { acc += item.data.length; return acc; }, 0); } return records.length; }, [records]); const onSelectItem = useCallback( (index: number) => { const selectItem = recordsIndexMap[index]; if (!selectItem) return props.onSelectionChange?.(props.selection); props.onSelectionChange?.( props.multiple ? [...props.selection, selectItem] : selectItem, ); }, [props, recordsIndexMap], ); useImperativeHandle(ref, () => componentRef.current); const Wrapper = enableKeyboardNavigation && Platform.OS === 'web' ? AccessibilityWrapper : Fragment; const accessibilityWrapperProps = enableKeyboardNavigation && Platform.OS === 'web' ? { listRef: componentRef as any, listLength: length, onSelectItem: onSelectItem, isFlat, onCancel, } : {}; return ( )}> ); }); type AccessibilityWrapperProps = { children: ReactNode; listRef: RefObject | RefObject; listLength: number; onSelectItem: (index: number) => void; isFlat: boolean; onCancel?: () => void; }; const withPreventDefault = (func: (e: KeyboardEvent) => void, preventDefault = true) => (e: KeyboardEvent) => { if (preventDefault) e.preventDefault(); func(e); }; const AccessibilityWrapper = memo( ({ children, listRef, listLength, onSelectItem, isFlat, onCancel, }: AccessibilityWrapperProps) => { const [currentIndex, setStore] = useFastContext(state => state.currentIndex); // use the ref here to avoid recreation of the keyToFunctionMap function const { store: currentIndexRef } = useStoreRef(); const keyToFunctionMap = useMemo( () => ({ ArrowUp: withPreventDefault(() => setStore(prev => ({ currentIndex: prev.currentIndex === null || prev.currentIndex === 0 ? listLength - 1 : prev.currentIndex - 1, })), ), ArrowDown: withPreventDefault(() => setStore(prev => ({ currentIndex: prev.currentIndex === null || prev.currentIndex === listLength - 1 ? 0 : prev.currentIndex + 1, })), ), Enter: withPreventDefault(e => { if (currentIndexRef.current.currentIndex === null) return; e.preventDefault(); onSelectItem(currentIndexRef.current.currentIndex); }, false), Escape: withPreventDefault(e => { if (currentIndexRef.current.currentIndex === null) onCancel?.(); e.preventDefault(); setStore(() => ({ currentIndex: null, })); }, false), }), [currentIndexRef, listLength, onCancel, onSelectItem, setStore], ); const onKeyPress = useCallback( (e: KeyboardEvent) => { const keyFunction = keyToFunctionMap[e.key as keyof typeof keyToFunctionMap]; if (!keyFunction) return; keyFunction(e); }, [keyToFunctionMap], ); const listLengthLatest = useLatest(listLength); useEffect(() => { if (listRef && !!listRef.current) { if ( currentIndex === null || currentIndex < 0 || currentIndex > listLengthLatest.current - 1 ) return; if (isFlat) { (listRef as RefObject).current?.scrollToIndex?.({ index: currentIndex || 0, animated: false, }); } else { (listRef as RefObject).current?.scrollToLocation({ animated: false, sectionIndex: 0, itemIndex: currentIndex, }); } } }, [currentIndex, isFlat, listRef, listLengthLatest]); useEffect(() => { const controller = new AbortController(); window.addEventListener('keydown', onKeyPress, { capture: true, signal: controller.signal, } as AddEventListenerOptions); return () => { controller.abort(); }; }, [onKeyPress]); return <>{children}; }, ); export const useCurrentIndexStore = useFastContext; export const useCurrentIndexStoreValue = useContextValue; export default withKeyboardAccessibility;