/**
* Item — a row of content: media on one side, a title and description in the
* middle, actions on the other side.
*
* It is the shape almost every list in an app already has — a settings row, a
* file in a picker, a member in a team list, a tool call in a transcript — so
* it exists as one composable primitive rather than being rebuilt per screen
* with slightly different padding each time.
*
* ```tsx
* -
*
*
* Invoice.pdf
* 2.4 MB · Updated yesterday
*
*
*
* ```
*
* Two axes, and they are independent. `orientation` on the item decides
* whether its own parts sit side by side or stack into a card; `orientation`
* on `Item.Group` decides whether the items themselves run down the screen or
* across it. A carousel wants both set to the non-default: a horizontal group
* of vertical items.
*/
import { createContext, forwardRef, useContext, type ReactNode } from 'react';
import { View, type ViewProps } from 'react-native';
import { tv, type VariantProps } from 'tailwind-variants';
import { AnimatedPressable, type AnimatedPressableProps } from '../../primitives/animated-pressable';
import { Text, type TextProps, textChildren } from '../../primitives/text';
import { cn } from '../../utils/cn';
type ItemSize = 'default' | 'sm' | 'xs';
type ItemOrientation = 'horizontal' | 'vertical';
const itemVariants = tv({
slots: {
root: 'gap-3 rounded-xl',
title: 'font-medium text-foreground',
description: 'text-muted-foreground',
},
variants: {
variant: {
default: {},
outline: { root: 'border border-border' },
muted: { root: 'bg-muted' },
},
size: {
default: { root: 'p-4', title: 'text-base', description: 'text-sm' },
sm: { root: 'p-3', title: 'text-sm', description: 'text-xs' },
xs: { root: 'gap-2 p-2', title: 'text-sm', description: 'text-xs' },
},
orientation: {
/** Media, text and actions side by side — the list-row shape. */
horizontal: { root: 'w-full flex-row items-center' },
/** Stacked into a card — the shape a horizontal carousel wants. */
vertical: { root: 'flex-col items-start' },
},
disabled: {
true: { root: 'opacity-[0.64]' },
},
},
defaultVariants: {
variant: 'default',
size: 'default',
orientation: 'horizontal',
},
});
/** Sub-components inherit the row's density and axis rather than repeating them. */
const ItemContext = createContext<{ size: ItemSize; orientation: ItemOrientation }>({
size: 'default',
orientation: 'horizontal',
});
const mediaVariants = tv({
base: 'shrink-0 items-center justify-center',
variants: {
variant: {
/** No box — for an Avatar or anything that styles itself. */
default: '',
/** Rounded square tile sized for an icon. */
icon: 'rounded-lg border border-border bg-muted',
/** Clipped frame for an image or thumbnail. */
image: 'overflow-hidden rounded-lg bg-muted',
},
size: {
default: '',
sm: '',
xs: '',
},
},
compoundVariants: [
{ variant: 'icon', size: 'default', class: 'h-10 w-10' },
{ variant: 'icon', size: 'sm', class: 'h-8 w-8' },
{ variant: 'icon', size: 'xs', class: 'h-6 w-6' },
{ variant: 'image', size: 'default', class: 'h-12 w-12' },
{ variant: 'image', size: 'sm', class: 'h-10 w-10' },
{ variant: 'image', size: 'xs', class: 'h-8 w-8' },
],
defaultVariants: {
variant: 'default',
size: 'default',
},
});
export interface ItemProps
extends Omit,
Omit, 'disabled'> {
className?: string;
disabled?: boolean;
/**
* Row density. `Item.Media`, `Item.Title` and `Item.Description` follow it,
* so it only needs setting here.
*/
size?: ItemSize;
/**
* `horizontal` is the list row: media, text and actions side by side.
* `vertical` stacks them into a card, which is what a horizontal carousel
* wants — and it is also what `Item.Header` and `Item.Footer` need, since
* both are full-width strips.
*/
orientation?: ItemOrientation;
children?: ReactNode;
}
/**
* Renders as a pressable when given `onPress`, and as a plain view otherwise,
* so a static row does not announce itself as a button.
*/
const ItemRoot = forwardRef(
(
{
className,
variant,
size = 'default',
orientation = 'horizontal',
disabled,
children,
onPress,
...props
},
ref
) => {
const { root } = itemVariants({ variant, size, orientation, disabled: !!disabled });
const body = !onPress ? (
{textChildren(children)}
) : (
{textChildren(children)}
);
return (
{body}
);
}
);
ItemRoot.displayName = 'Item';
export interface ItemGroupProps extends ViewProps {
className?: string;
/**
* `vertical` stacks the items — the settings-list shape. `horizontal` runs
* them across instead, for a carousel; pair it with a scrollable and
* `orientation="vertical"` on each item so every entry reads as a card.
*/
orientation?: ItemOrientation;
children?: ReactNode;
}
/** Stack of items. Pair with `Item.Separator` between them. */
const ItemGroup = forwardRef(
({ className, orientation = 'vertical', children, ...props }, ref) => (
{textChildren(children)}
)
);
ItemGroup.displayName = 'Item.Group';
export interface ItemSeparatorProps extends ViewProps {
className?: string;
/** Match the group's axis: a horizontal group needs vertical hairlines. */
orientation?: ItemOrientation;
}
/** Hairline between rows in a group. */
const ItemSeparator = forwardRef(
({ className, orientation = 'vertical', ...props }, ref) => (
)
);
ItemSeparator.displayName = 'Item.Separator';
export interface ItemMediaProps
extends ViewProps,
VariantProps {
className?: string;
children?: ReactNode;
}
/** Leading slot: an icon tile, a thumbnail, or an avatar passed through. */
const ItemMedia = forwardRef(
({ className, variant, size, children, ...props }, ref) => {
const item = useContext(ItemContext);
return (
{textChildren(children)}
);
}
);
ItemMedia.displayName = 'Item.Media';
export interface ItemContentProps extends ViewProps {
className?: string;
children?: ReactNode;
}
/**
* The text column. In a horizontal item it takes the remaining width so the
* actions stay pinned to the trailing edge; in a vertical one it just fills
* the width, because there is nothing beside it to push against.
*/
const ItemContent = forwardRef(
({ className, children, ...props }, ref) => {
const { orientation } = useContext(ItemContext);
return (
{textChildren(children)}
);
}
);
ItemContent.displayName = 'Item.Content';
export interface ItemTitleProps extends TextProps {
className?: string;
}
const ItemTitle = forwardRef, ItemTitleProps>(
({ className, ...props }, ref) => {
const { size } = useContext(ItemContext);
const { title } = itemVariants({ size });
return ;
}
);
ItemTitle.displayName = 'Item.Title';
export interface ItemDescriptionProps extends TextProps {
className?: string;
}
const ItemDescription = forwardRef<
React.ElementRef,
ItemDescriptionProps
>(({ className, ...props }, ref) => {
const { size } = useContext(ItemContext);
const { description } = itemVariants({ size });
return ;
});
ItemDescription.displayName = 'Item.Description';
export interface ItemActionsProps extends ViewProps {
className?: string;
children?: ReactNode;
}
/** Trailing slot: buttons, a chevron, a switch. */
const ItemActions = forwardRef(
({ className, children, ...props }, ref) => (
{textChildren(children)}
)
);
ItemActions.displayName = 'Item.Actions';
export interface ItemHeaderProps extends ViewProps {
className?: string;
children?: ReactNode;
}
/**
* Full-width strip above the item's main content — an eyebrow label, a badge
* row. Needs `orientation="vertical"` on the item, since a strip only makes
* sense once the item stacks.
*/
const ItemHeader = forwardRef(
({ className, children, ...props }, ref) => (
{textChildren(children)}
)
);
ItemHeader.displayName = 'Item.Header';
export interface ItemFooterProps extends ViewProps {
className?: string;
children?: ReactNode;
}
/** Full-width strip below the row's main content. */
const ItemFooter = forwardRef(
({ className, children, ...props }, ref) => (
{textChildren(children)}
)
);
ItemFooter.displayName = 'Item.Footer';
export const Item = Object.assign(ItemRoot, {
Group: ItemGroup,
Separator: ItemSeparator,
Media: ItemMedia,
Content: ItemContent,
Title: ItemTitle,
Description: ItemDescription,
Actions: ItemActions,
Header: ItemHeader,
Footer: ItemFooter,
});