import { forwardRef, useMemo, type ReactNode } from 'react'; import { Text, View, type TextProps, type ViewProps } from 'react-native'; import { Slot, TileGroup as TileGroupPrimitive, TileRoot as TileRootPrimitive, dataAttributes, mergeDataAttributes, useTileContext, type ITileGroupMultipleProps, type ITileGroupSingleProps, type ITileIndicatorProps, type ITileLeadingSlotProps, type ITileProps, type ITileTrailingSlotProps, } from '@cdx-ui/primitives'; import { Check } from '@cdx-ui/icons'; import { cn, ParentContext, useParentContext, useStyleContext } from '@cdx-ui/utils'; import { checkboxIconVariants, checkboxIndicatorVariants } from '../Checkbox/styles'; import { Icon } from '../Icon'; import { radioIndicatorVariants, radioInnerDotVariants } from '../Radio/styles'; import { tileContentVariants, tileDescriptionVariants, tileGroupVariants, tileIndicatorVariants, tileLeadingSlotVariants, tileRootVariants, tileTitleVariants, tileTrailingSlotVariants, type TileGroupVariantProps, type TileVariantProps, } from './styles'; const TILE_SCOPE = 'TILE'; const TILE_GROUP_SCOPE = 'TILE_GROUP'; interface TileGroupStyleContext { shape?: TileVariantProps['shape']; } const useTileGroupStyleContext = (): TileGroupStyleContext => { // `useStyleContext` types the result as a `Record`, but at runtime the value is // `undefined` when no provider is in scope (e.g. standalone Tile). Cast through // `unknown` to acknowledge that. const ctx = useStyleContext(TILE_GROUP_SCOPE) as unknown as TileGroupStyleContext | undefined; return ctx ?? {}; }; // ============================================================================= // TILE GROUP // ============================================================================= /** * Discriminated union mirroring `ITileGroupProps`, with `className` and layout variants added at * the styled layer. Distributing the intersection across each branch keeps `interface … extends` * ergonomics and avoids tripping `@typescript-eslint` on `(union) & { ... }`. * * `direction` and `spacing` constrain the group layout to the three supported patterns: * - `direction="column"` + `spacing="default"` — vertical stack with gaps (default). * - `direction="column"` + `spacing="none"` — tight vertical stack (rows touch). * - `direction="row"` + `spacing="default"` — side-by-side horizontal layout. * * `shape` (when set) is propagated to child `Tile`s via `ParentContext`, matching the * Avatar size-propagation pattern. A per-tile `shape` prop overrides the inherited value. */ export interface TileGroupSingleProps extends ITileGroupSingleProps, TileGroupVariantProps { className?: string; /** Visual shape inherited by child `Tile`s. Per-tile `shape` overrides this. */ shape?: TileVariantProps['shape']; } export interface TileGroupMultipleProps extends ITileGroupMultipleProps, TileGroupVariantProps { className?: string; /** Visual shape inherited by child `Tile`s. Per-tile `shape` overrides this. */ shape?: TileVariantProps['shape']; } export type TileGroupProps = TileGroupSingleProps | TileGroupMultipleProps; const TileGroup = forwardRef( ({ className, style, children, direction, spacing, shape, ...props }, ref) => { const parent = useParentContext(); const ctx = useMemo(() => ({ ...parent, [TILE_GROUP_SCOPE]: { shape } }), [parent, shape]); return ( {children} ); }, ); TileGroup.displayName = 'Tile.Group'; // ============================================================================= // TILE ROOT // ============================================================================= export interface TileProps extends ITileProps, TileVariantProps { className?: string; } const TileRoot = forwardRef( ({ shape: shapeProp, showSeparator, className, style, ...props }, ref) => { const { shape: inheritedShape } = useTileGroupStyleContext(); const shape = shapeProp ?? inheritedShape ?? 'card'; const computedClassName = cn(tileRootVariants({ shape, showSeparator }), className); const parent = useParentContext(); const ctx = useMemo(() => ({ ...parent, [TILE_SCOPE]: { shape } }), [parent, shape]); return ( ); }, ); TileRoot.displayName = 'Tile'; // ============================================================================= // SLOTS // ============================================================================= const leadingSlotStyle = { flexShrink: 0 as const }; export interface TileLeadingSlotProps extends ITileLeadingSlotProps { asChild?: boolean; className?: string; } const TileLeadingSlot = forwardRef( ({ asChild, className, style, 'aria-hidden': ariaHidden, ...props }, ref) => { const accessibilityElementsHidden = ariaHidden !== false; const Comp = asChild ? Slot : View; return ( ); }, ); TileLeadingSlot.displayName = 'Tile.LeadingSlot'; const contentStyle = { flex: 1, flexDirection: 'column' as const, minWidth: 0, }; export interface TileContentProps extends ViewProps { asChild?: boolean; className?: string; } const TileContent = forwardRef( ({ asChild, className, style, ...props }, ref) => { const Comp = asChild ? Slot : View; return ( ); }, ); TileContent.displayName = 'Tile.Content'; const noUnderlineStyle = { textDecorationLine: 'none' as const }; export interface TileTitleProps extends TextProps { asChild?: boolean; className?: string; } const TileTitle = forwardRef( ({ asChild, className, style, numberOfLines = 1, ...props }, ref) => { const Comp = asChild ? Slot : Text; return ( ); }, ); TileTitle.displayName = 'Tile.Title'; export interface TileDescriptionProps extends TextProps { asChild?: boolean; className?: string; } const TileDescription = forwardRef( ({ asChild, className, style, ...props }, ref) => { const Comp = asChild ? Slot : Text; return ( ); }, ); TileDescription.displayName = 'Tile.Description'; /** * Decorative selection affordance. Renders pure CVA-styled Views — no interactive hooks, * no nested Pressable, zero runtime overhead. The Tile root owns the press target and * ARIA role; this subtree is `aria-hidden`. * * The visual is inferred from the tile context: `single` (radio semantics) → radio ring, * `multiple` (checkbox semantics) → checkbox square. An explicit `indicatorType` prop * overrides — primarily for standalone tiles that want a radio-style visual. */ function TileIndicatorVisual({ indicatorType }: { indicatorType?: 'radio' | 'checkbox' }) { const { isSelected, isDisabled, selectionType } = useTileContext(); const effectiveType: 'radio' | 'checkbox' = indicatorType ?? (selectionType === 'single' ? 'radio' : 'checkbox'); const stateAttrs = dataAttributes({ checked: isSelected, disabled: isDisabled }); if (effectiveType === 'radio') { return ( ); } return ( {isSelected && } ); } const indicatorStyle = { flexShrink: 0 as const, pointerEvents: 'none' as const }; export interface TileIndicatorProps extends ITileIndicatorProps { asChild?: boolean; className?: string; children?: ReactNode; } const TileIndicator = forwardRef( ({ asChild, className, children, style, indicatorType, ...props }, ref) => { const Comp = asChild ? Slot : View; return ( {children ?? } ); }, ); TileIndicator.displayName = 'Tile.Indicator'; const trailingSlotStyle = { flexShrink: 0 as const }; export interface TileTrailingSlotProps extends ITileTrailingSlotProps { asChild?: boolean; className?: string; } const TileTrailingSlot = forwardRef( ({ asChild, className, style, ...props }, ref) => { const Comp = asChild ? Slot : View; return ( ); }, ); TileTrailingSlot.displayName = 'Tile.TrailingSlot'; type TileCompound = typeof TileRoot & { Group: typeof TileGroup; LeadingSlot: typeof TileLeadingSlot; Content: typeof TileContent; Title: typeof TileTitle; Description: typeof TileDescription; Indicator: typeof TileIndicator; TrailingSlot: typeof TileTrailingSlot; }; export const Tile = Object.assign(TileRoot, { Group: TileGroup, LeadingSlot: TileLeadingSlot, Content: TileContent, Title: TileTitle, Description: TileDescription, Indicator: TileIndicator, TrailingSlot: TileTrailingSlot, }) as TileCompound; export { useTileContext };