// Shared machinery for the Button family. Button and ElevatedButton both drive // the same tap interaction (press-scale flag + Material ripples + measured pixel // size) and lay out the same content row (label, optional adornments, loading // indicator), so those pieces live here rather than being duplicated per sibling. // Nothing here is variant-aware: each component resolves its own colours/classes // and passes the results in. import { Children, isValidElement, type ReactNode, useCallback, useContext, useEffect, useRef, useState } from 'react'; import type { GestureResponderEvent, LayoutChangeEvent, StyleProp, ViewStyle } from 'react-native'; import { StyleSheet, View } from 'react-native'; import Animated, { cancelAnimation, Easing, useAnimatedStyle, useSharedValue, withDelay, withRepeat, withTiming, } from 'react-native-reanimated'; import Svg, { Circle } from 'react-native-svg'; import { useMountEffect } from '../../../hooks/use-mount-effect'; import { cn } from '../../../lib/cn'; import { scaleAlpha } from '../../../lib/color'; import { EASE_IN_OUT, EASE_OUT } from '../../../lib/ease'; import { MotiView } from '../../../moti/components/view'; import { PresenceContext } from '../../../moti/presence/animate-presence-context'; import type { MotiTransitionProp } from '../../../theme/motion'; import { Text } from '../../typography/Text/text'; import type { PressMode } from './button-press'; import { BUTTON_GAP_CLASSNAME } from './button-scale'; // ── module-local types & helpers ──────────────────────────────────────────── // Kept ahead of the exports (useExportsLast). `Ripple` stays local: consumers // destructure `usePressRipples`' return and never name the type themselves. type Ripple = { id: number; x: number; y: number; size: number; released: boolean }; type ButtonRipplesProps = { ripples: Ripple[]; color: string; filled: boolean; zIndex?: number }; type UsePressRipplesArgs = { ripple: boolean; reduce: boolean; trackDims: boolean }; // Ripple timing: the ink blooms out over GROW, holds at full opacity while the // button is pressed, then fades over FADE once the press is released — the // ripple no longer evaporates mid-press. The release fade is eased out (a steep // start that lingers near transparent) rather than a flat linear dissolve. const RIPPLE_GROW_MS = 300; const RIPPLE_FADE_MS = 500; // The translucent "active" alpha a ripple wears, by plate: an opaque fill needs a // stronger wash (0.35) for the ink to read against its saturated fill, a light or // transparent plate a subtler one (0.12). The colour itself is the button's own // foreground ink, resolved per-variant (see ButtonRipples). const RIPPLE_ALPHA_FILLED = 0.35; const RIPPLE_ALPHA_LIGHT = 0.12; type BuildContentArgs = { loading: boolean | undefined; reduce: boolean; /** Resolved label class (colour + size), computed by the caller so each button * can fold its own variant/colour/hover override in before it reaches here. */ labelClass: string; children: ReactNode; leftAdornment: ReactNode; rightAdornment: ReactNode; spinnerColor: string; labelClassName: string | undefined; }; function renderChild(child: ReactNode, className: string, labelClassName?: string): ReactNode { if (typeof child === 'string' || typeof child === 'number') return ( {child} ); return isValidElement(child) ? child : null; } // ── exports ───────────────────────────────────────────────────────────────── // The size/shape axes and the label ramp live in button-scale.ts, next to the // box geometry they belong to (and reachable without pulling in this file's // SVG/Moti dependencies). Re-exported here so every sibling keeps importing its // types from one place. export type { ButtonShape, ButtonSize } from './button-scale'; /** * Props shared by every button in the family (Button, ElevatedButton). Each * component adds its own visual axis on top — Button its variant table, * ElevatedButton its coloured `variant` — but the interaction/layout surface * below is identical across all of them. */ export type BaseButtonProps = { children?: ReactNode; /** Node rendered to the left of the button label. */ leftAdornment?: ReactNode; /** Node rendered to the right of the button label. */ rightAdornment?: ReactNode; onPress?: () => void; disabled?: boolean; loading?: boolean; /** Spawn a Material-style ripple from the press point. On by default. */ ripple?: boolean; /** Settled scale for the uniform `scaleUp`/`scaleDown` press modes. Omit to * use the mode's own default (1.05 growing, 0.93 shrinking). */ pressScale?: number; /** * Shape of the press animation. * - `scaleUp` (default) — grows to `pressScale` (uniform, default 1.05). * - `scaleDown` — shrinks to `pressScale` (uniform, default 0.93). * - `scaleY` — compresses vertically and nudges down (horizontal segmented controls). * - `scaleX` — compresses horizontally (vertical segmented controls, middle buttons). * - `scaleXFirst` — compresses horizontally and nudges up (first button in a vertical group). * - `scaleXLast` — compresses horizontally and nudges down (last button in a vertical group). * - `none` — no press animation at all. */ pressMode?: PressMode; /** When true, skip the 0.5 opacity applied to disabled buttons. */ noDisabledOpacity?: boolean; /** Colour shown as an absolutely-positioned overlay behind the button content. */ backdropColor?: string; /** * Override the press-scale spring. Partial — only the fields you pass are changed. * Default: `MOTION_SNAPPY` (stiffness 500, damping 30, mass 0.6). */ pressTransition?: Partial; /** Stretch the button to fill its container width. */ fitWidth?: boolean; /** Additional UniWind class names merged onto the outer wrapper. */ className?: string; /** Additional class names merged onto the label Text. */ labelClassName?: string; /** Tailwind classes merged onto the Pressable container. */ contentClassName?: string; style?: StyleProp; accessibilityLabel?: string; testID?: string; }; /** * Material-style press ripples. Each ripple wears the button's own foreground * ink (`color`), tinted translucent with `scaleAlpha`, so the wash matches the * active colour and flips with the theme instead of hardcoding a white/dark * shimmer. The ink blooms out on press-in, holds at full opacity while the press * is held, then fades out on release — it no longer dissolves the instant it * spawns. `filled` only picks the alpha strength: 0.35 for opaque fills, 0.12 for * light or transparent plates. * * Renders a single absolutely-positioned container so every ripple MotiView * shares the same coordinate origin (the Pressable it sits inside) and the same * z-index layer — a bare array of absolutely-positioned siblings would each * position relative to its own parent, and a nested wraper (an earlier approach) * shifts the origin to the wrapper rather than to the Pressable whose * `locationX`/`locationY` the coordinates came from. */ export function ButtonRipples({ ripples, color, filled, zIndex }: ButtonRipplesProps) { if (ripples.length === 0) return null; const tint = scaleAlpha(color, filled ? RIPPLE_ALPHA_FILLED : RIPPLE_ALPHA_LIGHT); return ( {ripples.map((rp) => ( ))} ); } // Owns the tap interaction: the `pressed` flag (drives the scale spring), the live // ripple list, and the button's measured pixel size. `size_` is a ref — read // synchronously on press to size a ripple — while `dims` is state because the // ElevatedButton rim SVG needs it to re-render; that state write is gated behind // `trackDims` so no other button pays for a render on layout. // biome-ignore lint/style/useComponentExportOnlyModules: shared button machinery — the hook and content builder are consumed alongside ButtonRipples by both Button and ElevatedButton; splitting them off would fragment tightly-coupled press/layout logic // biome-ignore lint/style/useExportsLast: ButtonSpinner (non-export) sits between usePressRipples and other exports; moving it would separate the continuous spinner from the press machinery it belongs with export function usePressRipples({ ripple, reduce, trackDims }: UsePressRipplesArgs) { const [pressed, setPressed] = useState(false); const [ripples, setRipples] = useState([]); const [dims, setDims] = useState({ w: 0, h: 0 }); const nextId = useRef(0); const size_ = useRef({ w: 0, h: 0 }); // Pending post-release cleanup: clears the faded ripples from state. Held in a // ref so a rapid re-press can cancel it rather than dropping a fresh ripple. const clearTimer = useRef | null>(null); const onLayout = useCallback( (e: LayoutChangeEvent) => { const { width, height } = e.nativeEvent.layout; size_.current = { w: width, h: height }; if (trackDims) setDims((prev) => (prev.w === width && prev.h === height ? prev : { w: width, h: height })); }, [trackDims], ); // Cancel any pending cleanup on unmount so a fading ripple can't set state after // the button has gone. useMountEffect(() => () => { if (clearTimer.current !== null) clearTimeout(clearTimer.current); }); const handlePressIn = useCallback( (e: GestureResponderEvent) => { setPressed(true); if (!ripple || reduce) return; const { locationX: x, locationY: y } = e.nativeEvent; const r = Math.max(size_.current.w, size_.current.h) * 2; const id = nextId.current; nextId.current += 1; if (clearTimer.current !== null) { clearTimeout(clearTimer.current); clearTimer.current = null; } // Drop ripples from a previous press that already finished fading, then add // the fresh one — it holds at full opacity until release. setRipples((prev) => [...prev.filter((rp) => !rp.released), { id, x, y, size: r, released: false }]); }, [ripple, reduce], ); const handlePressOut = useCallback(() => { setPressed(false); if (!ripple || reduce) return; // Mark every live ripple released so it fades, then schedule its removal once // the fade completes. setRipples((prev) => prev.map((rp) => ({ ...rp, released: true }))); if (clearTimer.current !== null) clearTimeout(clearTimer.current); clearTimer.current = setTimeout(() => { setRipples((prev) => prev.filter((rp) => !rp.released)); clearTimer.current = null; }, RIPPLE_FADE_MS); }, [ripple, reduce]); return { pressed, ripples, dims, onLayout, handlePressIn, handlePressOut }; } /** * Continuous 0°→360° rotation for the loading spinner. Imperative * (shared value + withRepeat) for the same reason as BadgeSpinner — * MotiView's declarative `loop` re-issues the animation on every * parent re-render, restarting mid-revolution, and default easing * pauses at the repeat boundary. */ type ButtonSpinnerProps = { color: string; reduce: boolean /** Diameter in px. @default 16 */; size?: number }; export function ButtonSpinner({ color, reduce, size = 16 }: ButtonSpinnerProps) { const rotation = useSharedValue(0); const r = size * 0.375; // proportional radius (6/16 = 0.375) const sw = size * 0.125; // proportional stroke width (2/16 = 0.125) const c = size / 2; const circumference = Math.PI * 2 * r; // biome-ignore lint/plugin: Reanimated withRepeat loop must be started and cancelled as a side effect — not expressible as derived state useEffect(() => { if (reduce) { rotation.value = 0; return; } rotation.value = withRepeat(withTiming(360, { duration: 800, easing: Easing.linear }), -1, false); return () => cancelAnimation(rotation); }, [reduce, rotation]); const animatedStyle = useAnimatedStyle(() => ({ transform: [{ rotate: `${rotation.value}deg` }], })); return ( ); } // Three staggered bouncing dots shared by Button, ElevatedButton and StatefulButton. // Drive the bounce imperatively (shared value + withRepeat created // once in an effect) rather than moti's declarative `loop` — the same failure // mode ButtonSpinner's imperative pattern avoids: moti rebuilds its withRepeat on // every worklet re-run and a re-render landing at the target (theme toggle, // hover) leaves a dot frozen mid-bounce. const BUTTON_DOT_SIZE = 4; const BUTTON_DOT_GAP = 3; const BUTTON_DOT_BOUNCE = 4; const BUTTON_DOT_STAGGER = 120; type ButtonDotProps = { color: string; reduce: boolean; index: number }; function ButtonDot({ color, reduce, index }: ButtonDotProps) { const isPresent = useContext(PresenceContext)?.isPresent ?? true; const translateY = useSharedValue(0); const opacity = useSharedValue(0.5); // biome-ignore lint/plugin: the loop animation is an imperative side-effect assigned to a shared value — not expressible as derived state, and must run once per [reduce, index] rather than every render useEffect(() => { if (reduce || !isPresent) { translateY.value = 0; opacity.value = 1; return; } opacity.value = withTiming(1, { duration: 240 }); translateY.value = withDelay( index * BUTTON_DOT_STAGGER, withRepeat(withTiming(-BUTTON_DOT_BOUNCE, { duration: 400, easing: EASE_IN_OUT }), -1, true), ); return () => { cancelAnimation(translateY); cancelAnimation(opacity); }; }, [reduce, isPresent, index, translateY, opacity]); const animatedStyle = useAnimatedStyle(() => ({ transform: [{ translateY: translateY.value }], opacity: opacity.value, })); return ( ); } type ButtonDotsProps = { color: string; reduce: boolean }; export function ButtonDots({ color, reduce }: ButtonDotsProps) { return ( {[0, 1, 2].map((i) => ( ))} ); } // biome-ignore lint/style/useComponentExportOnlyModules: see usePressRipples — buildButtonContent is the layout half of the same shared press/content machinery export function buildButtonContent({ loading, reduce, labelClass, children, leftAdornment, rightAdornment, spinnerColor, labelClassName, }: BuildContentArgs): ReactNode { if (loading) return ; const hasAdornments = leftAdornment !== undefined || rightAdornment !== undefined; const isLeaf = typeof children === 'string' || typeof children === 'number'; const mergedLabelClass = cn(labelClass, labelClassName); if (isLeaf && !hasAdornments) return ( {children} ); return ( {leftAdornment} {isLeaf ? ( {children} ) : ( Children.map(children, (child) => renderChild(child, labelClass, labelClassName)) )} {rightAdornment} ); }