diff --git a/packages/@react-spectrum/s2/src/HorizontalCard.tsx b/packages/@react-spectrum/s2/src/HorizontalCard.tsx new file mode 100644 index 00000000000..e8f798d884c --- /dev/null +++ b/packages/@react-spectrum/s2/src/HorizontalCard.tsx @@ -0,0 +1,975 @@ +/* + * Copyright 2024 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 './ActionMenu'; +import {baseColor, color, focusRing, lightDark, space, style} from '../style' with {type: 'macro'}; +import {Button} from 'react-aria-components/Button'; +import {ButtonContext, LinkButtonContext} from './Button'; +import {Checkbox} from './Checkbox'; +import {composeRenderProps} from 'react-aria-components/composeRenderProps'; +import {ContentContext, FooterContext, TextContext} from './Content'; +import {ContextValue, DEFAULT_SLOT, Provider} from 'react-aria-components/slots'; +import { + controlSize, + getAllowedOverrides, + StyleProps, + UnsafeStyles +} from './style-utils' with {type: 'macro'}; +import {createContext, forwardRef, ReactNode, useContext, useRef} from 'react'; +import CrossIcon from '../ui-icons/Cross'; +import { + DOMProps, + DOMRef, + DOMRefValue, + FocusableRefValue, + GlobalDOMAttributes +} from '@react-types/shared'; +import {filterDOMProps} from 'react-aria/filterDOMProps'; +import {GridListItem, GridListItemProps} from 'react-aria-components/GridList'; +import {ImageContext} from './Image'; +import {ImageCoordinator} from './ImageCoordinator'; +import {inertValue} from 'react-aria/private/utils/inertValue'; +// @ts-ignore +import intlMessages from '../intl/*.json'; +import {Link} from 'react-aria-components/Link'; +import {mergeStyles} from '../style/runtime'; +import {pressScale} from './pressScale'; +import {SkeletonContext, useIsSkeleton} from './Skeleton'; +import {Tag, TagGroup, TagList} from 'react-aria-components/TagGroup'; +import {useDOMRef} from './useDOMRef'; +import {useFocusableRef} from './useDOMRef'; +import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter'; +import {useSpectrumContextProps} from './useSpectrumContextProps'; + +interface CardRenderProps { + /** The size of the Card. */ + size: 'XS' | 'S' | 'M' | 'L' | 'XL'; +} + +export interface CardProps + extends + Omit< + GridListItemProps, + | 'className' + | 'style' + | 'render' + | 'children' + | 'onHoverChange' + | 'onHoverStart' + | 'onHoverEnd' + | 'onClick' + | keyof GlobalDOMAttributes + >, + StyleProps { + /** The children of the Card. */ + children: ReactNode | ((renderProps: CardRenderProps) => 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' | 'quiet'; +} + +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? +// 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, + '--s2-container-bg': { + type: 'backgroundColor', + value: { + variant: { + primary: 'elevated', + secondary: 'layer-1', + basic: 'layer-2' + }, + forcedColors: 'ButtonFace' + } + }, + backgroundColor: { + default: '--s2-container-bg', + variant: { + tertiary: 'transparent', + quiet: 'transparent' + } + }, + // TODO: No box shadow for basic, secondary, dark + // also none for basic tertiary + boxShadow: { + default: 'emphasized', + isHovered: 'elevated', + isFocusVisible: 'elevated', + isSelected: 'elevated', + forcedColors: '[0 0 0 1px var(--hcm-buttonborder, ButtonBorder)]', + variant: { + tertiary: { + // Render border with box-shadow to avoid affecting layout. + default: `[0 0 0 2px ${color('gray-100')}]`, + isHovered: `[0 0 0 2px ${color('gray-200')}]`, + isFocusVisible: `[0 0 0 2px ${color('gray-200')}]`, + isSelected: 'none', + forcedColors: '[0 0 0 2px var(--hcm-buttonborder, ButtonBorder)]' + }, + quiet: 'none' + } + }, + 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: 68, + isCardView: 'full', + [onlyPreview]: 'auto' + }, + 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 + } + }, + '--card-padding-y': { + type: 'paddingTop', + value: { + default: '--card-spacing', + variant: { + quiet: 0 + } + } + }, + '--card-padding-x': { + type: 'paddingStart', + value: { + default: '--card-spacing', + variant: { + quiet: 0 + } + } + }, + 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' + } + } + }, + getAllowedOverrides() +); + +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' +}); +export const CardContext = + createContext, DOMRefValue>>(null); + +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: CardProps & {isBasic?: boolean}, + ref: DOMRef +) { + [props] = useSpectrumContextProps(props, ref, CardContext); + let {ElementType} = useContext(InternalCardViewContext); + let domRef = useDOMRef(ref); + let { + isBasic = false, + density = 'regular', + size = 'M', + variant = 'primary', + UNSAFE_className = '', + UNSAFE_style, + styles, + id, + ...otherProps + } = props; + let isQuiet = variant === 'quiet'; + let isSkeleton = useIsSkeleton(); + let children = ( + + + {typeof props.children === 'function' ? props.children({size}) : props.children} + + + ); + + let press = pressScale(domRef, UNSAFE_style); + 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 ( + + UNSAFE_className + + card( + {...renderProps, size, density, variant, isBasic, isCardView: false, isLink: true}, + styles + ) + } + style={renderProps => + // Only the preview in quiet cards scales down on press + variant === 'quiet' ? UNSAFE_style : press(renderProps) + }> + {renderProps => ( + + {children} + + )} + + ); + } + + if (ElementType === 'div' || isSkeleton) { + return ( +
+ + {children} + +
+ ); + } + + return ( + + UNSAFE_className + + card( + {...renderProps, isCardView: true, isLink: !!props.href, size, density, variant, isBasic}, + styles + ) + } + style={renderProps => + // Only the preview in quiet cards scales down on press + variant === 'quiet' ? UNSAFE_style : 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 UnsafeStyles, DOMProps { + children: ReactNode; +} + +export const CardPreview = forwardRef(function CardPreview( + props: CardPreviewProps, + ref: DOMRef +) { + let {size, isQuiet, isHovered, isFocusVisible, isSelected, isPressed, isCheckboxSelection} = + useContext(InternalCardContext); + let {UNSAFE_className = '', UNSAFE_style} = props; + let domRef = useDOMRef(ref); + return ( +
+ {isQuiet && } + {isQuiet && isCheckboxSelection && } +
+ {props.children} +
+
+ ); +}); + +const collection = style({ + display: 'grid', + gridTemplateColumns: 'repeat(3, 1fr)', + gap: { + default: 4, + size: { + XS: 2, + S: 2 + } + } +}); + +const collectionImage = style({ + width: 'full', + aspectRatio: { + default: 'square', + ':nth-last-child(4):first-child': '3/2' + }, + 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: CardProps, + ref: DOMRef +) { + let {size = 'M'} = props; + return ( + + {composeRenderProps(props.children, children => ( + + {children} + + ))} + + ); +}); + +export const BasicHorizontalCard = forwardRef(function BasicHorizontalCard( + props: CardProps, + ref: DOMRef +) { + let {size = 'M'} = props; + return ( + + {composeRenderProps(props.children, children => ( + + {children} + + ))} + + ); +}); + +const hoverBackground = { + default: 'gray-200', + isStaticColor: 'transparent-overlay-200' +} as const; + +const styles = style<{ + isDisabled: boolean; + isHovered: boolean; + isFocusVisible: boolean; + isPressed: boolean; + size: 'S' | 'M' | 'L' | 'XL'; +}>( + { + ...focusRing(), + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + size: controlSize(), + flexShrink: 0, + borderRadius: 'full', + padding: 0, + borderStyle: 'none', + transition: 'default', + backgroundColor: { + default: 'gray-200', + isHovered: hoverBackground, + isFocusVisible: hoverBackground, + isPressed: hoverBackground + }, + '--iconPrimary': { + type: 'color', + value: { + default: baseColor('neutral'), + isDisabled: 'disabled', + forcedColors: { + default: 'ButtonText', + isDisabled: 'GrayText' + } + } + }, + outlineColor: { + default: 'focus-ring', + forcedColors: 'Highlight' + }, + disableTapHighlight: true + }, + getAllowedOverrides() +); +const CloseButton = function CloseButton(props) { + let ref = useRef>(null); + let domRef = useFocusableRef(ref); + let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/s2'); + return ( + + ); +}; + +let assetListStyles = style({}, getAllowedOverrides()); + +export const AssetList = forwardRef(function AssetList(props: any, ref: DOMRef) { + let domRef = useDOMRef(ref); + return ( + + + {props.children} + + + ); +}); + +export const Asset = forwardRef(function Asset(props: CardProps, ref: DOMRef) { + let domRef = useDOMRef(ref); + return ( + + {props.children} + {/** Definitely not a close button, though looks like one. */} +
+ +
+
+ ); +}); diff --git a/packages/@react-spectrum/s2/stories/Card.stories.tsx b/packages/@react-spectrum/s2/stories/Card.stories.tsx index f57e55ca208..e866b279c0c 100644 --- a/packages/@react-spectrum/s2/stories/Card.stories.tsx +++ b/packages/@react-spectrum/s2/stories/Card.stories.tsx @@ -10,8 +10,9 @@ * governing permissions and limitations under the License. */ -import {ActionMenu} from '../src/ActionMenu'; +import {ActionButton} from '../src/ActionButton'; +import {ActionMenu} from '../src/ActionMenu'; import { AssetCard, Card, @@ -22,9 +23,17 @@ import { UserCard } from '../src/Card'; +import { + Asset as AssetComponent, + AssetList, + BasicHorizontalCard, + HorizontalCard, + CardPreview as HorizontalCardPreview +} from '../src/HorizontalCard'; import {Avatar} from '../src/Avatar'; import {Badge} from '../src/Badge'; import {Button} from '../src/Button'; +import ChevronRight from '../s2wf-icons/S2_Icon_ChevronRight_20_N.svg'; import {Content, Footer, Text} from '../src/Content'; import {Divider} from '../src/Divider'; import Folder from '../s2wf-icons/S2_Icon_Folder_20_N.svg'; @@ -457,3 +466,132 @@ export const Custom: Story = { } } }; + +export const Horizontal: Story = { + render: args => ( +
+ + + + + + + Card title + + Card description. Give a concise overview of the context or functionality that's + mentioned in the card title. + + + + + + + + + + + Card title + + + Card description. Give a concise overview of the context or functionality that's + mentioned in the card title. + + + + + + + Card title + Card description. + +
+ + Test + +
+
+ + + + Card title + Card description. + +
+ + + +
+
+ + + + Card title + Card description. + + + + + + Card title + Card description. + + + + + +
+ ) +}; + +export const AIAssetList: Story = { + render: args => ( + + + + + + + + + + + + ) +};