/* eslint-disable react-hooks/immutability -- Reanimated shared values are mutable gesture state. */ import { useMemo, useState } from 'react'; import { StyleSheet, View, type LayoutChangeEvent } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from 'react-native-reanimated'; import { Text } from '@/components/Text'; import { radius, spacing } from '@/theme'; import { createThemedStyles } from '@/theme/themed'; import { rgbaFromHex } from '@/theme/colorUtils'; import { playHaptic } from '@/lib/haptics'; import { usePullSearchGestureRef } from '@/components/search/PullSearchGesture'; import { RAIL_LETTERS } from '@/lib/letterIndex'; const CELL_HEIGHT = 17; const RAIL_PAD = spacing.xs; const RAIL_HEIGHT = RAIL_LETTERS.length * CELL_HEIGHT + RAIL_PAD * 2; const BUBBLE_SIZE = 52; interface AlphabetRailProps { /** Letters present in the current list — the rest render dimmed. */ activeLetters: ReadonlySet; onJumpToLetter: (letter: string) => void; /** * Fired when the finger lifts. The screen debounces `onJumpToLetter` so a fast * scrub does not rebuild the list once per letter crossed; this is its cue to * commit the last letter immediately instead of waiting out the debounce. */ onScrubEnd?: () => void; } /** * A-Z scrubber overlaid on the right edge of a library list. Fixed cell * geometry (full #A-Z always rendered) keeps the pointer math trivial; one * haptic tick per letter crossed. The magnified letter bubble tracks the * finger's vertical position (Y driven on the UI thread; the letter text only * changes on a letter-cross). Blocks the pull-to-search gesture so a scrub at * scroll-top never arms the search indicator. */ export function AlphabetRail({ activeLetters, onJumpToLetter, onScrubEnd }: AlphabetRailProps) { const styles = useStyles(); const pullSearchRef = usePullSearchGestureRef(); const [scrubLetter, setScrubLetter] = useState(null); const lastLetter = useSharedValue(''); // Rail's top offset inside the (vertically-centered) wrap + the finger's Y // within the rail, so the bubble can be placed in wrap-space. const railTop = useSharedValue(0); const bubbleY = useSharedValue(0); const scrubTo = (letter: string) => { if (!activeLetters.has(letter)) { setScrubLetter(null); return; } setScrubLetter(letter); playHaptic('frequentStep'); onJumpToLetter(letter); }; const endScrub = () => { setScrubLetter(null); onScrubEnd?.(); }; const pan = useMemo(() => { const gesture = Gesture.Pan() .minDistance(0) .onBegin((event) => { 'worklet'; lastLetter.value = ''; const y = Math.max(0, Math.min(RAIL_HEIGHT, event.y)); bubbleY.value = railTop.value + y; const index = Math.max( 0, Math.min(RAIL_LETTERS.length - 1, Math.floor((y - RAIL_PAD) / CELL_HEIGHT)) ); const letter = RAIL_LETTERS[index]; lastLetter.value = letter; runOnJS(scrubTo)(letter); }) .onUpdate((event) => { 'worklet'; const y = Math.max(0, Math.min(RAIL_HEIGHT, event.y)); // Track the finger every frame for a smooth bubble; the letter/haptic // below only fire when the letter actually changes. bubbleY.value = railTop.value + y; const index = Math.max( 0, Math.min(RAIL_LETTERS.length - 1, Math.floor((y - RAIL_PAD) / CELL_HEIGHT)) ); const letter = RAIL_LETTERS[index]; if (letter === lastLetter.value) return; lastLetter.value = letter; runOnJS(scrubTo)(letter); }) .onFinalize(() => { 'worklet'; lastLetter.value = ''; runOnJS(endScrub)(); }); return pullSearchRef ? gesture.blocksExternalGesture(pullSearchRef) : gesture; // eslint-disable-next-line react-hooks/exhaustive-deps -- scrubTo/endScrub capture the latest props via render closure }, [lastLetter, bubbleY, railTop, pullSearchRef, activeLetters, onJumpToLetter, onScrubEnd]); const bubbleStyle = useAnimatedStyle(() => ({ transform: [{ translateY: bubbleY.value - BUBBLE_SIZE / 2 }], })); const onRailLayout = (e: LayoutChangeEvent) => { railTop.value = e.nativeEvent.layout.y; }; return ( {scrubLetter ? ( {scrubLetter} ) : null} {RAIL_LETTERS.map((letter) => { const present = activeLetters.has(letter); const scrubbing = letter === scrubLetter; return ( {letter} ); })} ); } const useStyles = createThemedStyles((colors) => ({ wrap: { position: 'absolute', top: 0, bottom: 0, // Overhang the Screen's horizontal padding so the rail hugs the true edge. right: -spacing.md, justifyContent: 'center', alignItems: 'flex-end', }, // A faint scrim strip rather than a bordered glass pill: transparent enough to // feel like an overlay, dark enough to keep the letters legible over bright art. rail: { width: 16, paddingVertical: RAIL_PAD, alignItems: 'center', backgroundColor: rgbaFromHex(colors.bgPrimary, 0.35), borderRadius: radius.pill, }, cell: { height: CELL_HEIGHT, alignItems: 'center', justifyContent: 'center', }, letter: { fontSize: 10, lineHeight: CELL_HEIGHT, }, letterPresent: { color: colors.textSecondary, }, letterAbsent: { color: colors.textTertiary, opacity: 0.4, }, letterScrubbing: { color: colors.accentTextStrong, }, bubble: { position: 'absolute', top: 0, right: 34, width: BUBBLE_SIZE, height: BUBBLE_SIZE, borderRadius: radius.lg, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.bgTertiary, borderColor: colors.glassBorder, borderWidth: StyleSheet.hairlineWidth, elevation: 8, shadowColor: '#000', shadowOpacity: 0.28, shadowRadius: 12, shadowOffset: { width: 0, height: 6 }, }, bubbleLetter: { fontSize: 26, lineHeight: 30, color: colors.accentTextStrong, }, }));