/** * Tabs — segmented navigation between panels. * * The active tab is marked by one indicator that slides between measured * trigger positions rather than by a style on each trigger. That is what makes * the movement continuous: there is a single thing travelling, so a switch two * tabs away reads as one gesture instead of two states swapping. * * ```tsx * * * Account * 3}>Team * * … * * ``` * * **Swiping puts the panels in a row.** With `swipeable`, the panels are laid * out side by side in a strip as wide as all of them, inside a viewport that * shows one at a time, and moving between tabs is that strip translating. The * neighbours are therefore already built and already the right size before the * finger arrives at them, which is the whole point: a panel that has to be * mounted and measured at the moment it becomes visible is a panel that stalls * there, and it stalls for exactly as long as it takes to build. * * One shared value carries the strip's position, in panels rather than points, * and it is the only thing that decides where the strip is. A press springs it, * a drag sets it, and neither waits for React: the value the tab set reports is * updated alongside the movement, not ahead of it. * * A swipeable tab set therefore needs a height to fill, the same as any pager. * Give it one — `flex-1` on the tab set, or a fixed height — or the strip has * nothing to lay its panels out in. */ import { Children, createContext, Fragment, isValidElement, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactElement, type ReactNode, } from 'react'; import { Pressable, ScrollView, View, type LayoutChangeEvent, type ViewProps, } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { Easing, Extrapolation, FadeIn, interpolate, runOnJS, useAnimatedStyle, useReducedMotion, useSharedValue, withSpring, withTiming, } from 'react-native-reanimated'; import { tv } from 'tailwind-variants'; import { useDirectionSign } from '../../hooks/use-direction'; import { Text, textChildren } from '../../primitives/text'; import { cn } from '../../utils/cn'; const SPRING = { damping: 24, stiffness: 300, mass: 0.7 } as const; /** * How far sideways a finger must travel before the panel takes the gesture * from whatever is scrolling above it, and how far up or down before it gives * up on it. The window between them is what lets a swipe start inside a * vertically scrolling panel without either gesture stealing the other. */ const SWIPE_ACTIVATE_X = 12; const SWIPE_FAIL_Y = 8; /** A swipe past this share of a panel's width changes tab on release. */ const SWIPE_DISTANCE_RATIO = 0.25; /** …or past this speed, however short it was, in points per second. */ const SWIPE_VELOCITY = 500; /** * How much of the finger's travel the strip follows at the ends of the row, * where there is no further panel to bring on. Everywhere else it follows all * of it. */ const SWIPE_RESISTANCE_AT_END = 0.16; /** The spring the strip settles on, whether it was thrown or pressed. */ const ENTER_SPRING = { damping: 22, stiffness: 240, mass: 0.6 } as const; /** How far the label's reveal is from its own width, in points — the gap after the icon. */ const LABEL_GAP = 6; /** Points of room over the measured label, so a rounding error cannot truncate it. */ const LABEL_SLACK = 2; /** Milliseconds for a tab to open its label, and for the one before it to close. */ const EXPAND_DURATION = 260; /** * A width the ghost label is measured inside. * * A `Text` measures against the box it is in, and the box it is really in is a * pill whose width is the thing being measured. So the copy that gets measured * sits in a box wide enough not to be the constraint, and reports the width the * label actually wants. */ const MEASURE_WIDTH = 400; export type TabsVariant = 'segmented' | 'underline' | 'pill' | 'expanding'; /** * How much of an inactive panel survives a switch away from it. * * `false` unmounts it. `true` keeps it mounted. * * `'measured'` meant "keep it mounted *and* laid out at a real size", which was * a distinction only a tab set of separately hidden panels had to make. In a * swipeable tab set every panel in the strip is laid out at a real size * already, so it is the same as `true` there and is kept only so that passing * it does not break. * * @see TabsProps.keepMounted */ export type TabsKeepMounted = boolean | 'measured'; /** * `'disable-all'` turns off every animation in the tab set — the indicator, the * strip, and an expanding tab's reveal — including the ones its parts run * themselves. */ export type TabsAnimation = 'disable-all'; const tabsVariants = tv({ slots: { list: 'flex-row', indicator: 'absolute left-0', trigger: 'items-center justify-center', label: '', }, variants: { variant: { // A raised chip travelling inside a recessed track. segmented: { list: 'rounded-lg bg-muted p-1', indicator: 'bottom-1 top-1 rounded-md bg-popover shadow-sm', trigger: 'rounded-md py-1.5', }, // No track at all — the indicator is a rule under the active tab, and // the row sits on a hairline so inactive tabs still have a baseline. underline: { list: 'gap-1 border-b border-border', indicator: '-bottom-px h-0.5 rounded-full bg-foreground', trigger: 'py-2.5', }, // Filled chip on the page rather than in a track; the active label // inverts against it. pill: { list: 'gap-1', indicator: 'bottom-0 top-0 rounded-full bg-primary', trigger: 'rounded-full py-2', }, /* * A row of icon pills, one of which is open. * * Every tab draws its own pill here, so there is no indicator to slide * between them — the shape that moves is the open tab itself, widening * to let its label out and closing again behind it. An indicator sliding * under pills that already have backgrounds would be invisible anyway. * * The open pill is a step further from the page than the closed ones: * lighter in a dark theme, darker in a light one, which is what the * tertiary surface token means and why it is used rather than a colour. */ expanding: { list: 'gap-1.5', trigger: 'rounded-full bg-muted px-3.5 py-2.5', }, }, active: { true: { label: 'text-foreground' }, false: { label: 'text-muted-foreground' }, }, disabled: { true: { trigger: 'opacity-[0.44]' }, }, /** Intrinsic width in a scroller, equal shares when the row is fixed. */ scrollable: { true: { trigger: 'px-4' }, false: { trigger: 'flex-1' }, }, }, compoundVariants: [ { variant: 'pill', active: true, class: { label: 'text-primary-foreground' } }, // A pill is as wide as what is inside it. Equal shares would give every // closed tab the width of the open one, which is the layout this variant // exists to avoid. { variant: 'expanding', class: { trigger: 'flex-none' } }, { variant: 'expanding', active: true, class: { trigger: 'bg-surface-tertiary' } }, ], defaultVariants: { variant: 'segmented', scrollable: false, }, }); interface TabLayout { x: number; width: number; } interface TabsContextValue { value: string; setValue: (value: string) => void; registerLayout: (value: string, layout: TabLayout) => void; layouts: Record; variant: TabsVariant; keepMounted: TabsKeepMounted; /** * Whether the panels are in a strip rather than stacked in place. * * A panel in a strip is positioned by the strip and sized by the box it is * put in, so it does no hiding of its own — which is the whole difference * between the two modes as far as `Tabs.Content` is concerned. */ pager: boolean; animationDisabled: boolean; } const TabsContext = createContext(null); /** * Whether the row the trigger is in scrolls, published by that row. * * It is a `Tabs.List` prop and it decides a trigger's width — intrinsic in a * scroller, an equal share in a fixed row — so the two have to agree in the * commit they are laid out in. Routed through the root it arrived one commit * late: every trigger was measured once at its equal-share position, the * indicator snapped to that geometry because it was the first measurement it * had, and the second pass moved everything. With enough tabs to need a * scroller in the first place, the gap between the two geometries is most of * the row. * * Separate from the root's context so it can be provided by the list, and * defaulted so a trigger outside one still resolves. */ const TabsListContext = createContext(false); function useTabs(component: string): TabsContextValue { const context = useContext(TabsContext); if (!context) { throw new Error(`${component} must be used within a `); } return context; } export interface TabsProps extends ViewProps { className?: string; value?: string; onValueChange?: (value: string) => void; defaultValue: string; /** * `segmented` is a chip travelling inside a recessed track, `underline` is a * rule under the active tab, `pill` is a filled chip on the page. * * `expanding` is a row of icon pills where only the selected one is open: * it widens to let its label out and closes again behind it. For a short row * of destinations that are recognisable by their icons, where the labels * would otherwise take the whole width to say things nobody rereads. Give * every trigger an `icon` — a closed tab has nothing else. */ variant?: TabsVariant; /** * Mount every panel up front instead of only the ones that have been * reached, so a scroll position or a half-filled form is there from the * start rather than from the first visit. * * Usually unnecessary. A panel that has been shown once stays mounted for * the life of the tab set either way, and with `swipeable` the panels on * each side of the active one are mounted before you get to them. What this * adds is the panels you have *not* been near — the fourth tab of four — * which costs their render at startup and buys nothing until somebody opens * them. * * Turn it on when a panel has to be live while it is off screen: a form that * must validate as another tab is edited, a chart that has to be ready to * print, a subscription that must not miss a message. */ keepMounted?: TabsKeepMounted; /** * Move between tabs by dragging sideways on the panels, as well as by * pressing the triggers. * * Off by default, because a panel is allowed to contain something that * already wants a horizontal drag — a carousel, a slider, a row that swipes * open — and the two cannot both have it. Turn it on for panels of ordinary * scrolling content, where it is the gesture people try first. * * **It changes how the panels are laid out.** They go side by side in a strip * that is as wide as all of them, and the tab set shows one panel of it at a * time. So the panel on each side of the active one is built and sized before * you swipe to it, which is what stops a heavy panel — a virtualised list, a * chart — from stalling on the frame it becomes visible. * * **It needs a height to fill**, the same as any pager: `flex-1` on the tab * set, or a fixed height. Without one the strip has no room to lay its panels * out in, and a list inside a panel of no height renders no rows. In * development the tab set says so rather than rendering nothing. */ swipeable?: boolean; /** * Turn the tab set's animations off — the indicator, the strip, and an * expanding tab's reveal. * * For a screen that is already animating something more important, and as a * blunt instrument on a device that cannot afford them. The system's own * reduce-motion setting is honoured without this. */ animation?: TabsAnimation; children: ReactNode; } /** * Pulls the panels out of the children, keeping everything else where it was. * * A pager has to lay its panels out together, and they are written wherever * they read best — usually after the list, sometimes inside a fragment from a * `map`. So they are found rather than required to be somewhere: fragments are * flattened through, `Tabs.Content` elements are collected in the order they * appear, and every other child is left exactly where it was written. * * A panel that is *not* reachable this way — wrapped in a component of your own * — is not found, and the tab set falls back to showing one panel at a time. * Silently losing it would be worse than not paging it. */ function collectPanels(children: ReactNode): { rest: ReactNode[]; panels: ReactElement[]; } { const rest: ReactNode[] = []; const panels: ReactElement[] = []; const walk = (nodes: ReactNode) => { Children.forEach(nodes, (child) => { if (!isValidElement(child)) { if (child !== null && child !== undefined && child !== false) rest.push(child); return; } if (child.type === Fragment) { walk((child.props as { children?: ReactNode }).children); return; } if (child.type === TabsContent) { panels.push(child as ReactElement); return; } rest.push(child); }); }; walk(children); return { rest, panels }; } function TabsRoot({ className, value, onValueChange, defaultValue, variant = 'segmented', keepMounted = false, swipeable = false, animation, children, ...props }: TabsProps) { const [internalValue, setInternalValue] = useState(defaultValue); const [layouts, setLayouts] = useState>({}); const isControlled = value !== undefined; const resolvedValue = isControlled ? value : internalValue; const animationDisabled = animation === 'disable-all'; const setValue = useCallback( (next: string) => { if (!isControlled) setInternalValue(next); onValueChange?.(next); }, [isControlled, onValueChange] ); const registerLayout = useCallback((tab: string, layout: TabLayout) => { setLayouts((current) => { const existing = current[tab]; if (existing && existing.x === layout.x && existing.width === layout.width) { return current; } return { ...current, [tab]: layout }; }); }, []); /* * The panels are the sequence. * * They are read straight out of the children, in the order they are written, * which is available on the first render and cannot disagree with what is on * screen. The triggers used to register themselves to build this, which meant * the order arrived a commit late and every mount and unmount of a trigger * re-rendered every panel. */ const { rest, panels } = useMemo(() => collectPanels(children), [children]); const paged = swipeable && panels.length > 0; const context = useMemo( () => ({ value: resolvedValue, setValue, registerLayout, layouts, variant, keepMounted, pager: paged, animationDisabled, }), [ resolvedValue, setValue, registerLayout, layouts, variant, keepMounted, paged, animationDisabled, ] ); return ( {textChildren(paged ? rest : children)} {paged ? ( ) : null} ); } /** * The panels, side by side, behind a window one panel wide. * * Everything about the movement lives on `position`, measured in panels rather * than points: the strip is at `-position × width`, a drag sets it, a press * springs it, and it is the only thing that says where the strip is. Nothing * here waits for React to commit before moving, which is what the old * arrangement did — it mounted the arriving panel and animated it in on the * same frame, so the movement was only as smooth as the mount was quick. */ function TabsPager({ panels, value, setValue, keepMounted, animationDisabled, }: { panels: ReactElement[]; value: string; setValue: (value: string) => void; keepMounted: boolean; animationDisabled: boolean; }) { const sign = useDirectionSign(); const reducedMotion = useReducedMotion(); const still = animationDisabled || reducedMotion; const order = useMemo(() => panels.map((panel) => panel.props.value), [panels]); const count = panels.length; /* * The index of the value, or the last one that resolved. * * A value that is not among the panels is a moment rather than a state: a * controlled parent part-way through an update, panels rebuilt from a `map` * whose keys changed. Falling back to zero for that moment springs the strip * to the first panel and back, which is a visible flicker for something that * was never wrong. Holding the last index shows the panel that was already * there until the new one arrives. * * Zero remains the answer when nothing has ever resolved — a tab set with * nothing in it is a harder thing to debug than one showing the wrong tab. */ const resolved = order.indexOf(value); const lastResolved = useRef(0); if (resolved >= 0) lastResolved.current = resolved; const active = resolved >= 0 ? resolved : Math.min(lastResolved.current, count - 1); const [width, setWidth] = useState(0); const position = useSharedValue(active); const widthValue = useSharedValue(0); const countValue = useSharedValue(count); const start = useSharedValue(active); /** 1 between a drag activating and it being finalised, 0 otherwise. */ const dragging = useSharedValue(0); useEffect(() => { widthValue.value = width; countValue.value = count; }, [width, count, widthValue, countValue]); /* * Which panels have been built, and it only ever grows. * * A panel that has been reached stays mounted for the life of the tab set, * so a tab is slow at most once. That is what `keepMounted` was reached for * and could not deliver, because it decided mounting and hiding together and * the hiding took the panel's size away. */ const [reached, setReached] = useState(() => [active]); if (!reached.includes(active)) { // During render, not in an effect: a press on a far tab has to have its // panel in this commit, or the strip travels to an empty box. setReached((current) => (current.includes(active) ? current : [...current, active])); } useEffect(() => { const wanted = keepMounted ? Array.from({ length: count }, (_, index) => index) : [active - 1, active + 1].filter((index) => index >= 0 && index < count); /* * The neighbours arrive a tick late, on purpose. * * They are what makes a swipe cost nothing — the panel you are swiping * towards is already built — but mounting them in the same commit as the * active one puts three panels' worth of work on the frame the tab set * first appears. A timeout of zero is enough to let that frame out. */ const timer = setTimeout(() => { setReached((current) => { const missing = wanted.filter((index) => !current.includes(index)); return missing.length > 0 ? [...current, ...missing] : current; }); }, 0); return () => clearTimeout(timer); }, [active, count, keepMounted]); /* * Which tab the strip has already been sprung to by a swipe. * * A swipe moves the strip and *then* reports the change, so by the time the * value arrives the movement is under way with the flick's speed in it. * Springing again from the effect below would restart it from rest, which is * the flick visibly losing its throw halfway across. * * The index rather than a flag, so a change that never came back — a * controlled parent that ignored the swipe — cannot swallow the next press. */ const sprungTo = useRef(null); const orderRef = useRef(order); orderRef.current = order; const valueRef = useRef(value); valueRef.current = value; useEffect(() => { const already = sprungTo.current; sprungTo.current = null; if (already === active) return; if (still || width === 0) { position.value = active; return; } position.value = withSpring(active, ENTER_SPRING); }, [active, still, width, position]); const commit = useCallback( (index: number) => { const next = orderRef.current[index]; if (next === undefined || next === valueRef.current) return; sprungTo.current = index; setValue(next); }, [setValue] ); const pan = useMemo( () => Gesture.Pan() // Sideways past the threshold takes the gesture; any real vertical // travel hands it back, so a panel that scrolls still scrolls. .activeOffsetX([-SWIPE_ACTIVATE_X, SWIPE_ACTIVATE_X]) .failOffsetY([-SWIPE_FAIL_Y, SWIPE_FAIL_Y]) .onStart(() => { // On activation rather than on touch-down, so a tap that never // becomes a drag never claims a starting point. Rounded, so a drag // begun while the last one is still settling starts from the tab it // is settling on. dragging.value = 1; start.value = Math.round(position.value); }) .onUpdate((event) => { const span = widthValue.value; if (span === 0) return; const last = countValue.value - 1; const raw = start.value - (event.translationX * sign) / span; // Past either end there is no panel to bring on, so the strip gives // a little and then stops, rather than pulling a blank into view. if (raw < 0) position.value = raw * SWIPE_RESISTANCE_AT_END; else if (raw > last) position.value = last + (raw - last) * SWIPE_RESISTANCE_AT_END; else position.value = raw; }) .onEnd((event) => { const span = widthValue.value; if (span === 0) return; const last = countValue.value - 1; const from = start.value; const moved = position.value - from; // Points per second becomes panels per second, which is the unit the // spring that finishes the movement is working in. const speed = (-event.velocityX * sign) / span; let target = from; // Speed first: distance and speed can disagree, and a flick back the // way it came reads as a cancel however far it had already got. if (Math.abs(event.velocityX) > SWIPE_VELOCITY) { target = from + (speed > 0 ? 1 : -1); } else if (Math.abs(moved) > SWIPE_DISTANCE_RATIO) { target = from + (moved > 0 ? 1 : -1); } if (target < 0) target = 0; if (target > last) target = last; position.value = withSpring(target, { ...ENTER_SPRING, velocity: speed }); if (target !== from) runOnJS(commit)(target); }) .onFinalize((_event, success) => { // A cancelled gesture never reaches `onEnd`, and would otherwise // leave the strip wherever the finger abandoned it. Only for a drag // that actually started: this also runs for every touch that never // became one, and springing to the rounded position there would // interrupt a press's own movement with a tap on the panel. if (dragging.value === 1 && !success) { position.value = withSpring(start.value, ENTER_SPRING); } dragging.value = 0; }), [sign, commit, position, start, dragging, widthValue, countValue] ); /* * The width comes in as the React value, not through `widthValue`. * * `widthValue` is mirrored from state in an effect, so it is a commit behind * — and the commit it is behind by is the one where the strip first appears. * For that frame the transform evaluated to `-position × 0`, which is panel * zero on screen whichever tab is active: the tab set opened on the first * panel and jumped to the right one. Reading `width` here re-creates the * style when it changes, so the first frame of the strip is already in the * right place. `widthValue` is still what the gesture reads, because a * worklet cannot see React state. */ const strip = useAnimatedStyle( () => ({ transform: [{ translateX: -position.value * width * sign }] }), [width, sign] ); const onLayout = useCallback((event: LayoutChangeEvent) => { const measured = event.nativeEvent.layout; if (measured.width > 0) setWidth(measured.width); if (__DEV__ && measured.width > 0 && measured.height === 0) { console.warn( '[PanelUI] has no height to fill, so its panels have nowhere ' + 'to be laid out. Give the tab set a height — `className="flex-1"` on , ' + 'or a fixed height — the same as any pager needs.' ); } }, []); /* * A strip cannot be laid out before the width of one panel is known, so the * first render is the active panel on its own, filling the window. The strip * takes over on the next frame, and every frame after it. */ if (width === 0) { return ( {panels[active]} ); } return ( {panels.map((panel, index) => ( {reached.includes(index) ? panel : null} ))} ); } /** * Fill a height that is offered, and take the content's own when none is. * * `flexBasis: 'auto'` rather than the `0` that `flex: 1` sets, and it is on * every box between the tab set and a panel — viewport, strip, panel — because * one `flex: 1` anywhere in that chain breaks both cases at once. A `flex: 1` * box inside a parent of indefinite height resolves to *nothing*: its basis is * zero and there is no free space to grow into. So the tab set that had not * been given a height would collapse, and take every panel with it — which is * the same zero-height failure that made a kept panel useless to a list, one * level up. * * With `auto` the chain resolves both ways. Given a height, the panels fill it * and a virtualised list inside one has a real size to build against. Given * none, the strip is as tall as its tallest panel and every panel stretches to * match, so switching tabs does not change the tab set's height either. */ const FILL = { flexGrow: 1, flexShrink: 1, flexBasis: 'auto' } as const; /** The window the strip moves behind, one panel wide. */ const PAGER_VIEWPORT = { ...FILL, overflow: 'hidden' } as const; function TabsIndicator() { const { value, layouts, variant, animationDisabled } = useTabs('Tabs.List'); const x = useSharedValue(0); const width = useSharedValue(0); const initialized = useSharedValue(0); const layout = layouts[value]; const { indicator } = tabsVariants({ variant }); // In an effect, not the render body: touching a shared value during render // is a Reanimated strict-mode violation, and the write can be lost or // duplicated when React re-renders. useEffect(() => { if (!layout) return; if (initialized.value === 0 || animationDisabled) { // First measurement snaps into place; there is nothing to animate from. x.value = layout.x; width.value = layout.width; initialized.value = 1; } else { x.value = withSpring(layout.x, SPRING); width.value = withSpring(layout.width, SPRING); } }, [layout?.x, layout?.width, x, width, initialized, animationDisabled, layout]); const style = useAnimatedStyle(() => ({ opacity: initialized.value, transform: [{ translateX: x.value }], width: width.value, })); return ; } export interface TabsListProps extends ViewProps { className?: string; /** * Lay the triggers out at their natural widths inside a horizontal scroller * instead of splitting the row between them. For more tabs than fit — which * a fixed row answers by crushing every label. */ scrollable?: boolean; children: ReactNode; } /** * Where the active tab should sit in the scroller, or null when it cannot be * known yet. * * A little in from the edge rather than flush against it, so the tab does not * read as the last one in the row. */ function scrollTarget(layout: TabLayout | undefined): number | null { if (!layout) return null; return Math.max(layout.x - 24, 0); } function TabsList({ className, scrollable = false, children, ...props }: TabsListProps) { const { variant, value, layouts } = useTabs('Tabs.List'); const { list } = tabsVariants({ variant }); const scroller = useRef(null); /* * Where the row should be, kept as a ref rather than only applied once. * * A horizontal scroller does not always keep its offset when its content is * laid out again, and the row is laid out again on every switch — so the * scroller can be left at zero, showing the first tab, with nothing in this * component's state disagreeing. Re-applying the target from * `onContentSizeChange` puts it back where the selection says it should be. */ const target = scrollTarget(layouts[value]); const targetRef = useRef(target); targetRef.current = target; const settled = useRef(false); // Bring the active tab into view when the selection changes — a press, a // controlled switch, or a swipe on the panel below. useEffect(() => { if (!scrollable || target === null) return; scroller.current?.scrollTo({ x: target, // The first measurement has nowhere to travel from: animating it is a // row that visibly slides into place as the screen appears. animated: settled.current, }); settled.current = true; }, [scrollable, value, target]); const restore = useCallback(() => { if (!scrollable || targetRef.current === null) return; scroller.current?.scrollTo({ x: targetRef.current, animated: false }); }, [scrollable]); const row = ( {/* Nothing to slide: in `expanding` every tab draws its own pill, and the open one is the shape that moves. */} {variant === 'expanding' ? null : } {textChildren(children)} ); /* * The triggers are told whether they are in a scroller here rather than * through the root, so their width and the row they are measured in belong to * one commit. See {@link TabsListContext}. */ const scoped = {row}; if (!scrollable) return scoped; return ( {scoped} ); } export interface TabsTriggerProps { className?: string; value: string; /** * Rendered before the label. Required by `variant="expanding"`, where it is * the only thing a closed tab has left to identify it by. */ icon?: ReactNode; /** Rendered after the label — a count, a dot, a status. */ badge?: ReactNode; /** Unselectable, dimmed, and announced as disabled. */ disabled?: boolean; children: ReactNode; } function TabsTrigger({ className, value, icon, badge, disabled = false, children, }: TabsTriggerProps) { const context = useTabs('Tabs.Trigger'); const scrollable = useContext(TabsListContext); const active = context.value === value; const slots = tabsVariants({ variant: context.variant, active, disabled, scrollable, }); /* * Bound to `registerLayout` alone, which is stable for the life of the tab * set — not to the whole context, which is rebuilt every time any trigger * registers. Depending on the context made this a new function on every * measurement, so a layout burst handed every trigger a new `onLayout` for * every *other* trigger's measurement: quadratic prop updates in exactly the * case that has enough tabs to be slow. */ const { registerLayout } = context; const handleLayout = useCallback( (event: LayoutChangeEvent) => { const { x, width } = event.nativeEvent.layout; registerLayout(value, { x, width }); }, [registerLayout, value] ); if (context.variant === 'expanding') { return ( context.setValue(value)} > {children} ); } return ( context.setValue(value)} onLayout={handleLayout} className={cn(slots.trigger(), (icon || badge) && 'flex-row gap-1.5', className)} > {icon} {textChildren(children, (text) => ( {text} ))} {badge} ); } /** * A tab that is an icon until it is selected, and then an icon and its label. * * The label is never unmounted, only closed over. Two reasons, and both matter: * a screen reader walking a row of unlabelled icons has nothing to read out, * and a label that mounts on selection has no width to animate *from*, so the * pill would jump to its open size and the text would fade in inside it. * * So the width is animated instead, which needs a number — and the number is * the one thing that cannot be measured in place, because the box the label * sits in is the box being resized. A second copy, laid out once in a box wide * enough not to constrain it, reports the width and is never seen. */ function ExpandingTrigger({ active, disabled, icon, badge, still, className, labelClassName, onLayout, onPress, children, }: { active: boolean; disabled: boolean; icon?: ReactNode; badge?: ReactNode; still: boolean; className: string; labelClassName: string; onLayout: (event: LayoutChangeEvent) => void; onPress: () => void; children: ReactNode; }) { const [labelWidth, setLabelWidth] = useState(0); const reducedMotion = useReducedMotion(); const open = useSharedValue(active ? 1 : 0); useEffect(() => { if (reducedMotion || still) { open.value = active ? 1 : 0; return; } /* * A curve, not a spring. * * This animates a *width*, so every frame of it is a layout pass — and the * row is centred, so every other pill moves with it. A spring overshoots * past its target and settles back, which on a box that is clipping text * means the last word slides in, out and in again, and the whole row * wobbles with it. The curve leaves quickly and arrives slowly, and it * arrives once. */ open.value = withTiming(active ? 1 : 0, { duration: EXPAND_DURATION, easing: Easing.bezier(0.2, 0, 0, 1), }); }, [active, reducedMotion, still, open]); const reveal = useAnimatedStyle(() => ({ width: (labelWidth + LABEL_GAP) * open.value, /* * Behind the width, not ahead of it. Text drawn into a box narrower than * itself is text with its end cut off, and doing that on purpose for the * first half of the animation is the difference between a label arriving * and a label being wiped on. */ opacity: interpolate(open.value, [0.35, 0.9], [0, 1], Extrapolation.CLAMP), })); const label = textChildren(children, (text) => ( {text} )); return ( {icon} {/* * Pinned to the measured width so the text does not reflow as the box * around it closes — a label that rewraps on its way out reads as a * glitch rather than as a reveal. * * The gap after the icon is padding on this box, so it has to be added * to the width rather than taken out of it. Set to the measured width * alone, the padding comes off the inside and the label is handed six * points less than it asked for, which a single-line `Text` answers by * truncating: "Inbox" arrives as "Inbo…" and stays that way. */} {label} {/* Measured once, never seen, and out of the layout so it cannot widen the pill it is measuring. */} { /* * Rounded up and given a couple of points over. * * The measurement and the box it is put back into are two different * layout passes, and a fraction of a point between them is enough * for a single-line `Text` to decide it does not fit and truncate — * which shows up as a label permanently missing its last letter. */ const measured = Math.ceil(event.nativeEvent.layout.width) + LABEL_SLACK; if (measured > LABEL_SLACK && measured !== labelWidth) { setLabelWidth(measured); } }} > {label} {badge} ); } export interface TabsContentProps extends ViewProps { className?: string; value: string; children: ReactNode; } function TabsContent({ className, value, children, style, ...props }: TabsContentProps) { const context = useTabs('Tabs.Content'); const active = context.value === value; /* * In a strip, a panel does no hiding and no moving. * * It is positioned by the strip and sized by the box it was put in, so all * that is left to it is to fill that box and to stay out of the screen * reader's way while it is off screen. Everything else this used to do — the * displacement, the fade, the gesture, the two ways of being hidden — was * work to make one panel stand in for a row of them, and the row is real now. */ if (context.pager) { return ( {textChildren(children)} ); } if (!active && !context.keepMounted) return null; /* * Hidden rather than unmounted under `keepMounted`, and hidden thoroughly: * it is not drawn, it takes no touches, and the accessibility props take it * out of the reading order too. A screen reader walking through three panels * of a tab set it cannot see is worse than no tabs at all. * * `display: none` takes it out of layout as well, so a kept panel costs * nothing to have around — and can hold nothing that needs a size while it is * hidden. That is what `swipeable` is for: in a strip every panel has one. */ return ( {textChildren(children)} ); } /** A panel in the strip fills the box the strip put it in. */ const PAGER_PANEL = { ...FILL, width: '100%' } as const; /** …and one that is kept without a strip is mounted, but takes up no room. */ const HIDDEN_PANEL = { display: 'none' } as const; export const Tabs = Object.assign(TabsRoot, { List: TabsList, Trigger: TabsTrigger, Content: TabsContent, });