UNPKG

@blocklet/payment-react

Version:

Reusable react components for payment kit v2

162 lines (142 loc) 5.37 kB
import { useCallback, KeyboardEvent, useRef, RefObject } from 'react'; type TabNavigationOptions<T> = { /** whether to include custom option as the last item */ includeCustom?: boolean; /** the value or index of the current selected item */ currentValue?: T | number; /** whether the current selected item is custom */ isCustomSelected?: boolean; /** a function to compare values, used to determine the current selected item */ compareValue?: (item: T, value: any) => boolean; /** whether to allow Tab key navigation */ enabled?: boolean; /** a selector to find navigable elements */ selector?: string; /** an element container reference, limiting the query DOM range to improve performance */ containerRef?: RefObject<HTMLElement | null>; /** the type of the current value, can be 'index' or 'value' */ valueType?: 'index' | 'value'; }; /** * Tab key navigation hook - implement Tab key circular navigation between a set of options * * @param items an array of options, can be a simple type (string, number) array or an object array * @param onSelect callback when an item is selected * @param options configuration options * @returns an object containing the event handler and control functions * * @example * // simple string array * const { handleKeyDown } = useTabNavigation(['10', '20', '50'], handleSelect); * * // object array * const { handleKeyDown } = useTabNavigation( * [{id: 1, name: 'A'}, {id: 2, name: 'B'}], * handleSelect, * { compareValue: (item, value) => item.id === value.id } * ); */ export const useTabNavigation = <T>( items: T[], onSelect: (item: T | 'custom', index: number) => void, options?: TabNavigationOptions<T> ) => { const { valueType = 'value', includeCustom = false, currentValue, isCustomSelected = false, compareValue = (item: T, value: any) => item === value, enabled = true, selector = '.tab-navigable-card button', containerRef, } = options || {}; const hasTabbed = useRef(false); const findNavigableElements = useCallback(() => { if (containerRef?.current) { return containerRef.current.querySelectorAll(selector); } return document.querySelectorAll(selector); }, [containerRef, selector]); // get current index const determineCurrentIndex = useCallback(() => { const allOptions = includeCustom ? [...items, 'custom' as any] : items; if (allOptions.length === 0) return -1; // if not tabbed, determine start point by currentValue if (!hasTabbed.current) { if (isCustomSelected && includeCustom) { return items.length; // current selected custom option } if (currentValue !== undefined) { if (valueType === 'index' && typeof currentValue === 'number') { // if currentValue is index return currentValue >= 0 && currentValue < items.length ? currentValue : -1; } // if currentValue is value, find matched item return items.findIndex((item) => compareValue(item, currentValue)); } } else { // if tabbed, find current focused element const focusedElement = document.activeElement; const navigableElements = findNavigableElements(); for (let i = 0; i < navigableElements.length; i++) { if (navigableElements[i] === focusedElement) { return i; } } if (includeCustom && navigableElements.length > 0) { return navigableElements.length - 1; } } return -1; }, [items, includeCustom, isCustomSelected, currentValue, valueType, compareValue, findNavigableElements]); // get next index const getNextIndex = useCallback( (currentIndex: number, isShiftKey: boolean) => { const totalOptions = includeCustom ? items.length + 1 : items.length; if (currentIndex === -1) { return 0; // no current selected item, start from first item } if (isShiftKey) { // Shift+Tab backward return currentIndex === 0 ? totalOptions - 1 : currentIndex - 1; } // Tab next return currentIndex === totalOptions - 1 ? 0 : currentIndex + 1; }, [items, includeCustom] ); const handleKeyDown = useCallback( (e: KeyboardEvent) => { // if navigation is disabled or not Tab key, do not handle event if (!enabled || e.key !== 'Tab') return; e.preventDefault(); e.stopPropagation(); // determine current index and next index const currentIndex = determineCurrentIndex(); const nextIndex = getNextIndex(currentIndex, e.shiftKey); // mark as handled Tab event hasTabbed.current = true; // execute select callback const selectedItem = nextIndex === items.length ? 'custom' : items[nextIndex]; onSelect(selectedItem, nextIndex); // focus to next element setTimeout(() => { const elements = findNavigableElements(); if (elements[nextIndex]) { (elements[nextIndex] as HTMLElement).focus(); } }, 0); }, [items, onSelect, enabled, determineCurrentIndex, getNextIndex, findNavigableElements] ); // reset Tab state method const resetTabNavigation = useCallback(() => { hasTabbed.current = false; }, []); return { handleKeyDown, resetTabNavigation, isTabNavigationActive: hasTabbed.current, }; };