/** * EmptyState — the placeholder shown when a list or view has no content. * * Centred Header / Media / Title / Description / Content, with an `icon` media * variant that stacks two rotated ghost cards behind the icon. On the web that * is a pair of CSS pseudo-elements; React Native has none, so it is two * absolutely positioned siblings with static rotate transforms. */ import { createContext, forwardRef, useContext, type ReactNode } from 'react'; import { View, type Text as RNText, type ViewProps } from 'react-native'; import { tv, type VariantProps } from 'tailwind-variants'; import { Text, type TextProps, textChildren } from '../../primitives/text'; type EmptyStateSize = 'sm' | 'default' | 'lg'; /** Sub-components read the size so the title, description and media scale with the root. */ const EmptyStateContext = createContext<{ size: EmptyStateSize }>({ size: 'default' }); const emptyStateVariants = tv({ slots: { root: 'w-full items-center justify-center', header: 'w-full max-w-sm items-center gap-1', title: 'text-center font-semibold text-foreground', description: 'text-center text-muted-foreground', content: 'w-full max-w-sm items-center gap-3', }, variants: { variant: { // Fills whatever space it is given — a whole screen, or a flex parent. default: { root: 'flex-1' }, // A self-contained block that sits inside content rather than filling a // screen: bordered, on the card fill, sized to its contents. card: { root: 'rounded-2xl border border-border bg-card' }, }, size: { sm: { root: 'gap-4 px-4 py-8', title: 'text-base', description: 'text-sm' }, default: { root: 'gap-6 px-6 py-12', title: 'text-xl', description: 'text-sm' }, lg: { root: 'gap-6 px-6 py-16', title: 'text-2xl', description: 'text-base' }, }, }, defaultVariants: { variant: 'default', size: 'default', }, }); const mediaVariants = tv({ slots: { wrapper: 'items-center justify-center', card: 'shrink-0 items-center justify-center', ghost: 'absolute rounded-md border border-border bg-card', }, variants: { variant: { default: { card: 'bg-transparent', ghost: 'hidden', }, icon: { card: 'rounded-md border border-border bg-card', }, }, size: { sm: { card: 'h-8 w-8', ghost: 'h-8 w-8' }, default: { card: 'h-9 w-9', ghost: 'h-9 w-9' }, lg: { card: 'h-11 w-11', ghost: 'h-11 w-11' }, }, }, defaultVariants: { variant: 'default', size: 'default', }, }); export interface EmptyStateProps extends ViewProps, VariantProps { className?: string; /** * `default` fills its parent — a whole screen, or a flex slot. `card` is a * self-contained bordered block for an empty state that sits inside content * rather than owning the screen. */ variant?: 'default' | 'card'; /** Scales the padding, media, title and description together. */ size?: EmptyStateSize; children?: ReactNode; } const EmptyStateRoot = forwardRef( ({ className, variant, size = 'default', ...props }, ref) => { const { root } = emptyStateVariants({ variant, size }); return ( ); } ); EmptyStateRoot.displayName = 'EmptyState'; /** Groups Media, Title and Description into one centered column. */ const EmptyStateHeader = forwardRef( ({ className, ...props }, ref) => { const { header } = emptyStateVariants(); return ; } ); EmptyStateHeader.displayName = 'EmptyState.Header'; export interface EmptyStateMediaProps extends ViewProps, VariantProps { className?: string; children?: ReactNode; } /** * Visual anchor above the title. `variant="icon"` frames the child in a small * card with two rotated ghost cards fanned out behind it. */ const EmptyStateMedia = forwardRef( ({ className, variant = 'default', children, ...props }, ref) => { const { size } = useContext(EmptyStateContext); const { wrapper, card, ghost } = mediaVariants({ variant, size }); return ( {variant === 'icon' ? ( <> ) : null} {textChildren(children)} ); } ); EmptyStateMedia.displayName = 'EmptyState.Media'; const EmptyStateTitle = forwardRef(({ className, ...props }, ref) => { const { size } = useContext(EmptyStateContext); const { title } = emptyStateVariants({ size }); return ; }); EmptyStateTitle.displayName = 'EmptyState.Title'; const EmptyStateDescription = forwardRef( ({ className, ...props }, ref) => { const { size } = useContext(EmptyStateContext); const { description } = emptyStateVariants({ size }); return ; } ); EmptyStateDescription.displayName = 'EmptyState.Description'; /** Slot below the header, for actions such as a primary button. */ const EmptyStateContent = forwardRef( ({ className, ...props }, ref) => { const { content } = emptyStateVariants(); return ; } ); EmptyStateContent.displayName = 'EmptyState.Content'; export const EmptyState = Object.assign(EmptyStateRoot, { Header: EmptyStateHeader, Media: EmptyStateMedia, Title: EmptyStateTitle, Description: EmptyStateDescription, Content: EmptyStateContent, });