// biome-ignore-all lint/style/noExcessiveLinesPerFile: row gesture, wheel fallback, and list orchestration are tightly coupled import { cva } from 'class-variance-authority'; import { cloneElement, isValidElement, memo, type ReactElement, type ReactNode, useCallback, useEffect, useRef, useState, } from 'react'; import { Animated, PanResponder, Platform, Pressable, type StyleProp, View, type ViewStyle } from 'react-native'; import { useReducedMotion } from '../../../hooks/use-reduced-motion'; import { cn } from '../../../lib/cn'; import { elevatedShadow, FLOATING_SHADOW_CLASSNAME, type SurfaceElevation, surfaceBackground } from '../../../lib/elevated'; import { useThemeColors } from '../../../theme/use-theme-color'; import { Text } from '../../typography/Text/text'; // -- Types ------------------------------------------------------------------- export type SwipeSide = 'left' | 'right'; export type SwipeActionTone = 'neutral' | 'primary' | 'success' | 'warning' | 'danger'; export type SwipeAction = { id: string; label: string; icon?: ReactNode; tone?: SwipeActionTone; disabled?: boolean }; export type SwipeableListItem = { id: string; title?: string; description?: string; meta?: string; leading?: ReactNode; content?: ReactNode; leftActions?: SwipeAction[]; rightActions?: SwipeAction[]; disabled?: boolean; }; export type SwipeActionPayload = { item: SwipeableListItem; action: SwipeAction; side: SwipeSide }; // biome-ignore lint/style/useExportsLast: props type before internal WheelEvent types — collocated for readability export type SwipeableListProps = { items: SwipeableListItem[]; onAction?: (payload: SwipeActionPayload) => void; actionWidth?: number; revealThreshold?: number; closeOnAction?: boolean; /** * Swap the row's ladder shadow for the input field's large, diffuse halo * (`shadow-floating`). It replaces the `shadow-elevated-N` rung rather than * adding to it, so the row keeps its `elevation` tint but trades the * layered drop for the halo. @default false */ floating?: boolean; /** * Surface elevation of every row's draggable surface (0–3) — drives the * background tint and the drop shadow + dark-mode rim. `0` is the flat resting * surface (no shadow or border). Defaults to `3`. */ elevation?: SurfaceElevation; /** * Root testID, defaulting to `'swipeable-list'`. Each row takes * `-row-` and each of its action buttons * `-row--action-` — the buttons are behind the * draggable surface, so a testID is the only way to reach one without first * swiping the row open. * * With no `testID`, rows keep the standalone `swipeable-row-` they * have always had. Pass one and the rows nest under it instead, which is what * keeps two lists on a screen from colliding. */ testID?: string; /** Additional UniWind class names merged onto the outer wrapper. */ className?: string; style?: StyleProp; }; // Minimal web-only wheel types — the RN package tsconfig omits the DOM lib, so // the browser `WheelEvent`/`addEventListener` globals aren't available here. type WebWheelEvent = { deltaX: number; deltaY: number; deltaMode: number; preventDefault: () => void }; type PassiveListenerOptions = { passive: boolean }; type WebWheelTarget = { addEventListener: (type: 'wheel', listener: (e: WebWheelEvent) => void, opts?: PassiveListenerOptions) => void; removeEventListener: (type: 'wheel', listener: (e: WebWheelEvent) => void) => void; }; // On web the row's host node exposes DOM wheel listeners; narrow to that shape // without a cast by probing for the methods at runtime. function isWheelTarget(node: unknown): node is WebWheelTarget { return ( node !== null && typeof node === 'object' && typeof Reflect.get(node, 'addEventListener') === 'function' && typeof Reflect.get(node, 'removeEventListener') === 'function' ); } // -- Helpers ----------------------------------------------------------------- function swipeToValue(side: SwipeSide | null, lw: number, rw: number): number { if (side === 'left') return lw; if (side === 'right') return -rw; return 0; } // -- Constants --------------------------------------------------------------- // react-native-web honours these CSS-only props at runtime, but they aren't part // of RN's ViewStyle. Type them once here so the web-only style objects validate // at declaration instead of needing an inline cast. type WebViewStyle = ViewStyle & { touchAction?: string; userSelect?: string }; const WEB_ROW_STYLE: WebViewStyle = { touchAction: 'pan-y' }; const WEB_SURFACE_STYLE: WebViewStyle = { userSelect: 'none', touchAction: 'pan-y' }; // Spring on release — matches web feel (Animated.spring uses tension/friction). const SPRING_CONFIG = { tension: 200, friction: 26, useNativeDriver: true } as const; // Thresholds — see web original. const OPEN_RATIO = 0.46; const CLOSE_RATIO = 0.72; const VELOCITY_THRESHOLD = 0.5; // ~500 px/s in PanResponder vx units (px/ms) const FLING_DISTANCE = 14; // Web wheel/trackpad: ms of horizontal-scroll idle before snapping open/closed, // and the elastic damping applied past a full reveal (matches the drag pull). const WHEEL_SETTLE = 140; const OVERSCROLL_DAMPING = 0.04; // -- Tone colours for circular badge background ------------------------------ // Static class map (Tailwind scanner picks these up as literal strings in the // cva-style object rather than dynamic concatenation). const BADGE_BACKGROUND = cva('items-center justify-center rounded-full', { variants: { tone: { neutral: 'bg-muted', primary: 'bg-primary', success: 'bg-success', warning: 'bg-warning', danger: 'bg-danger', }, }, defaultVariants: { tone: 'neutral' }, }); // Icon stroke colours that match each tone background (used for SVG icons). // Every badge background is a theme token now — neutral/primary invert with // the theme and the status tones are soft plates paired with their // `*-foreground` partner — so the render path resolves all of them reactively // via useThemeColors() inside SwipeActionButton (see `iconColor`). This static // map is exported as SWIPE_TONE_ICON_COLOR for consumers rendering swipe icons // *outside* the component; its entries are light-mode fallbacks. const ICON_COLOR: Record = { neutral: '#626366' /* theme-exempt */, // mirrors light `muted-foreground` — resolved reactively in-component primary: '#f8f8fa' /* theme-exempt */, // mirrors light `primary-foreground` — resolved reactively in-component success: '#007500' /* theme-exempt */, // mirrors light `success-foreground` — resolved reactively in-component warning: '#a07100' /* theme-exempt */, // mirrors light `warning-foreground` — resolved reactively in-component danger: '#c53637' /* theme-exempt */, // mirrors light `danger-foreground` — resolved reactively in-component }; // -- SwipeActionButton ------------------------------------------------------- type SwipeActionButtonProps = { action: SwipeAction; actionWidth: number; side: SwipeSide; onAction: (action: SwipeAction, side: SwipeSide) => void; /** The owning row's testID; the button takes `-action-` from it. */ testID?: string; }; function SwipeActionButton({ action, actionWidth, side, onAction, testID }: SwipeActionButtonProps) { const tone = action.tone ?? 'neutral'; const colors = useThemeColors(); // Reactive per-tone icon colour. neutral/primary track the theme — their badge // backgrounds (`bg-muted`/`bg-primary`) invert, so the icon must invert too. // Status tones pair their soft plate background with the matching // `*-foreground` token, which is tuned for legibility on the plate in both // light and dark mode. const iconColor: Record = { neutral: colors['muted-foreground'], primary: colors['primary-foreground'], success: colors['success-foreground'], warning: colors['warning-foreground'], danger: colors['danger-foreground'], }; const handlePress = useCallback(() => onAction(action, side), [onAction, action, side]); return ( {isValidElement(action.icon) ? // Override the icon's colour with the tone-matched, theme-reactive // value so neutral/primary invert correctly in dark mode. Callers may // bake a colour into the icon node, but the tone owns the final stroke // here to stay legible against the badge background. // biome-ignore lint/plugin: action.icon is a consumer ReactNode; casting to ReactElement<{color?}> lets us inject the tone stroke without narrowing away other valid node shapes cloneElement(action.icon as ReactElement<{ color?: string }>, { color: iconColor[tone] }) : (action.icon ?? null)} {/* sr-only equivalent: label is set on the Pressable above */} {action.label} ); } // -- SwipeableListRow -------------------------------------------------------- export type SwipeableListRowProps = { item: SwipeableListItem; actionWidth: number; revealThreshold: number; openId: string | null; setOpenId: (id: string | null) => void; closeOnAction: boolean; floating: boolean; elevation: SurfaceElevation; onAction?: SwipeableListProps['onAction']; /** The row's own testID; its action buttons derive `-action-` from it. */ testID?: string; }; // biome-ignore lint/complexity/noExcessiveLinesPerFunction: gesture, wheel, and rendering are unified to share PanResponder and animation values function SwipeableListRow({ item, actionWidth, revealThreshold, openId, setOpenId, closeOnAction, floating, elevation, onAction, testID, }: SwipeableListRowProps) { const reduce = useReducedMotion(); const translateX = useRef(new Animated.Value(0)).current; // Track translateX in JS for use inside PanResponder release handler. const currentXRef = useRef(0); // Track the open side for this row (so PanResponder sees fresh values via handlersRef). const openSideRef = useRef(null); const leftActions = item.leftActions ?? []; const rightActions = item.rightActions ?? []; const leftWidth = leftActions.length * actionWidth; const rightWidth = rightActions.length * actionWidth; // Keep refs up-to-date so PanResponder always reads fresh values. const leftWidthRef = useRef(leftWidth); const rightWidthRef = useRef(rightWidth); const revealThresholdRef = useRef(revealThreshold); const reduceRef = useRef(reduce); const openIdRef = useRef(openId); // Sync props into refs so PanResponder callbacks always read the latest value // without needing a stale-closure workaround. Direct assignment during render // is the correct pattern — no useEffect needed. leftWidthRef.current = leftWidth; rightWidthRef.current = rightWidth; revealThresholdRef.current = revealThreshold; reduceRef.current = reduce; openIdRef.current = openId; // biome-ignore lint/plugin: subscribing to Animated.Value via addListener is an imperative side effect — not expressible without useEffect useEffect(() => { const id = translateX.addListener(({ value }) => { currentXRef.current = value; }); return () => translateX.removeListener(id); }, [translateX]); // Stable snap function (reads from refs, never stale). const snapTo = useCallback( (side: SwipeSide | null, vx = 0) => { const lw = leftWidthRef.current; const rw = rightWidthRef.current; const toValue = swipeToValue(side, lw, rw); openSideRef.current = side; if (reduceRef.current) { translateX.setValue(toValue); return; } Animated.spring(translateX, { toValue, velocity: vx, ...SPRING_CONFIG, }).start(); }, [translateX], ); // Decide the resting state from a position + velocity, then snap + report it. // Shared by drag release (PanResponder) and web wheel settle so both gestures // open/close by the exact same rules. const resolveSwipe = useCallback( (x: number, vx: number) => { const lw = leftWidthRef.current; const rw = rightWidthRef.current; const rt = revealThresholdRef.current; const leftOpenThreshold = Math.max(rt, lw * OPEN_RATIO); const rightOpenThreshold = Math.max(rt, rw * OPEN_RATIO); const curSide = openSideRef.current; if (curSide === 'left') { if (x < lw * CLOSE_RATIO || vx < -VELOCITY_THRESHOLD) { setOpenId(null); snapTo(null, vx); } else { setOpenId(item.id); snapTo('left', vx); } return; } if (curSide === 'right') { if (Math.abs(x) < rw * CLOSE_RATIO || vx > VELOCITY_THRESHOLD) { setOpenId(null); snapTo(null, vx); } else { setOpenId(item.id); snapTo('right', vx); } return; } // Not yet open — decide whether to open. if (lw > 0 && (x > leftOpenThreshold || (vx > VELOCITY_THRESHOLD && x > FLING_DISTANCE))) { setOpenId(item.id); snapTo('left', vx); return; } if (rw > 0 && (x < -rightOpenThreshold || (vx < -VELOCITY_THRESHOLD && x < -FLING_DISTANCE))) { setOpenId(item.id); snapTo('right', vx); return; } setOpenId(null); snapTo(null, vx); }, [item.id, snapTo, setOpenId], ); // When another row opens (openId changes away from this row's id), close this row. // biome-ignore lint/plugin: closing a sibling row when openId changes is an imperative side effect on the animation value — not expressible without useEffect useEffect(() => { if (openId !== item.id && openSideRef.current !== null) snapTo(null); }, [openId, item.id, snapTo]); // Ref for handlers, updated every render so PanResponder sees current values. const handlersRef = useRef({ snapTo, resolveSwipe, setOpenId, onAction, itemId: item.id, itemDisabled: item.disabled, closeOnAction, }); handlersRef.current = { snapTo, resolveSwipe, setOpenId, onAction, itemId: item.id, itemDisabled: item.disabled, closeOnAction, }; const panResponder = useRef( PanResponder.create({ // Only claim the responder when horizontal movement is dominant. onMoveShouldSetPanResponder: (_, gs) => { if (handlersRef.current.itemDisabled) return false; return Math.abs(gs.dx) > Math.abs(gs.dy) * 1.5 && Math.abs(gs.dx) > 4; }, onPanResponderGrant: () => { // If a different row is open, close it. const { itemId, setOpenId: setOpen } = handlersRef.current; if (openIdRef.current !== null && openIdRef.current !== itemId) setOpen(null); // Stop any in-flight spring. translateX.stopAnimation(); }, onPanResponderMove: (_, gs) => { const lw = leftWidthRef.current; const rw = rightWidthRef.current; const base = swipeToValue(openSideRef.current, lw, rw); let next = base + gs.dx; // Elastic resistance beyond full reveal. if (next > lw) next = lw + (next - lw) * OVERSCROLL_DAMPING; else if (next < -rw) next = -rw + (next + rw) * OVERSCROLL_DAMPING; translateX.setValue(next); currentXRef.current = next; }, onPanResponderRelease: (_, gs) => { // vx is px/ms; currentXRef holds the live translate set during the move. handlersRef.current.resolveSwipe(currentXRef.current, gs.vx); }, onPanResponderTerminate: () => { handlersRef.current.snapTo(null); }, }), ).current; const handleAction = useCallback( (action: SwipeAction, side: SwipeSide) => { handlersRef.current.onAction?.({ item, action, side }); if (handlersRef.current.closeOnAction) { setOpenId(null); snapTo(null); } }, // item changes identity when parent re-renders (immutable data pattern). // eslint-disable-next-line react-hooks/exhaustive-deps [item, snapTo, setOpenId], ); // Web: reveal actions with a horizontal wheel / trackpad two-finger scroll, // mirroring the drag gesture. Native has no wheel, so this is a no-op there. // The RN package tsconfig omits the DOM lib, so type the node/event minimally. const rowRef = useRef(null); const wheelSettleRef = useRef | null>(null); const wheelingRef = useRef(false); // biome-ignore lint/plugin: non-passive DOM wheel listener must be attached imperatively as a side effect — the RN synthetic handler is passive and can't block scroll useEffect(() => { if (Platform.OS !== 'web') return; const node = rowRef.current; if (!isWheelTarget(node)) return; const onWheel = (e: WebWheelEvent) => { if (handlersRef.current.itemDisabled) return; // Reveal is horizontal. Only claim horizontal-dominant scroll (trackpad // two-finger horizontal / shift + wheel); vertical scroll passes through // so a list embedded in a page still scrolls normally. if (Math.abs(e.deltaX) <= Math.abs(e.deltaY) || e.deltaX === 0) return; // Claim this gesture: block the page from scrolling and stop any spring. e.preventDefault(); if (!wheelingRef.current) { wheelingRef.current = true; translateX.stopAnimation(); } const lw = leftWidthRef.current; const rw = rightWidthRef.current; // Line-mode deltas (deltaMode 1) are in rows, not px — scale to ~16px. const px = e.deltaMode === 1 ? e.deltaX * 16 : e.deltaX; // Scroll right (positive delta) reveals right actions → content moves left. let next = currentXRef.current - px; // Same elastic resistance past a full reveal as the drag path. if (next > lw) next = lw + (next - lw) * OVERSCROLL_DAMPING; else if (next < -rw) next = -rw + (next + rw) * OVERSCROLL_DAMPING; translateX.setValue(next); currentXRef.current = next; // Debounce the settle: once the wheel goes idle, snap open/closed by the // same rules as a drag release (no fling velocity from a wheel). if (wheelSettleRef.current) clearTimeout(wheelSettleRef.current); wheelSettleRef.current = setTimeout(() => { wheelingRef.current = false; handlersRef.current.resolveSwipe(currentXRef.current, 0); }, WHEEL_SETTLE); }; node.addEventListener('wheel', onWheel, { passive: false }); return () => { node.removeEventListener('wheel', onWheel); if (wheelSettleRef.current) clearTimeout(wheelSettleRef.current); }; }, [translateX]); const defaultContent = ( {item.leading ? {item.leading} : null} {item.title ? ( {item.title} ) : null} {item.description ? ( {item.description} ) : null} {item.meta ? ( {item.meta} ) : null} ); return ( {/* Action rail — rendered behind the draggable surface */} {/* Left actions */} {leftActions.map((action) => ( ))} {/* Right actions */} {rightActions.map((action) => ( ))} {/* Draggable surface */} {item.content ?? defaultContent} ); } // -- SwipeableList ----------------------------------------------------------- export function SwipeableListImpl({ items, onAction, actionWidth = 56, revealThreshold = 34, closeOnAction = true, floating = false, elevation = 0, testID, className, style, }: SwipeableListProps) { // Track which row is currently open (by item id). const [openId, setOpenId] = useState(null); return ( {items.map((item) => ( ` is the shape this // component has always emitted, kept as the no-root fallback. testID={testID ? `${testID}-row-${item.id}` : `swipeable-row-${item.id}`} /> ))} ); } export const SwipeableList = memo(SwipeableListImpl); // Export tone colour helper so stories can apply icon colours matching the badge. // biome-ignore lint/style/useComponentExportOnlyModules: SWIPE_TONE_ICON_COLOR is a styling constant needed by consumers; extracting it to a separate file would break colocation with the badge colour map it mirrors export { ICON_COLOR as SWIPE_TONE_ICON_COLOR };