/** * Panelside — a navigation panel that moves the app aside instead of covering * it. * * ```tsx * * * * * * * * Recents * * * * * * * * * * * * * * ``` * * ## Why it is not a Drawer * * A drawer is an overlay: it mounts through a portal, lands above everything * and dims what it hid. That is the wrong shape here, because the whole point * of this pattern is that the app *stays legible* — it slides across, shrinks, * rounds its corners and waits, so the panel reads as a layer behind the app * rather than a sheet on top of it. A portal cannot do that: its content is * above the app content by construction, and the app content is somewhere else * in the tree entirely, unreachable. * * So Panelside owns both halves. It renders inline, keeps the panel and the * app screen as siblings under one clipping container, and gives them a single * `progress` value to move against. `Panelside.Scene` is the wrapper you put * around your own screen; without it there is nothing to push, which is why it * is explicit rather than inferred. * * ## The scene maths * * React Native scales a view about its centre, so a scene scaled to `s` has * already pulled its left edge `W * (1 - s) / 2` inward before any translation * is applied. Translating by the panel width alone would therefore leave a gap * that grows with the scale, and the panel would look mis-measured. Subtracting * that inset is what puts the scene's *visible* edge exactly where the panel * ends: * * scale = 1 - (1 - s) * p * translateX = p * (width + gap) - W * (1 - scale) / 2 * * Both are driven from one shared value, so a half-finished drag is a real * halfway state rather than an interpolation between two snapshots. * * ## One gesture, both directions * * A single pan opens and closes. By default it listens across the whole * surface, because that is the behaviour this pattern is known for: a sideways * drag anywhere on the app brings the panel in, from wherever your thumb * already was. What keeps a list usable underneath it is the pair of * thresholds — the drag gives itself up on twelve points of vertical travel * and only claims the touch at fourteen horizontal, so anything even slightly * vertical resolves as a scroll. * * `swipeFrom="edge"` narrows the closed-state hit area to a strip at the * leading screen edge instead. That is for a scene with its own use for a * horizontal drag — a carousel, a wide table, a pannable chart — which would * otherwise fight the panel and lose. * * Reanimated's default `ReduceMotion.System` applies throughout: with the * accessibility setting on, every spring here resolves instantly to its target * rather than travelling. */ import { Children, cloneElement, createContext, isValidElement, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactElement, type ReactNode, } from 'react'; import { Keyboard, Platform, Pressable, ScrollView, StyleSheet, TextInput, useWindowDimensions, View, type LayoutChangeEvent, type PressableProps, type ScrollViewProps, type TextInputProps, type ViewProps, } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { runOnJS, useAnimatedProps, useAnimatedStyle, useDerivedValue, useSharedValue, withSpring, type SharedValue, } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { tv } from 'tailwind-variants'; import { useCSSVariable } from 'uniwind'; import { LinearGradient } from 'expo-linear-gradient'; import { HugeiconsIcon, type IconSvgElement } from '@hugeicons/react-native'; import Cancel01Icon from '@hugeicons/core-free-icons/Cancel01Icon'; import Menu01Icon from '@hugeicons/core-free-icons/Menu01Icon'; import MoreHorizontalIcon from '@hugeicons/core-free-icons/MoreHorizontalIcon'; import Search01Icon from '@hugeicons/core-free-icons/Search01Icon'; import { IconColorProvider, useIconColor } from '../../icons'; import { BottomSheet, bottomSheetDetentHeight, type BottomSheetBodyProps, type BottomSheetProps, } from '../bottom-sheet'; import { Button } from '../button'; import { Menu, type MenuContentProps } from '../menu'; import { Tabs } from '../tabs'; import { getNativeUI } from '../../native'; import { AnimatedPressable } from '../../primitives/animated-pressable'; import { KeyboardAvoider } from '../../primitives/keyboard-avoider'; import { Text, textChildren, type TextProps } from '../../primitives/text'; import { useBackHandler } from '../../hooks/use-back-handler'; import { useDirectionSign } from '../../hooks/use-direction'; import { cn } from '../../utils/cn'; import { selectionTick } from '../../utils/haptics'; const SPRING = { damping: 24, stiffness: 300, mass: 0.7 } as const; /** * How wide the leading-edge strip that starts a swipe is, in points. * * Wider than the system's own edge gestures, because this one has to be found * without a bezel to feel for: a thumb reaching for the side of a phone lands * anywhere in the first 40-odd points, and a strip narrower than that reads as * a gesture that does not work rather than one that was missed. */ const EDGE_WIDTH = 48; /** A drag has to clear this before releasing it changes the open state. */ const COMMIT_DISTANCE = 60; /** A flick this fast commits regardless of how far it got. */ const COMMIT_VELOCITY = 500; /** * How much travel claims the drag, and how much gives it up — different on * each axis, and different again depending on where the swipe may start. * * From the edge strip the horizontal claim is small and the vertical give-up * generous: the target is narrow, so the gesture has to win early, and nobody * swipes in a straight line from the side of a phone. * * From anywhere the numbers invert, because now the whole screen is competing. * Giving up sooner than it claims is what makes a lazy diagonal resolve as a * scroll rather than as the panel: a list keeps every drag that is even * slightly vertical, and only a deliberate sideways one opens the panel. */ const OFFSETS = { edge: { activate: 6, fail: 16 }, anywhere: { activate: 14, fail: 12 }, } as const; /** Below this the drag is a tap that wobbled, and velocity is not consulted. */ const MIN_OFFSET = 5; /** Gap left between the panel's edge and the pushed scene. */ const GAP = 12; /** * How small the scene gets at full travel. One, by default: it does not shrink. * * Scaling is the obvious way to make the pushed screen read as a card, and it * is the wrong one. A scale is applied about the centre, so it insets the * scene at the top and the bottom as well as the side — the screen lifts away * from the status bar and the home indicator, and the two strips of panel that * appear above and below it are strips of nothing. What the apps this pattern * comes from do instead is keep the screen full height, running behind the * status bar exactly as it did before, and let the corner radius and the dim * carry the whole effect. Only the *content* respects the safe area, which it * was already doing. * * Set it below one for the shrinking version; nothing else has to change. */ const SCENE_SCALE = 1; /** The corner radius the scene picks up at full travel. */ const SCENE_RADIUS = 44; /** How far the scene is dimmed at full travel. */ const SCENE_DIM = 0.45; /** * How strongly the line along the scene's edge reads at full travel. * * One, because the token it is drawn in already carries its own alpha — 6% * white in a dark theme, 8% black in a light one. Holding it back further would * be dimming a colour that is already almost entirely transparent. */ const EDGE_OPACITY = 1; /** * How thick that line is. * * A point rather than `StyleSheet.hairlineWidth`. A hairline is one physical * pixel, which is right for a divider on a flat surface and too little on a * corner this round — most of the line is curve, and a third of a point of * curve antialiases away to nothing. */ const EDGE_WIDTH_PT = 1; /** * How far behind the scene the panel starts. * * The panel is never actually off-screen in push mode — it is simply covered. * Moving it a little anyway is what stops the reveal reading as a photograph * sliding off a poster: the two layers travel at different rates, so the panel * settles into place rather than having been there all along. */ const PARALLAX = 0.18; /** * Fraction of the container the panel takes, and the cap it never passes. * * Wide on purpose. The sliver of app left showing is not a preview of it — it * is a handle and a reminder, and the moment it is wide enough to read as a * column the screen turns into a two-pane layout that neither pane fits. The * cap keeps that true on a tablet, where the fraction alone would produce a * navigation list with a field of whitespace beside it. */ const WIDTH_FRACTION = 0.8; const WIDTH_MAX = 360; /** * The same, for a docked panel — and nothing like it, because the job changed. * * An overlay panel can take most of the width, since the app is behind it and * gets it all back on close. A docked panel keeps what it takes: every point * of it is a point the app does not have, and 80% of the container leaves a * column too narrow to put anything in. A third, capped, is a sidebar. */ const DOCK_WIDTH_FRACTION = 0.32; const DOCK_WIDTH_MAX = 320; /** How far above the floating footer the list starts dissolving into it. */ const FOOTER_FADE = 28; /** * What `BottomSheet.Content` leaves below its last child, maxed against the * home indicator. Mirrored here so the search field can subtract the strip it * already sits above before it travels with the keyboard. */ const SHEET_BOTTOM_PADDING = 16; /** * The top padding the search surface asks `BottomSheet.Content` for, and takes * back off the column's height. Smaller than the sheet's own default, because * this surface leads with a round button rather than with a title. */ const SHEET_TOP_PADDING = 12; /** Progress past which a layer is treated as fully hidden for accessibility. */ const HIDDEN_EPSILON = 0.05; const clamp = (value: number, min: number, max: number) => { 'worklet'; return Math.min(Math.max(value, min), max); }; /** * The stroke weight the panel's glyphs are drawn at. * * A shade heavier than the icon set's own default. The panel sits over a * dimmed screen and its rows are quiet by design, so a hairline glyph goes * soft against them at the sizes used here. */ const GLYPH_STROKE = 1.8; /** * What a glyph is drawn in when nothing has said. The same neutral the rest of * the chrome falls back to — visible against either theme, and never the thing * that decides how a panel looks, since every surface here provides a colour. */ const GLYPH_FALLBACK = '#737373'; /** * Floor for a row's action menu. * * Wide enough for a verb and its glyph with the label doing the growing. Left * to size itself, a menu takes its width from the one thing in each row that * is not flexible — the glyph — and comes up as a column of icons. */ const ACTIONS_MIN_WIDTH = 220; /** * One of the panel's own glyphs. * * It exists to keep the colour contract the rest of the library has: an icon * takes an explicit colour, then the one an enclosing surface is providing, * and only then falls back. The drawing component underneath knows nothing * about that inheritance and needs the resolved value handed to it. */ function Glyph({ icon, size = 20, color, }: { icon: IconSvgElement; size?: number; color?: string; }) { const inherited = useIconColor(); return ( ); } export type PanelsideMode = 'push' | 'overlay'; export type PanelsideSwipeFrom = 'anywhere' | 'edge'; export type PanelsideItemSize = 'default' | 'sm'; export type PanelsideCtaSize = 'default' | 'lg' | 'xl'; /** * What a header or a footer paints behind itself. * * `transparent` paints nothing, and the list runs the full height of the panel * underneath it. `fade` dissolves the list into the panel background over the * strip above the controls. `solid` is a band with an edge on it, for a footer * that is a row of the layout rather than something floating over one. */ export type PanelsideSurface = 'transparent' | 'fade' | 'solid'; /** How the panel's small round controls are drawn: a fill, or a ring. */ export type PanelsideControlVariant = 'filled' | 'outline'; const itemVariants = tv({ // No width: in a group it stretches on its own, and pinning it to full width // would stop it sharing a footer row with anything else. `shrink` because // React Native defaults `flexShrink` to 0 — in a footer beside a button, on a // panel narrow enough for the two not to fit, nothing would give way and // both would simply hang off the edge. base: 'shrink flex-row items-center rounded-xl', variants: { size: { default: 'gap-3 px-3 py-2.5', sm: 'gap-2.5 px-2.5 py-2', }, active: { true: 'bg-secondary' }, disabled: { true: 'opacity-40' }, }, defaultVariants: { size: 'default', }, }); const ctaVariants = tv({ // Taller and wider than a list row's control. It is the one thing in the // panel you are meant to reach for without reading, so it should not be the // same size as the eight chat titles above it. // // It used to be 40pt, chosen to sit level with the account button beside it. // That is the wrong thing to size it against: the account button is a target // you find once, and the compose pill is the one pressed every session — at // matching heights the two read as a pair of equals and the pill stopped // being the thing the footer is for. Four points is enough to separate them // without making the footer taller than the panel needs. base: 'shrink flex-row items-center justify-center rounded-full', variants: { variant: { primary: 'bg-primary', secondary: 'bg-secondary', }, size: { default: 'h-11 gap-2 px-6', lg: 'h-13 gap-2.5 px-7', xl: 'h-14 gap-3 px-8', }, }, defaultVariants: { variant: 'primary', size: 'default', }, }); interface PanelsideContextValue { open: boolean; setOpen: (open: boolean) => void; toggle: () => void; /** 0 closed, 1 open. The one value every layer animates against. */ progress: SharedValue; /** Panel width in points. */ width: number; mode: PanelsideMode; /** True when the panel is laid out beside the scene rather than behind it. */ docked: boolean; dismissible: boolean; /** Scene defaults set on the root. A `Panelside.Scene` prop still wins. */ scale?: number; radius?: number; dim?: number; /** * The page the scene is showing. * * On the root because the rows that navigate are in the panel and the pages * are in the scene, which are different subtrees. Anything else is one * `useState` every app threads through both. */ route: string; navigate: (route: string) => void; } const PanelsideContext = createContext(null); function usePanelsideContext(component: string): PanelsideContextValue { const context = useContext(PanelsideContext); if (!context) { throw new Error(`${component} must be used within a `); } return context; } export interface UsePanelsideResult { open: boolean; setOpen: (open: boolean) => void; toggle: () => void; /** * How far the panel has travelled, 0 to 1, on the UI thread. Read it to move * something of your own with the panel — a header that fades, a title that * slides — without a re-render per frame. */ progress: SharedValue; /** True while the panel is docked open beside the scene. */ docked: boolean; /** The page the scene is showing — a `Panelside.Page`'s `value`. */ route: string; /** Go to a page, closing the panel on the way. */ navigate: (route: string) => void; } /** * The panel's state, from anywhere inside a `` — including your own * screen inside `Panelside.Scene`, which is where a custom open button usually * lives. */ export function usePanelside(): UsePanelsideResult { const { open, setOpen, toggle, progress, docked, route, navigate } = usePanelsideContext('usePanelside'); // `navigate` closes the panel. The root cannot do it there without making // its own `setOpen` a dependency of the callback every row holds. const go = useCallback( (next: string) => { navigate(next); setOpen(false); }, [navigate, setOpen] ); return { open, setOpen, toggle, progress, docked, route, navigate: go }; } /** * What the panel's own parts share: the floating footer's height, so the * scroller can leave room for it. The footer overlays the list rather than * taking a row of its own, which means nothing else can know how tall it is * until it has laid itself out. */ interface PanelsideSurfaceValue { footerHeight: number; setFooterHeight: (height: number) => void; } const PanelsideSurfaceContext = createContext(null); export interface PanelsideProps { children: ReactNode; /** Open state, when you want to own it. Pair with `onOpenChange`. */ open?: boolean; /** Called with the next open state, whether a gesture or you caused it. */ onOpenChange?: (open: boolean) => void; /** Open state to start at when you are not controlling it. */ defaultOpen?: boolean; /** * How the two layers relate. `push` moves the scene aside and curves it, * which is the point of this component. `overlay` slides the panel over a * scene that stays put — the same navigation, for a screen whose content * cannot afford to move. */ mode?: PanelsideMode; /** * Panel width in points. Defaults to 80% of the container capped at 360, * and to a third of it capped at 320 once docked — an overlay panel gives * the width back when it closes and a docked one keeps it, so they are not * the same measurement. The caps are what stop a tablet getting a navigation * list with a field of whitespace beside it. */ width?: number; /** * Container width at or above which the panel stops being an overlay and * becomes a permanent sidebar: laid out beside the scene, always open, with * the gesture and the trigger switched off. A docked panel also narrows to a * third of the container, capped at 320 — docked, every point it takes is a * point the app does not get back. * * Off by default, and deliberately not a guess — a large phone in landscape * is wider than a small tablet in portrait, so no single number is right for * every app. Set it high enough that what is left over is still a screen: * around 700 is the first width where both halves have room. */ dock?: number | false; /** Swipe to open, and drag the scene to close. Default true. */ swipeEnabled?: boolean; /** * Where a swipe may begin. `anywhere` is the default and the behaviour this * pattern is known for — a sideways drag across the app opens the panel from * wherever your thumb already was. * * `edge` narrows it to a strip at the leading screen edge, for a scene that * has its own use for a horizontal drag: a carousel, a wide table, a chart * you can pan. Anything like that under an `anywhere` panel will fight it, * and the panel usually wins. */ swipeFrom?: PanelsideSwipeFrom; /** * How wide the leading-edge strip that starts a swipe is, when `swipeFrom` * is `edge`. Default 48 — wider than the system's own edge gestures, because * there is no bezel to feel for. Ignored otherwise. */ edgeWidth?: number; /** * Tapping the pushed scene, or the Android back button, closes the panel. * Default true. */ dismissible?: boolean; /** * A tick under the finger when a swipe commits to opening or closing. Off by * default — needs the optional `expo-haptics`, and is silent without it. * * It fires on the commit rather than during the drag: the panel following * your thumb is already the feedback for the drag, and a tick per frame is * what makes a gesture feel broken rather than responsive. */ haptics?: boolean; /** * How far the scene shrinks at full open, as a scale factor. Sets the * default for every `Panelside.Scene` underneath; the scene's own prop still * wins. Here so the three numbers that describe the curve can be set once * where the panel is configured, rather than on a part further down. */ scale?: number; /** The corner radius the scene reaches at full open, in points. */ radius?: number; /** How far the scene is dimmed at full open, 0 to 1. */ dim?: number; /** * Which page the scene is showing. Controlled; pair it with `onRouteChange`. * * A route is any string you choose. It is matched against * `Panelside.Page`'s `value` and against `Panelside.Item`'s `to`, so a row * marks itself as the current destination and the scene swaps to the page * without either being wired to the other. */ route?: string; /** Which page the scene starts on, when the panel is not controlling `route`. */ defaultRoute?: string; /** Called with the route a row navigated to. */ onRouteChange?: (route: string) => void; className?: string; } function PanelsideRoot({ children, open: controlledOpen, onOpenChange, defaultOpen = false, mode = 'push', width: widthProp, dock = false, swipeEnabled = true, swipeFrom = 'anywhere', edgeWidth = EDGE_WIDTH, dismissible = true, haptics = false, scale, radius, dim, route: controlledRoute, defaultRoute = '', onRouteChange, className, }: PanelsideProps) { const { width: windowWidth } = useWindowDimensions(); const sign = useDirectionSign(); const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen); const controlled = controlledOpen !== undefined; const open = controlled ? controlledOpen : uncontrolledOpen; const [uncontrolledRoute, setUncontrolledRoute] = useState(defaultRoute); const route = controlledRoute ?? uncontrolledRoute; /* * Navigating closes the panel, and does so here rather than at each call * site. The thing you just moved to would otherwise be behind the thing you * moved from, which is the one arrangement no app wants — and a row that has * to be told to close is a row every app writes the same three lines for. */ const navigate = useCallback( (next: string) => { if (controlledRoute === undefined) setUncontrolledRoute(next); onRouteChange?.(next); }, [controlledRoute, onRouteChange] ); /* * Measured rather than taken from the window, because Panelside does not * have to be the whole screen — it can be one tab of a larger layout, and * the push distance is a fraction of whatever it actually got. The window * width is only the value to use until the first layout arrives. */ const [containerWidth, setContainerWidth] = useState(windowWidth); const onLayout = useCallback((event: LayoutChangeEvent) => { setContainerWidth(event.nativeEvent.layout.width); }, []); const docked = dock !== false && containerWidth >= dock; const width = widthProp ?? (docked ? Math.min(containerWidth * DOCK_WIDTH_FRACTION, DOCK_WIDTH_MAX) : Math.min(containerWidth * WIDTH_FRACTION, WIDTH_MAX)); /** * How far the drag runs. In push mode the scene clears the gap too. * * Floored at 1 because it is a divisor: a container measured at zero — one * frame during a collapsed layout is enough — would otherwise make progress * infinite and park both layers somewhere off the screen for good. */ const extent = Math.max(1, mode === 'push' ? width + GAP : width); const translation = useSharedValue(open ? extent : 0); const progress = useDerivedValue(() => translation.value / extent, [extent]); /* * Which end state a spring is already heading for, so the effect below does * not restart an animation the gesture just launched with velocity. Without * it, committing a flick re-springs from mid-flight at zero velocity, which * is visible as a stutter right where the motion should feel fastest. */ const animatingTo = useSharedValue<'open' | 'close' | null>(null); const settle = useCallback( (next: boolean, velocity?: number) => { 'worklet'; const target = next ? extent : 0; if (translation.value === target) return; if (animatingTo.value === (next ? 'open' : 'close')) return; /* * A velocity pointing away from the target would fight the spring, and * the spring wins — so the only thing it contributes is a hitch at the * start. Drop it and let the spring do the whole trip. */ const aligned = velocity !== undefined && ((target > translation.value && velocity > 0) || (target < translation.value && velocity < 0)); animatingTo.value = next ? 'open' : 'close'; translation.value = withSpring( target, { ...SPRING, velocity: aligned ? velocity : 0 }, () => { animatingTo.value = null; } ); }, [animatingTo, extent, translation] ); const setOpen = useCallback( (next: boolean) => { if (!controlled) setUncontrolledOpen(next); onOpenChange?.(next); }, [controlled, onOpenChange] ); const toggle = useCallback(() => setOpen(!open), [open, setOpen]); const close = useCallback(() => setOpen(false), [setOpen]); /* * Wrapped so the gesture's worklet has one stable function to hand to * `runOnJS` — a fresh closure each render would be re-serialised on every * frame the pan is rebuilt. */ const tick = useCallback(() => { if (haptics) selectionTick(); }, [haptics]); useBackHandler(open && dismissible && !docked, close); /* * A docked panel is open by definition, and it must not animate there — on * a rotation into a docked layout the panel is already beside the scene, so * a spring would slide furniture that never moved. */ useEffect(() => { if (docked) { translation.value = extent; return; } settle(open); }, [docked, extent, open, settle, translation]); const offsets = OFFSETS[swipeFrom]; const pan = useMemo(() => { let gesture = Gesture.Pan() .enabled(swipeEnabled && !docked) .activeOffsetX([-offsets.activate, offsets.activate]) // Real vertical intent hands the touch back, so a list inside the panel // and a scroller inside the app both keep their own drags. .failOffsetY([-offsets.fail, offsets.fail]) .onChange((event) => { translation.value = clamp(translation.value + event.changeX * sign, 0, extent); }) .onEnd((event) => { const distance = event.translationX * sign; const velocity = event.velocityX * sign; const decisive = (Math.abs(distance) > MIN_OFFSET && Math.abs(velocity) > COMMIT_VELOCITY) || Math.abs(distance) > COMMIT_DISTANCE; // Below the threshold the drag was not an instruction: go back to // wherever the panel already was. const next = decisive ? (velocity === 0 ? distance : velocity) > 0 : open; // `velocity` is already in travel space; the spring runs on the same // axis, so it must not be converted back to screen space here. settle(next, velocity); // Only where the drag changed something. A swipe that fell short and // sprang back did not open or close anything, and a tick that says it // did is worse than no tick at all. if (next !== open) runOnJS(tick)(); runOnJS(setOpen)(next); }); /* * The strip is only ever a closed-state restriction. Open, it is dropped * whatever `swipeFrom` says: the panel is already out, so there is no app * underneath left to compete for the same horizontal swipe. */ if (!open && swipeFrom === 'edge') { gesture = gesture.hitSlop( sign === 1 ? { left: 0, width: edgeWidth } : { right: 0, width: edgeWidth } ); } return gesture; }, [ docked, edgeWidth, extent, offsets, open, setOpen, settle, sign, swipeEnabled, swipeFrom, tick, translation, ]); const context = useMemo( () => ({ open, setOpen, toggle, progress, width, mode, docked, dismissible, scale, radius, dim, route, navigate, }), [ dim, dismissible, docked, mode, navigate, open, progress, radius, route, scale, setOpen, toggle, width, ] ); return ( {children} ); } export interface PanelsidePanelProps extends ViewProps { className?: string; children?: ReactNode; } function PanelsidePanel({ className, children, style, ...props }: PanelsidePanelProps) { const { progress, width, mode, docked } = usePanelsideContext('Panelside.Panel'); const [footerHeight, setFooterHeight] = useState(0); const animatedStyle = useAnimatedStyle(() => { const p = docked ? 1 : progress.value; const distance = mode === 'overlay' ? width : width * PARALLAX; return { transform: [{ translateX: -(1 - p) * distance }] }; }, [docked, mode, width]); /* * Out of the accessibility tree until it is nearly all the way in. A panel * at rest is behind the app, fully covered and not a thing you can reach — * but nothing about being covered says that to a screen reader, which will * happily read out a navigation list nobody can see. */ const animatedProps = useAnimatedProps(() => { const hidden = !docked && progress.value < 1 - HIDDEN_EPSILON; return Platform.OS === 'android' ? { importantForAccessibility: hidden ? 'no-hide-descendants' : 'auto' } : { accessibilityElementsHidden: hidden }; }, [docked]); const surface = useMemo( () => ({ footerHeight, setFooterHeight }), [footerHeight] ); return ( {children} ); } export interface PanelsideHeaderProps extends ViewProps { className?: string; /** Rendered as the heading. Omit it and supply your own in `children`. */ title?: string; /** A single element pinned to the trailing end of the title row. */ action?: ReactNode; /** * What the header paints behind itself. * * `transparent` is the default and paints nothing, so the header is the * panel's own surface with a title on it rather than a bar sitting on top of * one. In the panel's normal stacking that is the whole story — the header * takes a row and the list starts below it. * * `fade` and `solid` are for a header the caller has lifted out of that * stack — `className="absolute start-0 end-0 top-0"` — so the list runs * underneath it. They are the two shapes `Panelside.Footer` offers, drawn * the other way up. */ surface?: PanelsideSurface; /** Anything below the title row — a search field, a workspace switcher. */ children?: ReactNode; } function PanelsideHeader({ className, title, action, surface = 'transparent', children, style, ...props }: PanelsideHeaderProps) { const insets = useSafeAreaInsets(); const background = useCSSVariable('--color-background'); const solid = typeof background === 'string' ? background : '#000000'; return ( {/* The footer's fade, upside down: opaque under the title and clearing to nothing at the bottom edge, so a row scrolled up into the header dissolves rather than sliding out from under a line. */} {surface === 'fade' ? ( <> ) : null} {(title || action) && ( {title ? ( {title} ) : ( )} {action} )} {textChildren(children)} ); } export interface PanelsideSearchProps extends TextInputProps { className?: string; containerClassName?: string; } /** * A compact filter field for the panel. * * Deliberately not the library's `Input`: that field carries a label, a * description, an error slot and keyboard avoidance, none of which a panel * search row wants, and all of which would have to be switched off at every * call site. */ function PanelsideSearch({ className, containerClassName, placeholder = 'Search', ...props }: PanelsideSearchProps) { const placeholderTint = useCSSVariable('--color-muted-foreground'); const textTint = useCSSVariable('--color-foreground'); const muted = typeof placeholderTint === 'string' ? placeholderTint : undefined; return ( ); } export interface PanelsideContentProps extends ScrollViewProps { className?: string; contentContainerClassName?: string; children?: ReactNode; } function PanelsideContent({ className, contentContainerClassName, contentContainerStyle, children, ...props }: PanelsideContentProps) { const surface = useContext(PanelsideSurfaceContext); return ( {textChildren(children)} ); } export interface PanelsideGroupProps extends ViewProps { className?: string; children?: ReactNode; } function PanelsideGroup({ className, children, ...props }: PanelsideGroupProps) { return ( {textChildren(children)} ); } export interface PanelsideGroupLabelProps extends ViewProps { className?: string; children?: ReactNode; } function PanelsideGroupLabel({ className, children, ...props }: PanelsideGroupLabelProps) { return ( {textChildren(children, (text) => ( {text} ))} ); } /** * What a row's parts read off the row, rather than being handed it. * * A label goes medium and un-muted when the row is the current destination, * and an icon takes the matching tint. Passing that to every part would mean * `` at every call site, with an `active` * that has to be kept in step with the row's own. */ interface PanelsideItemContextValue { active: boolean; disabled: boolean; size: PanelsideItemSize; /** The colour an icon in this row should take, resolved once by the row. */ tint: string | undefined; } const PanelsideItemContext = createContext(null); function usePanelsideItem(part: string): PanelsideItemContextValue { const value = useContext(PanelsideItemContext); if (!value) throw new Error(`${part} must be used inside a .`); return value; } export interface PanelsideItemProps extends Omit { className?: string; /** * Leading element — an icon, an avatar, a coloured dot. The shorthand for * `Panelside.ItemIcon`. */ icon?: ReactNode; /** * The row's text, truncated to one line since chat titles run long. The * shorthand for `Panelside.ItemLabel`. */ label?: string; /** * The page this row goes to — a `Panelside.Page`'s `value`. * * Pressing it sets the panel's route, and the row marks itself active while * that route is the current one. It also closes the panel, since the thing * you just moved to would otherwise be behind the thing you moved from. * * `active` and `onPress` still win where they are passed, so a row can * navigate and do something else as well. */ to?: string; /** Leave the panel open after navigating. Off by default. */ closeOnNavigate?: boolean; /** Marks the row as the current destination. Derived from `to` when given. */ active?: boolean; /** * Trailing count or status. A number or string renders as a pill; anything * else renders as given. The shorthand for `Panelside.ItemBadge`. */ badge?: ReactNode; disabled?: boolean; /** * Row density. `sm` tightens the padding for a panel that has to show more * history at once, without touching the type size — a list you can read is * worth more than two extra rows. */ size?: PanelsideItemSize; /** * The row's contents, written out: `Panelside.ItemIcon`, * `Panelside.ItemLabel`, `Panelside.ItemBadge` and `Panelside.Action`, in * whatever order the row wants them. Anything else you draw works too. * * Children and the shorthand props compose — a row can take its label from * `label` and still write a trailing `Panelside.Action` as a child. */ children?: ReactNode; } /** * One destination, or one conversation. * * There are two ways to fill it, and they are the same row. The shorthand — * `icon`, `label`, `badge` — covers the row every navigation panel has, and is * what most call sites should use. The parts cover everything else: two lines * of text, a label that is not a string, a badge before the label rather than * after it, a trailing control that is not an overflow menu. * * ```tsx * } label="Inbox" badge={12} /> * * * * {thread.title} * {thread.unread} * * * ``` */ function PanelsideItem({ className, icon, label, to, closeOnNavigate = true, active: activeProp, badge, disabled = false, size = 'default', onPress, children, ...props }: PanelsideItemProps) { const panel = useContext(PanelsideContext); const restTint = useCSSVariable('--color-muted-foreground'); const activeTint = useCSSVariable('--color-foreground'); // A row outside a `` still works — the parts are usable on their // own — it simply has no route to match against. const active = activeProp ?? (to !== undefined && panel?.route === to); const press = useCallback( (event: Parameters>[0]) => { onPress?.(event); if (to === undefined || !panel) return; panel.navigate(to); if (closeOnNavigate) panel.setOpen(false); }, [closeOnNavigate, onPress, panel, to] ); const tint = active ? typeof activeTint === 'string' ? activeTint : undefined : typeof restTint === 'string' ? restTint : undefined; const context = useMemo( () => ({ active, disabled, size, tint }), [active, disabled, size, tint] ); /* * The row needs something flexible in the middle or its trailing content * floats next to the icon instead of sitting at the end. A label supplies * that, and a written-out `Panelside.ItemLabel` supplies it too — so the * spacer is only for the row that has neither, which is a row of nothing but * an icon and a badge. * * And only where there is something to push. A row of nothing but an icon * has no trailing content, and a spacer in it is not inert: laid out against * the width available rather than against the row's own contents, it expands, * takes the space with it, and squeezes whatever shares the line — which is * how an icon-only row in a footer beside a compose button cropped that * button's label and left its own icon short of the trailing edge. */ const filled = label !== undefined || children !== undefined; const trailing = badge !== undefined && badge !== null; return ( {icon ? {icon} : null} {label !== undefined ? {label} : null} {filled || !trailing ? null : } {badge !== undefined && badge !== null ? ( {badge} ) : null} {children} ); } export interface PanelsideItemIconProps extends ViewProps { className?: string; children?: ReactNode; } /** * The leading slot on a row. * * Whatever is inside it inherits the row's own tint rather than each call site * passing a colour that stops being right the moment the row goes active. */ function PanelsideItemIcon({ className, children, ...props }: PanelsideItemIconProps) { const { tint } = usePanelsideItem('Panelside.ItemIcon'); // No wrapper unless one was asked for: an icon is already the right size, // and a `View` around it is a layout node between the row and its glyph. if (className === undefined && Object.keys(props).length === 0) { return {children}; } return ( {children} ); } export interface PanelsideItemLabelProps extends TextProps { className?: string; children?: ReactNode; } /** * The row's text. It takes the flexible middle, so a long title truncates * rather than pushing the badge and the action off the end of the panel. */ function PanelsideItemLabel({ className, children, ...props }: PanelsideItemLabelProps) { const { active } = usePanelsideItem('Panelside.ItemLabel'); return ( {children} ); } export interface PanelsideItemBadgeProps extends ViewProps { className?: string; children?: ReactNode; } /** * The trailing count or status on a row. Text becomes a pill; anything else is * drawn as given, so a dot or a chip needs no opting out of the pill. */ function PanelsideItemBadge({ className, children, ...props }: PanelsideItemBadgeProps) { usePanelsideItem('Panelside.ItemBadge'); if (typeof children !== 'string' && typeof children !== 'number') { return <>{children}; } return ( {children} ); } export interface PanelsideActionProps extends Omit { className?: string; /** * What a screen reader announces. The default control is an unlabelled glyph, * so this is the only description it has. */ label?: string; /** Replaces the default overflow glyph. */ children?: ReactNode; } function PanelsideAction({ className, label = 'More options', children, onPress, ...props }: PanelsideActionProps) { const tint = useCSSVariable('--color-muted-foreground'); const color = typeof tint === 'string' ? tint : undefined; return ( { event.stopPropagation(); onPress?.(event); }} {...props} > {children ?? } ); } export interface PanelsideItemActionsProps { className?: string; /** * What a screen reader announces for the button. The control is an * unlabelled glyph, so this is the only description it has. */ label?: string; /** Replaces the default overflow glyph. */ icon?: ReactNode; /** Where the panel opens relative to the button. Defaults to below it. */ placement?: MenuContentProps['placement']; /** How it lines up on that edge. Defaults to the button's trailing edge. */ align?: MenuContentProps['align']; /** * Floor for the menu's width. A panel sized to its contents takes its width * from whatever inside it is not flexible — in a row of a flexible label and * a fixed glyph, that is the glyph, and the menu comes up as a column of * icons with the words squeezed out of it. */ minWidth?: number; /** Passed through to the panel — `width`, `maxHeight`, `offset` and the rest. */ contentProps?: Omit; /** The rows: `Menu.Item`, `Menu.Separator`, `Menu.Label`. */ children?: ReactNode; } /** * A row's actions, behind an overflow button at the end of it. * * ```tsx * * * Rename * * Delete * * * ``` * * The panel is anchored to the button rather than presented from the bottom of * the screen, so it lines up with the row it belongs to and the list it came * from stays readable behind it. It is also narrow: the panel it opens in is a * fraction of the screen, and a sheet covering that to offer four verbs costs * more than it says. * * Pressing the button does not press the row. A row that navigates would * otherwise navigate away from the thing the menu is about. */ function PanelsideItemActions({ className, label = 'More options', icon, placement = 'bottom', align = 'end', minWidth = ACTIONS_MIN_WIDTH, contentProps, children, }: PanelsideItemActionsProps) { return ( {icon} {children} ); } export interface PanelsideFooterProps extends ViewProps { className?: string; /** * Overlay the scrolling list instead of taking a row below it. Default true — * the list runs the full height of the panel behind it, and `Panelside.Content` * leaves exactly this footer's height of room at the end. */ floating?: boolean; /** * What the footer paints behind its controls. * * `transparent` is the default and paints nothing: the list runs under the * controls, which is how the panel reads as one surface with two things * floating on it rather than as a list with a bar bolted to the bottom. * * `fade` dissolves the list into the panel background over the strip above * the controls. It costs a band of the panel, and buys a compose button that * never has a chat title running through its label — worth turning on for a * panel whose history is long enough that something is always underneath. * * `solid` is a band with a hairline over it, for a footer that is a row of * the layout. Implied by `floating={false}`, which has no list to float over. */ surface?: PanelsideSurface; children?: ReactNode; } function PanelsideFooter({ className, floating = true, surface = 'transparent', children, style, ...props }: PanelsideFooterProps) { const insets = useSafeAreaInsets(); const panel = useContext(PanelsideSurfaceContext); const setFooterHeight = panel?.setFooterHeight; const background = useCSSVariable('--color-background'); const solid = typeof background === 'string' ? background : '#000000'; // A footer in the flow has nothing to float over, so the only thing it can // be is the band — whatever was asked for. const paint = floating ? surface : 'solid'; const onLayout = useCallback( (event: LayoutChangeEvent) => { setFooterHeight?.(event.nativeEvent.layout.height); }, [setFooterHeight] ); return ( {/* The fade is two layers rather than one gradient across the whole box. A gradient sized to the box puts its midpoint wherever the box happens to be tall, which is exactly where the labels are — so the top `FOOTER_FADE` points are the gradient and everything below it, the band the controls actually sit in, is plain background. Both are inside the footer's own bounds, so neither depends on a parent that does not clip its children. */} {paint === 'fade' ? ( <> ) : null} {paint === 'solid' && floating ? ( ) : null} {textChildren(children)} ); } const styles = StyleSheet.create({ fade: { position: 'absolute', top: 0, left: 0, right: 0 }, rise: { position: 'absolute', bottom: 0, left: 0, right: 0 }, hidden: { display: 'none' }, }); export interface PanelsideCtaProps extends Omit { className?: string; /** The button's text. */ label?: string; /** Leading element, usually an icon. */ icon?: ReactNode; /** `primary` is the filled accent pill; `secondary` is the quiet one. */ variant?: 'primary' | 'secondary'; /** * How tall the pill is. `default` is 44pt — a step above the account button * beside it, so the footer reads as one primary control and one secondary * one. `lg` is 52pt, for a panel where the call to action is the only thing * in the row, and `xl` is 56pt. * * Ignored under `native` — the platform sizes its own button, and asks for a * control size rather than a height. The three steps reach the platform's * regular, large and extra-large controls. */ size?: PanelsideCtaSize; /** * Render the platform's own button instead of the pill. Requires the * optional `@expo/ui` package; without it this prop does nothing. * * **Theme tokens do not apply** — the platform draws the button, so * `className` and `icon` are ignored and it sizes itself to `label`. */ native?: boolean; /** * Draw the native button in the platform's Liquid Glass material. Requires * `native`, and iOS 26 or later; ignored anywhere else. */ glass?: boolean; children?: ReactNode; } function PanelsideCta({ className, label, icon, variant = 'primary', size = 'default', native = false, glass = false, disabled, children, ...props }: PanelsideCtaProps) { const primaryTint = useCSSVariable('--color-primary-foreground'); const secondaryTint = useCSSVariable('--color-secondary-foreground'); const raw = variant === 'primary' ? primaryTint : secondaryTint; const tint = typeof raw === 'string' ? raw : undefined; /* * Delegated to Button rather than reaching for the native bridge here. * Button already resolves the package lazily, maps the variant onto the * platform's own style and falls back when it is missing — reimplementing * that would be a second copy to keep in step with the first. */ if (native) { return ( ); } return ( {icon} {label ? ( {label} ) : null} {textChildren(children)} ); } export interface PanelsideSceneProps extends ViewProps { className?: string; /** * How small the scene gets at full travel. Default 1 — the screen keeps its * full height and stays behind the status bar, and the radius and dim do the * work. Below one it shrinks about its centre, which insets it top and bottom * as well as at the side. * * Falls back to the same prop on the `Panelside` root, so the three numbers * that describe the curve can be set once where the panel is configured. */ scale?: number; /** The corner radius the scene reaches at full travel. Default 44. */ radius?: number; /** How far the scene dims at full travel, 0 to 1. Default 0.45. */ dim?: number; /** * Styles the layer that dims the scene. Its opacity is `dim`'s to set, so * this is for the colour — a scrim that is not black, for a light theme * where black at 45% reads as a hole rather than as shade. */ scrimClassName?: string; children?: ReactNode; } function PanelsideScene({ className, scale: scaleProp, radius: radiusProp, dim: dimProp, scrimClassName, children, style, ...props }: PanelsideSceneProps) { const { progress, width, mode, docked, dismissible, open, setOpen, scale: rootScale, radius: rootRadius, dim: rootDim, } = usePanelsideContext('Panelside.Scene'); // The part's own prop, then the root's, then the constant. Three levels // because the root's is a default for every scene under it and the part's is // a statement about this one. const scale = scaleProp ?? rootScale ?? SCENE_SCALE; const radius = radiusProp ?? rootRadius ?? SCENE_RADIUS; const dim = dimProp ?? rootDim ?? SCENE_DIM; const [sceneWidth, setSceneWidth] = useState(0); const sign = useDirectionSign(); /* * The same border token every other edge in the library is drawn in, so this * one belongs to the same set rather than being a line of its own invention. * It already inverts with the theme — white at 6% in a dark one, black at 8% * in a light one — which is what makes it read on both sides of a boundary * between two surfaces of the same colour. * * It only failed to show before because it was drawn *under* the scrim. Above * it, at a full point, the token is enough on its own. */ const edge = useCSSVariable('--color-border'); const edgeColor = typeof edge === 'string' ? edge : undefined; const onLayout = useCallback((event: LayoutChangeEvent) => { setSceneWidth(event.nativeEvent.layout.width); }, []); const close = useCallback(() => setOpen(false), [setOpen]); /* * `pushes` rather than a branch inside the worklet, so the style always * returns the same set of properties. Reanimated keeps a property it has * seen once; dropping it from a later frame leaves the last value applied * instead of resetting it. */ const pushes = mode === 'push' && !docked; const animatedStyle = useAnimatedStyle(() => { const p = pushes ? progress.value : 0; const s = 1 - (1 - scale) * p; return { transform: [ // Subtracting the inset a centre-origin scale already applied is what // lands the scene's visible edge on the panel's, rather than near it. { translateX: sign * (p * (width + GAP) - (sceneWidth * (1 - s)) / 2) }, { scale: s }, ], borderRadius: p * radius, }; }, [pushes, radius, scale, sceneWidth, sign, width]); const scrimStyle = useAnimatedStyle(() => { const p = docked ? 0 : progress.value; return { opacity: p * dim }; }, [dim, docked]); /* * The edge is drawn as its own layer rather than as a border on the scene. * A border is a layout property: put one on the scene itself and it insets * everything inside by its width for the whole life of the screen, open or * shut, to show a line that is only wanted while the panel is out. A ring * over the top costs nothing when it is invisible. */ const ringStyle = useAnimatedStyle(() => { const p = docked ? 0 : progress.value; return { opacity: p * EDGE_OPACITY, borderRadius: p * radius }; }, [docked, radius]); const animatedProps = useAnimatedProps(() => { const hidden = !docked && progress.value > 1 - HIDDEN_EPSILON; return Platform.OS === 'android' ? { importantForAccessibility: hidden ? 'no-hide-descendants' : 'auto' } : { accessibilityElementsHidden: hidden }; }, [docked]); return ( {children} {/* Layered over the scene rather than under it, so it dims the app and catches the tap in the scene's own space — which means it inherits the corner radius instead of having to reproduce it. `pointerEvents` is a prop driven by state, not an animated style. A view at zero opacity still takes touches, so getting this wrong does not look like anything — it silently eats every tap on the app, including the one on the button that opens the panel. */} {dismissible ? ( ) : null} {/* A hairline where the scene meets the panel. Without it two surfaces of the same colour meet at a corner and the radius is the only thing saying they are separate — which reads as a rendering artefact rather than as an edge. */} {edgeColor ? ( ) : null} ); } export interface PanelsidePagesProps extends ViewProps { className?: string; /** `Panelside.Page` elements. Anything else is rendered as given. */ children?: ReactNode; } /** * The pages the panel navigates between. * * Put it inside `Panelside.Scene` and give each page a `value` that a row's * `to` matches. Nothing is wired between the two: the row sets the panel's * route, and the page whose value equals it is the one shown. * * ```tsx * * * * * * * ``` * * A page is mounted the first time it is visited and stays mounted after * that, hidden rather than removed. Going back to one is then a style change * rather than a mount: its list does not rebuild, its scroll position is where * you left it, and whatever it was fetching is already there. A page whose * contents go stale — or whose data is large enough that keeping it is worse * than fetching it again — takes `keepAlive={false}`. */ function PanelsidePages({ className, children, ...props }: PanelsidePagesProps) { const { route } = usePanelsideContext('Panelside.Pages'); /* * Which pages have ever been the route. * * A `Set` in state rather than a ref: mounting a page for the first time has * to be a render, and the ref would not cause one. It only ever grows, and * only by one entry per page, so the identity change per first visit costs * nothing after the pages have all been seen once. */ const [visited, setVisited] = useState(() => [route]); useEffect(() => { setVisited((current) => (current.includes(route) ? current : [...current, route])); }, [route]); return ( {Children.map(children, (child) => { if (!isValidElement(child) || child.type !== PanelsidePage) { return child; } const { value, keepAlive = true } = child.props; const current = value === route; if (!current && (!keepAlive || !visited.includes(value))) return null; return cloneElement(child, { hidden: !current }); })} ); } export interface PanelsidePageProps extends ViewProps { className?: string; /** What a row's `to` has to equal for this page to be the one shown. */ value: string; /** * Keep the page mounted once it has been visited. Default true, which is * what makes going back to it instant. Off, it is torn down on the way out * and rebuilt on the way in. */ keepAlive?: boolean; /** * Set by `Panelside.Pages`. A hidden page is laid out by nobody, is not in * the accessibility tree, and takes no touches — but it is still mounted, * which is the whole point of it. */ hidden?: boolean; children?: ReactNode; } /** One page. Only meaningful inside `Panelside.Pages`. */ function PanelsidePage({ className, value, keepAlive, hidden = false, children, style, ...props }: PanelsidePageProps) { // `display: none` rather than unmounting, and rather than opacity: it takes // the page out of layout entirely, so a hidden page costs no measurement, // while its component tree — and everything it is holding — stays. return ( {textChildren(children)} ); } export interface PanelsideTriggerProps extends Omit { className?: string; /** What a screen reader announces. */ label?: string; /** * A single pressable element to use instead of the default button. Its own * `onPress` still runs. */ children?: ReactElement<{ onPress?: (...args: unknown[]) => void }>; } function PanelsideTrigger({ className, label = 'Open navigation panel', children, onPress, ...props }: PanelsideTriggerProps) { const { toggle, docked } = usePanelsideContext('Panelside.Trigger'); const tint = useCSSVariable('--color-foreground'); const color = typeof tint === 'string' ? tint : undefined; // A docked panel is already open and cannot be closed, so a control for it // would be a button that does nothing. if (docked) return null; if (children && isValidElement(children)) { return cloneElement(children, { onPress: (...args: unknown[]) => { children.props.onPress?.(...args); onPress?.(...(args as Parameters>)); toggle(); }, }); } return ( { onPress?.(event); toggle(); }} className={cn('h-10 w-10 items-center justify-center rounded-full', className)} accessibilityRole="button" accessibilityLabel={label} > ); } /* ------------------------------------------------------------------ * * Search. * * A field in the header is the obvious way to put search in a navigation * panel, and it is the wrong one on a phone. The panel is 80% of the screen * and the field is 40 points of it, so a search that returns anything has to * push the history down the screen it is already filling — and the field is at * the top, which is the far end of the screen from the keyboard that has just * opened under it. * * So search is a surface rather than a row. A round button in the header opens * a sheet that is the whole screen; the tabs across the top narrow what is * being searched; the results fill the middle; and the field is at the bottom, * where the thumb already is, riding the keyboard rather than hiding behind * it. * * `Panelside.Search` — the inline field — is still exported, and is still * right for a docked panel on a tablet, where there is width for a field and * no keyboard covering half the screen. * ------------------------------------------------------------------ */ export interface PanelsideSearchTriggerProps extends Omit { className?: string; /** What a screen reader announces. */ label?: string; /** * `filled` is the default: a circle in the secondary surface, which is what * a control sitting alone on the panel's own surface needs to read as one. * * `outline` is a ring and no fill, for a panel whose other controls are * outlined too — a filled circle among them is the only thing on the screen * claiming to be a second primary. * * Ignored under `native`, where the platform owns the button's chrome. */ variant?: PanelsideControlVariant; /** Replaces the default magnifier. */ children?: ReactNode; /** * Render the platform's own button instead of the circle. Requires the * optional `@expo/ui` package; without it this prop does nothing. */ native?: boolean; /** * Draw the native button in the platform's Liquid Glass material. Requires * `native`, and iOS 26 or later; ignored anywhere else. */ glass?: boolean; } /** * The search button. Goes in `Panelside.Header`'s `action` slot. * * It draws the control and nothing else: a circle, or the platform's own * button under `native`, with the magnifier already in it. What pressing it * opens is `onPress`'s to decide — a page the scene navigates to, a sheet, a * screen pushed onto the app's own stack. * * That is the same arrangement `Panelside.Trigger` has, and it is here for the * same reason: where search goes is a decision about the app, and a component * that answers it for you is one you have to work around the first time the * answer is different. */ function PanelsideSearchTrigger({ className, label = 'Search', variant = 'filled', children, native = false, glass = false, onPress, ...props }: PanelsideSearchTriggerProps) { const tint = useCSSVariable('--color-foreground'); const color = typeof tint === 'string' ? tint : undefined; const open = onPress; const glyph = children ?? ; if (native) { return ( ); } return ( {glyph} ); } PanelsidePanel.displayName = 'Panelside.Panel'; PanelsideHeader.displayName = 'Panelside.Header'; PanelsideSearch.displayName = 'Panelside.Search'; PanelsideContent.displayName = 'Panelside.Content'; PanelsideGroup.displayName = 'Panelside.Group'; PanelsideGroupLabel.displayName = 'Panelside.GroupLabel'; PanelsideItem.displayName = 'Panelside.Item'; PanelsideItemIcon.displayName = 'Panelside.ItemIcon'; PanelsideItemLabel.displayName = 'Panelside.ItemLabel'; PanelsideItemBadge.displayName = 'Panelside.ItemBadge'; PanelsideAction.displayName = 'Panelside.Action'; PanelsideItemActions.displayName = 'Panelside.ItemActions'; PanelsideFooter.displayName = 'Panelside.Footer'; PanelsideCta.displayName = 'Panelside.Cta'; PanelsideScene.displayName = 'Panelside.Scene'; PanelsidePages.displayName = 'Panelside.Pages'; PanelsidePage.displayName = 'Panelside.Page'; PanelsideTrigger.displayName = 'Panelside.Trigger'; PanelsideSearchTrigger.displayName = 'Panelside.SearchTrigger'; export const Panelside = Object.assign(PanelsideRoot, { Panel: PanelsidePanel, Header: PanelsideHeader, Search: PanelsideSearch, Content: PanelsideContent, Group: PanelsideGroup, GroupLabel: PanelsideGroupLabel, Item: PanelsideItem, ItemIcon: PanelsideItemIcon, ItemLabel: PanelsideItemLabel, ItemBadge: PanelsideItemBadge, Action: PanelsideAction, ItemActions: PanelsideItemActions, Footer: PanelsideFooter, Cta: PanelsideCta, Scene: PanelsideScene, Pages: PanelsidePages, Page: PanelsidePage, Trigger: PanelsideTrigger, SearchTrigger: PanelsideSearchTrigger, });