@blocklet/payment-react
Version:
Reusable react components for payment kit v2
162 lines (142 loc) • 5.37 kB
text/typescript
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,
};
};