/**
* SectionRail — a floating navigator for a long screen.
*
* A stack of short bars pinned to one edge, one per section, that expands into
* a labelled panel when you touch it. Collapsed it is a position indicator you
* can read at a glance without giving up any content width; expanded it is a
* list you can jump from.
*
* The bars are deliberately unlabelled. A permanent list of section titles
* down the side of a phone screen is either too small to read or too wide to
* keep — the bars carry only the two things that survive at that size, which
* section you are in and roughly how deep it sits.
*
* ```tsx
*
*
*
*
*
*
* Introduction
* Setup
*
*
* ```
*
* It floats: the root is absolutely positioned and lets touches through
* everywhere it is not drawing, so the content underneath still scrolls.
*/
import {
Children,
createContext,
isValidElement,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react';
import { Pressable, ScrollView, View, type ViewProps } from 'react-native';
import Animated, {
FadeIn,
FadeOut,
interpolateColor,
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
} from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useCSSVariable } from 'uniwind';
import { Portal } from '../../primitives/portal';
import { Text, textChildren } from '../../primitives/text';
import { cn } from '../../utils/cn';
import { selectionTick } from '../../utils/haptics';
const SPRING = { damping: 20, stiffness: 260, mass: 0.6 } as const;
/** Bar width at the top level, and how much each nested level takes off it. */
const BAR_WIDTH = 14;
const BAR_LEVEL_STEP = 3;
/** How much wider the active bar gets, so position is readable at a glance. */
const BAR_ACTIVE_EXTRA = 10;
/**
* How much of that extra the bars either side of the active one keep.
*
* The rail is read by shape, and one long bar in a column of identical short
* ones only says *which* one — it takes a second look to see where that is in
* the run. A step down on each side gives the active bar a slope to sit on, so
* position is legible from the silhouette alone.
*
* Only the immediate neighbours. Two steps of falloff is a taper down the whole
* rail, which reads as a gradient the bars happen to sit in rather than as a
* mark on one of them.
*/
const BAR_NEIGHBOUR = 0.45;
/** Opacity of a bar with nothing near it, and how much proximity adds. */
const BAR_REST_OPACITY = 0.32;
/**
* How long a jump from the panel is given to arrive before the rail starts
* ticking again. Long enough for a scroll across a whole screen, short enough
* that a jump which never lands does not mute the next one.
*/
const JUMP_TIMEOUT = 900;
/**
* How long the rail stays quiet *after* a jump has landed.
*
* A caller may set the value optimistically, before the scroll it triggers has
* started — so arriving at the target is not proof the journey is over. This
* covers the tail of the animation, in which the scroller passes through the
* sections either side of the destination and reports each of them.
*/
const JUMP_SETTLE = 260;
/** Indent per level in the expanded panel. */
const ITEM_INDENT = 10;
/**
* How wide the panel may grow by default. A row spends at least 40pt on
* indent, padding and the panel's own border before any text, so a tighter cap
* than this truncates ordinary section titles on a narrow phone.
*/
const PANEL_MAX_WIDTH = '78%' as const;
/** …and a floor, so a rail with one short section still opens a readable panel. */
const PANEL_MIN_WIDTH = 184;
export type SectionRailPlacement = 'left' | 'right';
export type SectionRailAlign = 'center' | 'top' | 'bottom';
/** Vertical position of the rail, and of the panel that opens from it. */
const ALIGNMENT: Record = {
center: 'justify-center',
top: 'justify-start',
bottom: 'justify-end',
};
interface SectionRailContextValue {
value: string | undefined;
onValueChange?: (value: string) => void;
open: boolean;
setOpen: (open: boolean) => void;
close: () => void;
placement: SectionRailPlacement;
align: SectionRailAlign;
}
const SectionRailContext = createContext(null);
/**
* The bars' values, in the order they are written.
*
* A bar knows whether it is the active one; it cannot know how far it is *from*
* the active one, which is what the falloff either side needs. The trigger
* reads it off its own children — the only place in the tree where the run is
* visible at all — rather than having each bar register itself, since a
* registration order is whatever order the rows happened to mount in and the
* rail is drawn in the order they were written.
*/
const SectionRailBarsContext = createContext([]);
function useSectionRail(component: string): SectionRailContextValue {
const context = useContext(SectionRailContext);
if (!context) {
throw new Error(`${component} must be used within a `);
}
return context;
}
export interface SectionRailProps extends ViewProps {
className?: string;
/** Which edge the rail sits against. */
placement?: SectionRailPlacement;
/**
* Where along that edge it sits. `bottom` puts it in a corner, out of the
* way of the text — the panel then opens upward from the rail rather than
* centred on the screen.
*/
align?: SectionRailAlign;
/**
* Tick under the finger on every change of section, however it was made —
* tapped in the panel, or scrolled past. Needs the optional `expo-haptics`
* package; without it this does nothing.
*/
haptics?: boolean;
/** Active section id. Controlled — usually driven by a scroll handler. */
value?: string;
/** Starting section when uncontrolled. */
defaultValue?: string;
/** Fires when a section is chosen from the expanded panel. */
onValueChange?: (value: string) => void;
/** Controlled expansion. */
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
/**
* How long the panel stays up after a choice, so a mis-tap can be corrected
* without opening it again. Set 0 to close immediately.
*/
closeDelay?: number;
/** Gap between the rail and the edge of the safe area. */
offset?: number;
children: ReactNode;
}
function SectionRailRoot({
className,
placement = 'right',
align = 'center',
haptics = false,
value: valueProp,
defaultValue,
onValueChange,
open: openProp,
defaultOpen = false,
onOpenChange,
closeDelay = 300,
offset = 12,
children,
...props
}: SectionRailProps) {
const [internalValue, setInternalValue] = useState(defaultValue);
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const insets = useSafeAreaInsets();
const timer = useRef | null>(null);
const jumpTimer = useRef | null>(null);
const isControlled = valueProp !== undefined;
const value = isControlled ? valueProp : internalValue;
const isOpenControlled = openProp !== undefined;
const open = isOpenControlled ? openProp : internalOpen;
const setOpen = useCallback(
(next: boolean) => {
if (timer.current) {
clearTimeout(timer.current);
timer.current = null;
}
if (!isOpenControlled) setInternalOpen(next);
onOpenChange?.(next);
},
[isOpenControlled, onOpenChange]
);
const close = useCallback(() => setOpen(false), [setOpen]);
// A pending close, or a jump still being waited on, must not fire after the
// rail has gone.
useEffect(
() => () => {
if (timer.current) clearTimeout(timer.current);
if (jumpTimer.current) clearTimeout(jumpTimer.current);
},
[]
);
/*
* The section a tap asked for, while the screen is still travelling to it.
*
* A jump is animated, so the scroll handler driving `value` reports every
* section the screen passes on the way — each of which is a change of section
* as far as the tick below can tell. One tap became two or three ticks, and
* the panel lit up a row nobody chose. Nothing between the tap and the
* arrival is a section the reader went to, so nothing between them ticks.
*/
const jumpTo = useRef<{ target: string; landed: boolean } | null>(null);
const endJump = useCallback(() => {
jumpTo.current = null;
if (jumpTimer.current) {
clearTimeout(jumpTimer.current);
jumpTimer.current = null;
}
}, []);
const handleValueChange = useCallback(
(next: string) => {
jumpTo.current = { target: next, landed: false };
if (jumpTimer.current) clearTimeout(jumpTimer.current);
/*
* A backstop, not the normal way out. A jump to a section the scroller
* cannot reach — the last one on a screen shorter than the viewport —
* never arrives, and without this the rail would stay silent for good.
*/
jumpTimer.current = setTimeout(endJump, JUMP_TIMEOUT);
if (!isControlled) setInternalValue(next);
onValueChange?.(next);
if (closeDelay <= 0) {
setOpen(false);
return;
}
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => setOpen(false), closeDelay);
},
[isControlled, onValueChange, closeDelay, setOpen, endJump]
);
/*
* Fired from the resolved value rather than from the change handler, so a
* section arrived at by scrolling ticks as well as one that was tapped —
* "every change" means every change. The ref skips the first run, since
* mounting is not a change of section.
*/
const ticked = useRef(false);
useEffect(() => {
if (!ticked.current) {
ticked.current = true;
return;
}
const jump = jumpTo.current;
if (jump !== null) {
// Still on the way. Nothing the screen passes through is a section the
// reader went to, so nothing between the tap and the arrival ticks.
if (value !== jump.target) return;
// Arrived — worth feeling, once. The window stays armed a moment longer
// rather than closing here, because a caller that sets the value
// optimistically hands it to us *before* the scroll starts: closing on
// the first match would disarm the rail for the whole animation, which
// is the entire journey it was there to cover. Every section the screen
// then passed lit a row and fired a tick of its own, several of them
// inside a few hundred milliseconds.
if (jump.landed) return;
jump.landed = true;
if (jumpTimer.current) clearTimeout(jumpTimer.current);
jumpTimer.current = setTimeout(endJump, JUMP_SETTLE);
}
if (haptics) selectionTick();
}, [value, haptics, endJump]);
const context = useMemo(
() => ({
value,
onValueChange: handleValueChange,
open,
setOpen,
close,
placement,
align,
}),
[value, handleValueChange, open, setOpen, close, placement, align]
);
return (
{textChildren(children)}
);
}
export interface SectionRailTriggerProps extends ViewProps {
className?: string;
children: ReactNode;
}
/**
* The collapsed rail. Wraps the bars and opens the panel on press — one target
* over the whole stack rather than one per bar, because a 3px bar is not
* something anyone can hit.
*/
function SectionRailTrigger({ className, children, ...props }: SectionRailTriggerProps) {
const { open, setOpen, placement } = useSectionRail('SectionRail.Trigger');
/*
* The run of bars, read off the children. Only the values are taken, so a bar
* wrapped in anything of the caller's is simply not found and falls back to
* drawing itself from `selected` alone.
*/
const values = useMemo(() => {
const found: string[] = [];
Children.forEach(children, (child) => {
if (isValidElement(child) && child.type === SectionRailBar) {
const value = (child.props as SectionRailBarProps).value;
if (typeof value === 'string') found.push(value);
}
});
return found;
}, [children]);
return (
setOpen(!open)}
// Generous padding is the hit target; the bars themselves are hairlines.
hitSlop={10}
className={cn(
'gap-1.5 py-2.5',
placement === 'right' ? 'items-end ps-5 pe-2' : 'items-start ps-2 pe-5',
className
)}
{...props}
>
{textChildren(children)}
);
}
export interface SectionRailBarProps {
className?: string;
/** Section this bar stands for. Matches the root's `value`. */
value: string;
/** Nesting depth. Deeper levels draw a shorter bar. */
level?: number;
}
/**
* One section, drawn as a bar.
*
* Three lengths rather than two: the active bar is longest and brightest, the
* bars either side of it keep a share of that, and everything further away sits
* at the resting length. What the reader gets from the extra step is *where* in
* the run they are without counting bars — the slope points at the middle of it.
*/
function SectionRailBar({ className, value, level = 0 }: SectionRailBarProps) {
const { value: active } = useSectionRail('SectionRail.Bar');
const values = useContext(SectionRailBarsContext);
const selected = active === value;
const restColor = useCSSVariable('--color-muted-foreground');
const activeColor = useCSSVariable('--color-foreground');
/*
* How near this bar is to the one that is active: 1 for the active bar itself,
* a share of it for its neighbours, 0 for the rest. A bar the trigger did not
* find — one the caller wrapped in something of their own — has no position to
* measure from, so it falls back to the plain selected-or-not it always had.
*/
const index = values.indexOf(value);
const activeIndex = active === undefined ? -1 : values.indexOf(active);
const proximity =
index < 0 || activeIndex < 0
? selected
? 1
: 0
: Math.abs(index - activeIndex) === 0
? 1
: Math.abs(index - activeIndex) === 1
? BAR_NEIGHBOUR
: 0;
const base = Math.max(BAR_WIDTH - level * BAR_LEVEL_STEP, 6);
const progress = useSharedValue(proximity);
useEffect(() => {
progress.value = withSpring(proximity, SPRING);
}, [proximity, progress]);
const idle = typeof restColor === 'string' ? restColor : '#818181';
const on = typeof activeColor === 'string' ? activeColor : '#f5f5f5';
const style = useAnimatedStyle(() => ({
width: base + progress.value * BAR_ACTIVE_EXTRA,
// The far bars are dim on purpose — the rail is a position indicator, so
// only the part of it the reader is in is meant to be read.
opacity: BAR_REST_OPACITY + progress.value * (1 - BAR_REST_OPACITY),
backgroundColor: interpolateColor(progress.value, [0, 1], [idle, on]),
}));
return (
);
}
export interface SectionRailContentProps extends ViewProps {
className?: string;
/**
* How wide the panel may grow, as a fraction of the screen or a point width.
* The default leaves room for the rail and the edge it is anchored to; raise
* it for a screen whose section titles are long enough to be worth wrapping
* rather than truncating.
*/
maxWidth?: number | `${number}%`;
children: ReactNode;
}
/**
* The expanded panel. Mounted through a portal so it floats over everything,
* and unmounted after it fades out rather than left behind hidden.
*/
function SectionRailContent({
className,
maxWidth = PANEL_MAX_WIDTH,
children,
...props
}: SectionRailContentProps) {
const context = useSectionRail('SectionRail.Content');
const { open, close, placement, align } = context;
const insets = useSafeAreaInsets();
if (!open) return null;
return (
{/* A press anywhere else puts it away. */}
{children}
);
}
/**
* Split out from Content so the slide-in animated style is not on the same
* view as the entering/exiting fade — Reanimated will let a layout animation
* overwrite an animated style that touches the same property.
*/
function SectionRailPanel({
className,
maxWidth = PANEL_MAX_WIDTH,
children,
...props
}: SectionRailContentProps) {
const { placement } = useSectionRail('SectionRail.Content');
const slide = useSharedValue(0);
useEffect(() => {
slide.value = withTiming(1, { duration: 180 });
}, [slide]);
// Arrives from the edge it is anchored to, so it reads as the rail
// unfolding rather than as a panel appearing over it.
const style = useAnimatedStyle(() => ({
transform: [
{ translateX: (1 - slide.value) * (placement === 'right' ? 16 : -16) },
],
}));
return (
{textChildren(children)}
);
}
export interface SectionRailItemProps {
className?: string;
/** Section this row jumps to. Matches the root's `value`. */
value: string;
/** Nesting depth. Indents the row to match its bar. */
level?: number;
children: ReactNode;
}
/** A labelled row in the expanded panel. */
function SectionRailItem({ className, value, level = 0, children }: SectionRailItemProps) {
const { value: active, onValueChange } = useSectionRail('SectionRail.Item');
const selected = active === value;
return (
onValueChange?.(value)}
// `paddingStart`, not `paddingLeft`: the indent has to fall on the same
// side as the bar it belongs to, which is the trailing edge under RTL.
style={{ paddingStart: 10 + level * ITEM_INDENT }}
className={cn(
'rounded-lg py-1.5 pe-2 active:bg-accent',
selected && 'bg-accent',
className
)}
>
{children}
);
}
export const SectionRail = Object.assign(SectionRailRoot, {
Trigger: SectionRailTrigger,
Bar: SectionRailBar,
Content: SectionRailContent,
Item: SectionRailItem,
});