/**
* Frame — a widget shell: a card of rows sitting in a tray, with the tray's
* one exposed strip along the top carrying the title.
*
* The two surfaces are nested rather than stacked, and only one edge of the
* outer one is ever visible. The panel is flush to the shell's left, right and
* bottom, so the shell reads as something the card is *sitting in* rather than
* as a border around it — and the strip left at the top is the header, which
* is why the header needs no rule under it and no background of its own.
*
* The shell's radius is the larger of the two, and the panel's top corners are
* tighter. That is the reverse of the usual nested-radius rule, and it is
* deliberate: with only the top corners free, matching them would make the two
* surfaces read as one misdrawn shape. The panel's bottom corners are not set
* at all — the shell clips them, so they take its radius exactly.
*
* That clip follows the shell's *border box*, not the box inside its border.
* Along the straight edges the panel is held off by the border width and the
* edge shows through, but at the corner arcs the panel's square corner is
* clipped to the outer radius and paints across the border. At the default
* hairline that is a sliver nobody sees. Give the shell a thicker border and
* the corners visibly eat it, so a Frame with `border-2` or more needs the
* panel told where to stop:
*
* ```tsx
*
* …
*
* ```
*
* The radius to use is the shell's less its border width. It is on the caller
* because both arrive as `className` strings, which the component cannot read.
*
* ```tsx
*
*
* Agent monitor
* All agents under 25% token limit
*
*
*
*
*
* opus-4.6
* Indexing the repository
*
* Running
*
*
*
* ```
*
* `inset` is the other way to nest the two. The panel floats clear of the
* shell on all four sides rather than sitting flush against three, and the band
* left around it carries `Frame.Footer`. The band is a recess: the shell is the
* popover surface with `--color-inset` laid over it rather than a colour of its
* own, so it always comes out darker than the panel it holds. The surface
* ladder cannot do that job — it runs darker in a light theme and lighter in a
* dark one, and a recess has to read the same way in both.
*
* There is no shadow under it. A recessed band and a drop shadow are opposite
* claims about where a surface sits, and this one is set into the page.
*
* The panel draws the hairlines between its own rows. React Native has no
* `:first-child`, so the alternative is every caller writing
* `divided={index > 0}` on every row and getting it wrong once.
*
* A row is three slots, and they exist because of one React Native detail:
* Yoga defaults `flexShrink` to `0`, the opposite of the web. A child that is
* not told to shrink never does, so a fourth thing in a row pushes the others
* past the edge — where the frame's `overflow-hidden` silently cuts them off
* rather than wrapping or truncating. `Frame.Media` and `Frame.Actions` hold
* their size, `Frame.Content` takes what is left and is allowed to shrink to
* nothing, and the row fits at any width without the caller measuring anything.
*/
import {
Children,
cloneElement,
createContext,
forwardRef,
isValidElement,
useContext,
type ReactNode,
} from 'react';
import {
Pressable,
View,
type PressableProps,
type Text as RNText,
type ViewProps,
} from 'react-native';
import { tv } from 'tailwind-variants';
import { ChevronRightIcon } from '../../icons';
import { Text, type TextProps, textChildren } from '../../primitives/text';
import { cn } from '../../utils/cn';
/**
* The `inset` shell's geometry. Numbers rather than classes, because an
* arbitrary Tailwind value a running dev server has not already compiled turns
* into nothing at all — no error, no warning, the corner simply squares off.
*
* The panel's radius is the shell's less the shell's padding, so the two curves
* are concentric. A panel radius that ignored the band would leave a crescent
* of shell thicker at the corners than along the sides.
*/
const SHELL_PADDING = 8;
const SHELL_RADIUS = 38;
const PANEL_RADIUS = SHELL_RADIUS - SHELL_PADDING;
const FOOTER_INSET = 26;
const FOOTER_GAP = 16;
/** What the band does to an action put in it: equal width, and a full pill. */
const FOOTER_ACTION = 'h-11 flex-1 rounded-full';
const frameVariants = tv({
slots: {
root: '',
recess: 'absolute inset-0 bg-inset',
panel: 'overflow-hidden bg-card',
header: 'flex-row items-center justify-between gap-3 px-4 pb-3 pt-2.5',
footer: 'flex-row items-center gap-2 px-4 pb-3.5 pt-3',
},
variants: {
variant: {
// `overflow-hidden` is load-bearing — it is what makes the panel's
// bottom corners take the shell's radius instead of squaring off
// against it.
//
// The panel is flush left, right and bottom: no side or bottom border,
// because the shell's own edge is already there, and no bottom radius,
// because the shell clips it. Only the top corners and the rule under
// the header are the panel's to draw.
default: {
root: 'overflow-hidden rounded-3xl border border-border bg-surface',
panel: 'rounded-t-2xl border-t border-border',
},
// No shell: the panel is the whole widget. For a Frame nested inside a
// card that already draws a border, where the shell's own edge sitting
// just inside it reads as a double line.
plain: {
root: '',
panel: 'rounded-3xl border border-border',
// Nothing to be held in from — the footer lines up with the panel.
footer: 'px-0',
},
// The panel floats inside the shell on all four sides instead of sitting
// flush against three of them, and the band around it is a recess rather
// than a lighter tray.
inset: {
root: 'overflow-hidden bg-popover',
panel: 'bg-popover',
header: 'px-3 pb-2.5 pt-1',
footer: 'gap-3.5 p-0',
},
},
},
defaultVariants: {
variant: 'default',
},
});
export type FrameVariant = 'default' | 'plain' | 'inset';
/**
* True inside a `Frame.Content`. The header's caption and a row's title are the
* same part in two places and only the placement decides the weight: a header
* labels the tray the card sits in, so it stays quiet, while a row title is the
* row's subject and has to carry it.
*/
const FrameSlotContext = createContext(false);
/**
* The root's variant, for the parts that draw differently under each one. The
* panel, the header and the footer all need it, and none of them can be told
* directly — a caller writes `` once and expects the
* shape to follow.
*/
const FrameVariantContext = createContext('default');
export interface FrameProps extends ViewProps {
className?: string;
}
export interface FrameRootProps extends FrameProps {
/**
* `plain` drops the outer shell so the panel is the widget — for a Frame
* inside a container that already draws its own border. `inset` sets the
* panel into a recessed band on all four sides instead, and gives
* `Frame.Footer` somewhere to sit.
*/
variant?: FrameVariant;
}
const FrameRoot = forwardRef(
({ className, variant = 'default', children, style, ...props }, ref) => {
const slots = frameVariants({ variant });
const inset = variant === 'inset';
return (
{inset ? : null}
{children}
);
}
);
FrameRoot.displayName = 'Frame';
export interface FrameHeaderProps extends FrameProps {
children?: ReactNode;
}
/**
* The header row — the strip of shell left exposed above the panel. Title on
* the left, `Frame.Action` on the right. Add `className="flex-col items-start"`
* when you want a description underneath.
*/
const FrameHeader = forwardRef(
({ className, ...props }, ref) => {
// `min-w-0` on nothing here — the title itself takes the flexible side, so
// a long one truncates instead of shoving the action off the edge.
//
// The padding follows the variant: under `inset` the shell already holds
// everything in by its own padding, so repeating the full inset here would
// set the title further from the edge than the panel below it.
const variant = useContext(FrameVariantContext);
return (
);
}
);
FrameHeader.displayName = 'Frame.Header';
/**
* Muted in a header, where it is a caption on the tray the card sits in. Inside
* a `Frame.Content` it is the row's subject instead, so it takes the foreground
* colour and medium weight, and truncates to one line rather than pushing the
* row's trailing slot out of view. Pass `numberOfLines` to override either way.
*/
const FrameTitle = forwardRef(({ className, ...props }, ref) => {
const inRow = useContext(FrameSlotContext);
if (inRow) {
return (
);
}
return (
);
});
FrameTitle.displayName = 'Frame.Title';
export interface FrameActionProps extends FrameProps {
children?: ReactNode;
}
/**
* Trailing slot on the header row — a column label, a count, a button, a badge.
* Plain strings render as muted text; anything else renders as-is.
*/
const FrameAction = forwardRef(
({ className, children, ...props }, ref) => (
{textChildren(children, (text) => (
{text}
))}
)
);
FrameAction.displayName = 'Frame.Action';
/**
* Secondary line under the title — in a column-wrapped header, or under a row's
* title inside `Frame.Content`, where it drops a size and wraps to two lines.
*/
const FrameDescription = forwardRef(
({ className, ...props }, ref) => {
const inRow = useContext(FrameSlotContext);
return (
);
}
);
FrameDescription.displayName = 'Frame.Description';
export interface FrameMediaProps extends FrameProps {
children?: ReactNode;
}
/**
* Leading slot on a row — an icon, an avatar, a status dot. Holds its size, so
* whatever it holds is never squeezed by the text beside it.
*/
const FrameMedia = forwardRef(
({ className, ...props }, ref) => (
)
);
FrameMedia.displayName = 'Frame.Media';
export interface FrameContentProps extends FrameProps {
children?: ReactNode;
}
/**
* The flexible middle of a row — usually a `Frame.Title` over a
* `Frame.Description`. It takes whatever the media and actions leave and is
* allowed to shrink past its content, which is what keeps a long line from
* pushing the rest of the row off the edge.
*/
const FrameContent = forwardRef(
({ className, children, ...props }, ref) => (
// `min-w-0` is the whole trick: a flex child's minimum size is its content
// unless told otherwise, so `flex-1` alone still refuses to go narrower
// than the longest word in it.
{children}
)
);
FrameContent.displayName = 'Frame.Content';
export interface FrameActionsProps extends FrameProps {
children?: ReactNode;
}
/**
* Trailing slot on a row — a chip, a value, a switch, a small button. Holds its
* size, so it stays readable however long the content beside it runs.
*/
const FrameActions = forwardRef(
({ className, ...props }, ref) => (
)
);
FrameActions.displayName = 'Frame.Actions';
/**
* Marks the parts that take part in the panel's own divider bookkeeping —
* a Row draws a line above itself, a Section draws one above its heading.
* Anything else the panel is given is left alone.
*/
interface Dividable {
divided?: boolean;
}
/**
* Draws the hairline above every child but the first. An explicit `divided`
* on a child wins, so a row can still opt out or force one.
*/
function divideChildren(children: ReactNode) {
let seen = 0;
return Children.map(children, (child) => {
if (!isValidElement(child)) return child;
if (!DIVIDABLE.has(child.type)) return child;
const index = seen++;
if (child.props.divided !== undefined) return child;
return cloneElement(child, { divided: index > 0 });
});
}
export interface FramePanelProps extends FrameProps {
/**
* Set false to place the hairlines by hand instead — for a panel whose rows
* are generated somewhere the divider order is not obvious.
*/
dividers?: boolean;
children?: ReactNode;
}
/**
* The card holding the frame's content — flush to the shell on three sides,
* with the header strip above it. Under `inset` it floats clear of all four
* instead, at the radius that keeps it concentric with the shell.
*/
const FramePanel = forwardRef(
({ className, dividers = true, children, style, ...props }, ref) => {
const variant = useContext(FrameVariantContext);
return (
{dividers ? divideChildren(children) : children}
);
}
);
FramePanel.displayName = 'Frame.Panel';
export interface FrameRowProps extends Omit, Dividable {
className?: string;
/**
* Draw a hairline above this row. `Frame.Panel` sets it for you; pass it
* explicitly to override the panel's decision either way.
*/
divided?: boolean;
/** Trailing chevron marking the row as leading somewhere. */
chevron?: boolean;
/**
* Let the row run onto a second line instead of holding one. For a cluster
* of chips or tags, where the alternative is the last ones being clipped.
*/
wrap?: boolean;
/**
* Where the row's slots sit against each other. `start` for a row two or
* three lines tall, where centring an icon against a tall text column leaves
* it floating in the middle.
*/
align?: 'center' | 'start';
children?: ReactNode;
}
/**
* A row inside a Frame.Panel. Give it an `onPress` and it becomes a real
* pressable — press feedback, a button role — rather than a View with a
* handler bolted on.
*/
const FrameRow = forwardRef(
(
{ className, divided, chevron, wrap, align = 'center', children, onPress, ...props },
ref
) => {
const classes = cn(
'flex-row gap-3 px-4 py-3.5',
align === 'start' ? 'items-start' : 'items-center',
wrap && 'flex-wrap',
divided && 'border-t border-border',
onPress && 'active:bg-muted',
className
);
const body = (
<>
{textChildren(children)}
{chevron ? : null}
>
);
if (!onPress) {
return (
{body}
);
}
return (
{body}
);
}
);
FrameRow.displayName = 'Frame.Row';
export interface FrameSectionProps extends FrameProps, Dividable {
/** Heading above the rows. Strings are wrapped for you. */
title?: ReactNode;
divided?: boolean;
children?: ReactNode;
}
/**
* A labelled cluster of rows inside a Panel, for a widget holding more than
* one group. It divides its own rows the way the panel does, so the two nest
* without either having to know about the other.
*/
const FrameSection = forwardRef(
({ className, title, divided, children, ...props }, ref) => (
{title ? (
{typeof title === 'string' ? (
{title}
) : (
title
)}
) : null}
{divideChildren(children)}
)
);
FrameSection.displayName = 'Frame.Section';
export interface FrameFooterProps extends FrameProps {
children?: ReactNode;
}
/**
* Shapes an `inset` footer's actions into the band's pills.
*
* The band is a row of equal decisions, so the actions are equal widths and
* full circles rather than whatever radius each one arrived with. Doing it
* here rather than asking every caller for three classes is the difference
* between a variant that looks a certain way and one that can be made to.
*
* The caller's own `className` is merged last, so any of it can still be
* overridden — a trailing icon button that should stay square, say.
*/
function pillChildren(children: ReactNode) {
return Children.map(children, (child) => {
if (!isValidElement<{ className?: string }>(child)) return child;
return cloneElement(child, {
className: cn(FOOTER_ACTION, child.props.className),
});
});
}
/**
* The row of actions under the panel — what somebody does with the widget,
* rather than more of what it says.
*
* Under `inset` it sits in the band, held further in than the panel is. A row
* running the full width of the shell reads as another edge of it rather than
* as things to press. Under the other two variants the panel stops being flush
* at the bottom, which is what having a footer means there.
*/
const FrameFooter = forwardRef(
({ className, style, children, ...props }, ref) => {
const variant = useContext(FrameVariantContext);
const inset = variant === 'inset';
return (
{inset ? pillChildren(children) : children}
);
}
);
FrameFooter.displayName = 'Frame.Footer';
/** Parts the panel divides. Declared after them, since it holds references. */
const DIVIDABLE = new Set([FrameRow, FrameSection]);
export const Frame = Object.assign(FrameRoot, {
Header: FrameHeader,
Title: FrameTitle,
Action: FrameAction,
Description: FrameDescription,
Panel: FramePanel,
Footer: FrameFooter,
Section: FrameSection,
Row: FrameRow,
Media: FrameMedia,
Content: FrameContent,
Actions: FrameActions,
});