diff --git a/src/components/Global/components/alphabetical-selector.tsx b/src/components/Global/components/alphabetical-selector.tsx index 69203f86..ef73106e 100644 --- a/src/components/Global/components/alphabetical-selector.tsx +++ b/src/components/Global/components/alphabetical-selector.tsx @@ -1,5 +1,5 @@ import React, { RefObject, useEffect, useMemo, useRef, useState } from 'react' -import { LayoutChangeEvent, View as RNView } from 'react-native' +import { View as RNView } from 'react-native' import { getToken, useTheme, View, YStack } from 'tamagui' import { Gesture, GestureDetector } from 'react-native-gesture-handler' import Animated, { @@ -7,6 +7,7 @@ import Animated, { useAnimatedStyle, runOnJS, withTiming, + withDelay, } from 'react-native-reanimated' import { Text } from '../helpers/text' import { useSafeAreaFrame } from 'react-native-safe-area-context' @@ -38,48 +39,59 @@ export default function AZScroller({ const alphabetSelectorRef = useRef(null) - const alphabetSelectorTopY = useRef(0) - const letterHeight = useRef(0) + // Shared values for use on the UI thread + const alphabetSelectorTopY = useSharedValue(0) + const letterHeight = useSharedValue(0) const selectedLetter = useSharedValue('') const [overlayLetter, setOverlayLetter] = useState('') const showOverlay = () => { 'worklet' - overlayOpacity.value = withTiming(1) + if (overlayOpacity.value !== 1) { + overlayOpacity.value = withTiming(1) + } } - const hideOverlay = () => { + const hideOverlay = (delayMs: number = 200) => { 'worklet' - overlayOpacity.value = withTiming(0) + overlayOpacity.value = withDelay(delayMs, withTiming(0)) + } + + const getLetterFromAbsoluteY = (absoluteY: number) => { + 'worklet' + if (letterHeight.value <= 0) return '' + const relativeY = absoluteY - alphabetSelectorTopY.value + const rawIndex = Math.floor(relativeY / letterHeight.value) + const clampedIndex = Math.max(0, Math.min(alphabet.length - 1, rawIndex)) + return alphabet[clampedIndex] ?? '' + } + + const updateSelectionFromY = (absoluteY: number) => { + 'worklet' + const letter = getLetterFromAbsoluteY(absoluteY) + if (letter && letter !== selectedLetter.value) { + selectedLetter.value = letter + runOnJS(setOverlayLetter)(letter) + showOverlay() + } else if (letter) { + showOverlay() + } } const panGesture = useMemo( () => Gesture.Pan() - .runOnJS(true) + .minDistance(10) + .hitSlop(8) .onBegin((e) => { - const relativeY = e.absoluteY - alphabetSelectorTopY.current - const index = Math.floor(relativeY / letterHeight.current) - if (alphabet[index]) { - const letter = alphabet[index] - selectedLetter.value = letter - setOverlayLetter(letter) - runOnJS(showOverlay)() - } + updateSelectionFromY(e.absoluteY) }) .onUpdate((e) => { - const relativeY = e.absoluteY - alphabetSelectorTopY.current - const index = Math.floor(relativeY / letterHeight.current) - if (alphabet[index]) { - const letter = alphabet[index] - selectedLetter.value = letter - setOverlayLetter(letter) - runOnJS(showOverlay)() - } + updateSelectionFromY(e.absoluteY) }) .onEnd(() => { - runOnJS(hideOverlay)() + hideOverlay(200) if (selectedLetter.value) { runOnJS(onLetterSelect)(selectedLetter.value.toLowerCase()) } @@ -90,37 +102,29 @@ export default function AZScroller({ const tapGesture = useMemo( () => Gesture.Tap() - .runOnJS(true) + .maxDistance(10) + .hitSlop(8) .onBegin((e) => { - const relativeY = e.absoluteY - alphabetSelectorTopY.current - const index = Math.floor(relativeY / letterHeight.current) - if (alphabet[index]) { - const letter = alphabet[index] - selectedLetter.value = letter - setOverlayLetter(letter) - runOnJS(showOverlay)() - } + updateSelectionFromY(e.absoluteY) }) .onEnd(() => { - runOnJS(hideOverlay)() + hideOverlay(200) if (selectedLetter.value) runOnJS(onLetterSelect)(selectedLetter.value.toLowerCase()) }), [onLetterSelect], ) - const gesture = Gesture.Simultaneous(panGesture, tapGesture) + // Prefer pan exclusively when movement occurs to avoid duplicate taps + const gesture = Gesture.Exclusive(panGesture, tapGesture) const animatedOverlayStyle = useAnimatedStyle(() => ({ opacity: overlayOpacity.value, transform: [{ scale: overlayOpacity.value }], })) - const handleLetterLayout = (event: LayoutChangeEvent) => { - letterHeight.current = event.nativeEvent.layout.height - } - useEffect(() => { + // Keep haptics behavior as-is per request trigger('impactLight') }, [overlayLetter]) @@ -135,37 +139,33 @@ export default function AZScroller({ justifyContent='flex-start' alignItems='center' alignContent='center' + accessible + accessibilityRole='adjustable' + accessibilityLabel='Alphabet scroller' onLayout={() => { requestAnimationFrame(() => { - alphabetSelectorRef.current?.measureInWindow((x, y, width, height) => { - alphabetSelectorTopY.current = y + alphabetSelectorRef.current?.measureInWindow((x, y, w, h) => { + alphabetSelectorTopY.value = y + if (h && h > 0) { + letterHeight.value = h / alphabet.length + } }) }) }} ref={alphabetSelectorRef} > - {alphabet.map((letter, index) => { - const letterElement = ( - - {letter} - - ) - - return index === 0 ? ( - - {letterElement} - - ) : ( - letterElement - ) - })} + {alphabet.map((letter) => ( + + {letter} + + ))} @@ -179,6 +179,7 @@ export default function AZScroller({ width: getToken('$13'), height: getToken('$13'), justifyContent: 'center', + alignItems: 'center', backgroundColor: theme.background.val, borderRadius: getToken('$4'), borderWidth: getToken('$1'), @@ -193,7 +194,6 @@ export default function AZScroller({ textAlign: 'center', fontFamily: 'Figtree-Bold', color: theme.primary.val, - marginHorizontal: 'auto', }} > {overlayLetter} @@ -206,15 +206,20 @@ export default function AZScroller({ export const alphabeticalSelectorCallback = async ( letter: string, pageParams: RefObject>, - { - hasNextPage, - fetchNextPage, - isPending, - }: UseInfiniteQueryResult, + infiniteQuery: UseInfiniteQueryResult, ) => { - while (!pageParams.current.has(letter.toUpperCase()) && hasNextPage) { + const target = letter.toUpperCase() + let safety = 0 + const MAX_PAGES = 100 + while (!pageParams.current.has(target) && infiniteQuery.hasNextPage && safety < MAX_PAGES) { console.debug(`Fetching next page for alphabet selection`) - await fetchNextPage() + await infiniteQuery.fetchNextPage() + safety += 1 + } + if (!pageParams.current.has(target) && safety >= MAX_PAGES) { + console.warn( + `Alphabetical Selector: reached page fetch cap (${MAX_PAGES}) without finding ${target}`, + ) } console.debug(`Alphabetical Selector Callback: ${letter} complete`) }