/* * Copyright 2026 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software distributed under * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ import {ActionMenuContext} from '@react-spectrum/s2/ActionMenu'; import {ButtonContext} from '@react-spectrum/s2/Button'; import {Checkbox} from '@react-spectrum/s2/Checkbox'; import { color, focusRing, lightDark, space, style } from '@react-spectrum/s2/style' with {type: 'macro'}; import {composeRenderProps} from 'react-aria-components/composeRenderProps'; import {ContentContext} from '@react-spectrum/s2/Content'; import {createContext, forwardRef, ReactNode, useContext} from 'react'; import {DEFAULT_SLOT, Provider} from 'react-aria-components/slots'; import {DOMProps, DOMRef, GlobalDOMAttributes} from '@react-types/shared'; import {filterDOMProps} from 'react-aria/filterDOMProps'; import {FooterContext} from '@react-spectrum/s2/Footer'; import {GridListItem, GridListItemProps} from 'react-aria-components/GridList'; import {ImageContext} from '@react-spectrum/s2/Image'; import {ImageCoordinator} from '@react-spectrum/s2/ImageCoordinator'; import {inertValue} from 'react-aria/private/utils/inertValue'; import {Link} from 'react-aria-components/Link'; import {LinkButtonContext} from '@react-spectrum/s2/LinkButton'; import {mergeStyles} from '@react-spectrum/s2/mergeStyles'; import {pressScale} from '@react-spectrum/s2/pressScale'; import {SkeletonContext, useIsSkeleton} from '@react-spectrum/s2/Skeleton'; import {StyleString} from '@react-spectrum/s2/style' with {type: 'macro'}; import {TextContext} from '@react-spectrum/s2/Text'; import {useDOMRef} from './useDOMRef'; interface HorizontalCardRenderProps { /** The size of the Card. */ size: 'XS' | 'S' | 'M' | 'L' | 'XL'; } export interface HorizontalCardProps extends Omit< GridListItemProps, | 'className' | 'style' | 'render' | 'children' | 'onHoverChange' | 'onHoverStart' | 'onHoverEnd' | 'onClick' | keyof GlobalDOMAttributes > { /** The children of the Card. */ children: ReactNode | ((renderProps: HorizontalCardRenderProps) => ReactNode); /** * The size of the Card. * * @default 'M' */ size?: 'XS' | 'S' | 'M' | 'L' | 'XL'; /** * The amount of internal padding within the Card. * * @default 'regular' */ density?: 'compact' | 'regular' | 'spacious'; /** * The visual style of the Card. * * @default 'primary' */ variant?: 'primary' | 'secondary' | 'tertiary'; /** * Spectrum-defined styles, returned by the `style()` macro. */ styles?: StyleString; } export interface BasicCardProps extends Omit { /** * The visual style of the Card. * * @default 'primary' */ variant?: 'primary' | 'secondary' | 'tertiary' | 'quiet'; /** Whether the card is in an error state. */ isInvalid?: boolean; } const borderRadius = { default: 'lg', size: { XS: 'default', S: 'default' }, isBasic: 'default' } as const; // Figma missing a lot of combinations of variant, tshirt, density // Quiet Basic cards? // Does Basic not participate in selection? (It does, but it's denoted by the border...) // Why is there a flipped horizontal card? // Max width on contents for horizontal cards? Doesn't appear to be one that includes the preview because the preview can have any ratio and that // causes the width grow. // (Max) height on cards? Maybe that makes more sense. const onlyPreview = ':not(:has([data-slot=content])):not(:has([data-slot=preview]))'; let card = style({ display: 'flex', flexDirection: 'row', position: 'relative', borderRadius, backgroundColor: { default: lightDark('transparent-white-300', 'transparent-black-300'), forcedColors: 'ButtonFace' }, // TODO: waiting for design to investigate thumbnail card/attachement error state boxShadow: { default: `[inset 0 0 0 1px light-dark(${color('transparent-black-300')}, ${color('transparent-white-300')})]`, isInvalid: `[inset 0 0 0 1px ${color('negative-900')}]` }, forcedColorAdjust: 'none', transition: 'default', fontFamily: 'sans', textDecoration: 'none', overflow: { default: 'clip', variant: { quiet: 'visible' } }, contain: 'layout', disableTapHighlight: true, userSelect: { isCardView: 'none' }, cursor: { isLink: 'pointer' }, height: { default: { size: { XS: 160, S: 180, M: 200, L: 220, XL: 240 } }, isBasic: { default: 68, size: { XS: 52, S: 60, M: 68, L: 76, XL: 80 } }, isCardView: 'full' }, width: { default: 'full', [onlyPreview]: 'auto' }, aspectRatio: { [onlyPreview]: '1/1' }, '--card-spacing': { type: 'paddingTop', value: { density: { compact: { size: { XS: '[6px]', S: 8, M: 12, L: 16, XL: 20 } }, regular: { size: { XS: 8, S: 12, M: 16, L: 20, XL: 24 } }, spacious: { size: { XS: 12, S: 16, M: 20, L: 24, XL: 28 } } }, [onlyPreview]: 0 } }, alignItems: { isBasic: 'center' }, '--card-padding-y': { type: 'paddingTop', value: { default: '--card-spacing' } }, '--card-padding-x': { type: 'paddingStart', value: { default: '--card-spacing' } }, paddingY: '--card-padding-y', paddingX: '--card-padding-x', boxSizing: 'border-box', ...focusRing(), outlineStyle: { default: 'none', isFocusVisible: 'solid', // Focus ring moves to preview when quiet. variant: { quiet: 'none' } }, '--basic-thumb-size': { type: 'height', value: { default: 68, size: { XS: 24, S: 26, M: 32, L: 36, XL: 40 }, [onlyPreview]: 'full' } } }); let selectionIndicator = style({ position: 'absolute', inset: 0, zIndex: 2, borderRadius, pointerEvents: 'none', borderWidth: 2, borderStyle: 'solid', borderColor: 'gray-1000', transition: 'default', opacity: { default: 0, isSelected: 1 }, // Quiet cards with no checkbox have an extra inner stroke // to distinguish the selection indicator from the preview. outlineColor: lightDark('transparent-white-600', 'transparent-black-600'), outlineOffset: -4, outlineStyle: { default: 'none', isStrokeInner: 'solid' }, outlineWidth: 2 }); let preview = style({ position: 'relative', transition: 'default', overflow: 'clip', marginY: 'calc(var(--card-padding-y) * -1)', marginStart: 'calc(var(--card-padding-x) * -1)', marginEnd: { ':last-child': 'calc(var(--card-padding-x) * -1)' }, borderRadius: { isQuiet: borderRadius }, boxShadow: { isQuiet: { isHovered: 'elevated', isFocusVisible: 'elevated', isSelected: 'elevated' } }, ...focusRing(), outlineStyle: { default: 'none', isQuiet: { isFocusVisible: 'solid' } } }); const image = style({ height: 'full', aspectRatio: '1/1', objectFit: 'cover', userSelect: 'none', pointerEvents: 'none' }); let title = style({ font: 'title', fontSize: { size: { XS: 'title-xs', S: 'title-xs', M: 'title-sm', L: 'title', XL: 'title-lg' } }, lineClamp: 3, gridArea: 'title' }); let description = style({ font: 'body', fontSize: { size: { XS: 'body-2xs', S: 'body-2xs', M: 'body-xs', L: 'body-sm', XL: 'body' } }, lineClamp: 3, gridArea: 'description' }); let content = style({ display: 'grid', // By default, all elements are displayed in a stack. // If an action menu is present, place it next to the title. gridTemplateColumns: { default: ['1fr'], ':has([data-slot=menu])': ['minmax(0, 1fr)', 'auto'] }, gridTemplateAreas: { default: ['title', 'description'], ':has([data-slot=menu])': ['title menu', 'description description'] }, columnGap: 4, flexGrow: 1, alignItems: 'baseline', alignContent: 'start', rowGap: { size: { XS: 4, S: 4, M: space(6), L: space(6), XL: 8 } }, paddingStart: { default: '--card-spacing', ':first-child': 0 }, paddingEnd: { default: 'calc(var(--card-spacing) * 1.5 / 2)', ':last-child': 0 } }); let actionMenu = style({ gridArea: 'menu', // Don't cause the row to expand, preserve gap between title and description text. // Would use -100% here but it doesn't work in Firefox. marginY: 'calc(-1 * self(height))' }); let footer = style({ display: 'flex', flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 8 }); export const InternalCardViewContext = createContext({ ElementType: 'div' as 'div' | typeof GridListItem, layout: 'grid' as 'grid' | 'waterfall' }); interface InternalCardContextValue { isQuiet: boolean; size: 'XS' | 'S' | 'M' | 'L' | 'XL'; isSelected: boolean; isHovered: boolean; isFocusVisible: boolean; isPressed: boolean; isCheckboxSelection: boolean; } const InternalCardContext = createContext({ isQuiet: false, size: 'M', isSelected: false, isHovered: false, isFocusVisible: false, isPressed: false, isCheckboxSelection: true }); const actionButtonSize = { XS: 'XS', S: 'XS', M: 'S', L: 'M', XL: 'L' } as const; const Card = forwardRef(function Card( props: Omit & { isBasic?: boolean; isInvalid?: boolean; variant?: 'primary' | 'secondary' | 'tertiary' | 'quiet'; }, ref: DOMRef ) { let {ElementType} = useContext(InternalCardViewContext); let domRef = useDOMRef(ref); let { isBasic = false, isInvalid = false, density = 'regular', size = 'M', variant = 'primary', styles, id, ...otherProps } = props; let isQuiet = variant === 'quiet'; let isSkeleton = useIsSkeleton(); let children = ( {typeof props.children === 'function' ? props.children({size}) : props.children} ); // oxlint-disable-next-line react/react-compiler let press = pressScale(domRef); if (ElementType === 'div' && !isSkeleton && props.href) { // Standalone Card that has an href should be rendered as a Link. // NOTE: In this case, the card must not contain interactive elements. return ( mergeStyles( card({ ...renderProps, size, density, variant, isBasic, isInvalid, isCardView: false, isLink: true }), styles ) } style={renderProps => // Only the preview in quiet cards scales down on press variant === 'quiet' ? undefined : press(renderProps) }> {renderProps => ( {children} )} ); } if (ElementType === 'div' || isSkeleton) { return (
{children}
); } return ( mergeStyles( card({ ...renderProps, isCardView: true, isLink: !!props.href, size, density, variant, isBasic, isInvalid }), styles ) } style={renderProps => // Only the preview in quiet cards scales down on press variant === 'quiet' ? undefined : press(renderProps) }> {({selectionMode, selectionBehavior, isHovered, isFocusVisible, isSelected, isPressed}) => ( {/* Selection indicator and checkbox move inside the preview for quiet cards */} {!isQuiet && } {!isQuiet && selectionMode !== 'none' && selectionBehavior === 'toggle' && ( )} {/* this makes the :first-child selector work even with the checkbox */}
{children}
)}
); }); function SelectionIndicator() { let {size, isSelected, isQuiet, isCheckboxSelection} = useContext(InternalCardContext); return (
); } function CardCheckbox() { let {size} = useContext(InternalCardContext); return (
); } export interface CardPreviewProps extends DOMProps { children: ReactNode; /** * Spectrum-defined styles, returned by the `style()` macro. */ styles?: StyleString; } // TODO: this should be the same component as the one in @react-spectrum/s2/Card export const CardPreview = forwardRef(function CardPreview( props: CardPreviewProps, ref: DOMRef ) { let {size, isQuiet, isHovered, isFocusVisible, isSelected, isPressed, isCheckboxSelection} = useContext(InternalCardContext); let domRef = useDOMRef(ref); // oxlint-disable react/react-compiler return (
{isQuiet && } {isQuiet && isCheckboxSelection && }
{props.children}
); // oxlint-enable react/react-compiler }); const collection = style({ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: { default: 4, size: { XS: 2, S: 2 } } }); const collectionImage = style({ width: 'full', gridColumnEnd: { ':nth-last-child(4):first-child': 'span 3' }, objectFit: 'cover', pointerEvents: 'none', userSelect: 'none' }); export const CollectionCardPreview = forwardRef(function CollectionCardPreview( props: CardPreviewProps, ref: DOMRef ) { let {size} = useContext(InternalCardContext)!; return (
{props.children}
); }); const buttonSize = { XS: 'S', S: 'S', M: 'M', L: 'L', XL: 'XL' } as const; export const HorizontalCard = forwardRef(function HorizontalCard( props: HorizontalCardProps, ref: DOMRef ) { let {size = 'M'} = props; return ( {composeRenderProps(props.children, children => ( {children} ))} ); }); export const BasicHorizontalCard = forwardRef(function BasicHorizontalCard( props: BasicCardProps, ref: DOMRef ) { let {size = 'M'} = props; return ( {composeRenderProps(props.children, children => ( {children} ))} ); });