/** biome-ignore-all lint/style/noExcessiveLinesPerFile: the three swap animations, the two slot primitives and the button that composes them read best in one file */ import type { VariantProps } from 'class-variance-authority'; import { type ReactNode, useCallback, useState } from 'react'; import { type LayoutChangeEvent, Pressable, type StyleProp, StyleSheet, View, type ViewStyle } from 'react-native'; import { usePageVisible } from '../../../hooks/use-page-visible'; import { useReducedMotion } from '../../../hooks/use-reduced-motion'; import { cn } from '../../../lib/cn'; import { EASE_IN_OUT, EASE_OUT, SPRING_SWAP } from '../../../lib/ease'; import { elevatedShadow, FLOATING_SHADOW_CLASSNAME, type SurfaceElevation } from '../../../lib/elevated'; import { MotiText } from '../../../moti/components/text'; import { MotiView } from '../../../moti/components/view'; import { AnimatePresence } from '../../../moti/presence/animate-presence'; import { MOTION_SNAPPY, mergeTransition, TIMING_BASE } from '../../../theme/motion'; import { useThemeColors } from '../../../theme/use-theme-color'; import { Text, type TextWeight } from '../../typography/Text/text'; import { type BaseButtonProps, ButtonRipples, usePressRipples } from './button-internals'; import { pressAnimate } from './button-press'; import { BUTTON_BOX, BUTTON_GAP_CLASSNAME, BUTTON_ICON_SIZE, type ButtonShape, type ButtonSize } from './button-scale'; import { BUTTON_HOVER_CLASS, buttonContainer as container, FILLED_RIPPLE_VARIANTS, buttonLabel as labelClass, variantIconColorToken, } from './button-variants'; // The family's public types, re-exported so a ButtonSwap consumer takes its // axes from the same place a Button consumer does. export type { ButtonShape, ButtonSize } from './button-scale'; export type { ButtonVariant } from './button-variants'; export type ButtonSwapItem = { id: string; label: ReactNode; icon?: ReactNode; ariaLabel?: string }; // biome-ignore lint/style/useExportsLast: CoreAnimation narrows this type and must stay immediately below it for readability export type ButtonSwapAnimation = 'blur' | 'roll' | 'cascade'; /** Animations with a single-element variant set (cascade animates per letter). */ type CoreAnimation = 'blur' | 'roll'; const BLUR_TRANSITION = { type: 'timing', duration: 200, easing: EASE_IN_OUT } as const; const BLUR_EXIT = { type: 'timing', duration: 200, easing: EASE_IN_OUT } as const; const ROLL_TRANSITION = { type: 'timing', duration: 240, easing: EASE_OUT } as const; const ROLL_EXIT = { type: 'timing', duration: 180, easing: EASE_IN_OUT } as const; // Cascade rolls the label one letter at a time, left to right. Each letter // enters from below on SPRING_SWAP and exits upward on a short tween, and the // exits are staggered at half the enter rate so the tail of the old label // lingers briefly — mirroring the web reference (0.025 s stagger, spring in, // 0.16 s ease-out exit at half stagger). const CASCADE_STAGGER = 25; // ms between letters (web original: 0.025 s) const CASCADE_EXIT_DURATION = 160; // ms (web original: 0.16 s) // Fallback roll distance before the slot has been measured (px). const ROLL_FALLBACK = 18; export type ButtonSwapTextProps = { value: string; children: ReactNode; animation?: ButtonSwapAnimation; /** Applied to the outer measured slot. */ style?: StyleProp; /** Applied to the rendered text (colour/size). */ textClassName?: string; /** Font weight (resolves a per-weight font token). */ weight?: TextWeight; testID?: string; }; export type ButtonSwapIconProps = { value: string; children: ReactNode; animation?: ButtonSwapAnimation; /** Square edge of the icon slot in px. Defaults to the family's `md` adornment * icon size ({@link BUTTON_ICON_SIZE.md}); {@link ButtonSwap} always passes its * own per-`size` value, so this default only serves standalone use. */ size?: number; style?: StyleProp; testID?: string; }; /** * A Button whose label and icon swap under it. It carries the whole Button * styling surface — the same `variant` table, `size`/`shape` box, `elevation`, * `floating` halo, ripples and class overrides — so a swapping button drops into * a row of Buttons without a seam. What it does NOT take from Button is content: * the label comes from `items`, not `children`, and the state it swaps between is * its own (there is no `loading`; reach for {@link StatefulButton} for that). */ export interface ButtonSwapProps extends VariantProps, Omit { items: ButtonSwapItem[]; value?: string; defaultValue?: string; onValueChange?: (value: string, item: ButtonSwapItem) => void; size?: ButtonSize; /** * Corner treatment. Defaults to `rounded` — the family's 8px interactive radius * — so it matches a neighbouring Button. Pass `pill` for the capsule this * component has always worn. */ shape?: ButtonShape; animation?: ButtonSwapAnimation; iconOnly?: boolean; /** Advance to the next item on press. Default true. */ cycle?: boolean; /** * Swap the button's ladder shadow for the input field's large, diffuse halo * (`shadow-floating`) — the same recipe {@link Button}'s `floating` prop wears. * It *replaces* whatever shadow `elevation` resolved rather than adding to it, * since both write `box-shadow`. @default false */ floating?: boolean; /** * Shadow level (0–3) the button casts. Drives the shadow *only* — the fill * comes from `variant`, not the surface ladder — so raising `elevation` floats * the button without recolouring it. `0` is flat (no shadow). @default 0 */ elevation?: SurfaceElevation; } export function ButtonSwapText({ value, children, animation = 'blur', textClassName = 'text-foreground', weight, ...props }: ButtonSwapTextProps) { const reduce = useReducedMotion(); const pageVisible = usePageVisible(); const [rollHeight, setRollHeight] = useState(0); const onLayout = useCallback( (e: LayoutChangeEvent) => { const h = e.nativeEvent.layout.height; if (h && h !== rollHeight) setRollHeight(h); }, [rollHeight], ); // Cascade needs a plain string to split into letters; non-string content and // reduced motion fall back to the closest single-element animation (roll). const label = typeof children === 'string' ? children : null; const cascade = animation === 'cascade' && label !== null && !reduce; const core: CoreAnimation = animation === 'cascade' ? 'roll' : animation; const roll = rollHeight || ROLL_FALLBACK; // Cascade letters travel 105% of the line box (web: y "105%"/"-105%") so // ascenders/descenders fully clear the clip before fading. const cascadeRoll = Math.round(roll * 1.05); // Reduced motion renders statically — and so does a hidden page: rAF is // paused in background tabs, so swaps arriving while hidden would queue // animations that all replay from their initial state on return. Rendering // the settled label instead lands background swaps instantly, and the swap // picks up from the current value once the page is visible again. if (reduce || !pageVisible) return ( {label === null ? ( children ) : ( {label} )} ); return ( {/* Hidden sizer establishes the slot width/height for the current label. String labels size a Text; arbitrary nodes size a wrapping View. */} {label === null ? ( {children} ) : ( {label} )} {cascade && label !== null ? ( {Array.from(label).map((char, i) => ( {/* biome-ignore lint/suspicious/noLeakedRender: char is always a string character — safe alternate branch */} {char === ' ' ? ' ' : char} ))} ) : ( {label === null ? ( children ) : ( {label} )} )} ); } export function ButtonSwapIcon({ value, children, animation = 'blur', size = BUTTON_ICON_SIZE.md, style, testID, }: ButtonSwapIconProps) { const reduce = useReducedMotion(); const pageVisible = usePageVisible(); // Icons are single elements — cascade maps to its closest motion, roll. const core: CoreAnimation = animation === 'cascade' ? 'roll' : animation; // Same hidden-page fallback as ButtonSwapText — see the comment there. if (reduce || !pageVisible) return ( {children} ); return ( {children} ); } export function ButtonSwap({ items, value, defaultValue, onValueChange, variant = 'neutral', size = 'md', shape = 'pill', floating = false, elevation, animation = 'blur', iconOnly = size === 'icon', cycle = true, onPress, disabled, ripple = true, pressScale, pressMode = 'scaleUp', noDisabledOpacity = false, backdropColor, pressTransition, fitWidth, className, contentClassName, labelClassName, style, accessibilityLabel, testID, }: ButtonSwapProps) { const reduce = useReducedMotion(); const colors = useThemeColors(); const pressSpring = mergeTransition(MOTION_SNAPPY, pressTransition); const v = variant ?? 'neutral'; const isDisabled = Boolean(disabled); // The ripple wears the variant's foreground ink so the wash matches the active // colour and flips with the theme. const rippleColor = colors[variantIconColorToken(v)]; // The shadow is `elevation`-driven and defaults to flat (`0`); `floating` // swaps whichever rung resolves for the halo. const resolvedElevation: SurfaceElevation = elevation ?? 0; const { pressed, onLayout, ripples, handlePressIn, handlePressOut } = usePressRipples({ ripple, reduce, trackDims: false, }); const [internalValue, setInternalValue] = useState(defaultValue ?? items[0]?.id); const currentValue = value ?? internalValue; const activeIndex = Math.max( 0, items.findIndex((item) => item.id === currentValue), ); const activeItem = items[activeIndex] ?? items[0]; const hasIcon = items.some((item) => item.icon); const nextItem = cycle && items.length > 0 ? items[(activeIndex + 1) % items.length] : undefined; const handlePress = useCallback(() => { if (disabled) return; onPress?.(); if (!(cycle && nextItem)) return; if (value === undefined) setInternalValue(nextItem.id); onValueChange?.(nextItem.id, nextItem); }, [disabled, cycle, nextItem, value, onValueChange, onPress]); if (!activeItem) return null; const accessibleLabel = accessibilityLabel ?? activeItem.ariaLabel ?? (iconOnly && typeof activeItem.label === 'string' ? activeItem.label : undefined); return ( {/* State backdrop — animates in/out by opacity so the variant background shows through when idle and the state colour fills it when set. */} {hasIcon ? ( {activeItem.icon ?? null} ) : null} {iconOnly ? null : ( {activeItem.label} )} {ripple && !reduce ? ( ) : null} ); }