From c65e87b1352405d33856767fb39f6ac7f2ab99af Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Thu, 7 May 2026 15:06:59 +1000 Subject: [PATCH 1/7] AI components, starting with cards --- .../@react-spectrum/s2/src/HorizontalCard.tsx | 742 ++++++++++++++++++ .../s2/stories/Card.stories.tsx | 82 ++ 2 files changed, 824 insertions(+) create mode 100644 packages/@react-spectrum/s2/src/HorizontalCard.tsx diff --git a/packages/@react-spectrum/s2/src/HorizontalCard.tsx b/packages/@react-spectrum/s2/src/HorizontalCard.tsx new file mode 100644 index 00000000000..2ccfa09bdb2 --- /dev/null +++ b/packages/@react-spectrum/s2/src/HorizontalCard.tsx @@ -0,0 +1,742 @@ +/* + * 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 {AvatarContext} from './Avatar'; +import {ButtonContext, LinkButtonContext} from './Button'; +import {Checkbox} from './Checkbox'; +import {color, focusRing, lightDark, space, style} from '../style' with {type: 'macro'}; +import {composeRenderProps} from 'react-aria-components/composeRenderProps'; +import {ContentContext, FooterContext, TextContext} from './Content'; +import {ContextValue, DEFAULT_SLOT, Provider} from 'react-aria-components/slots'; +import {createContext, CSSProperties, forwardRef, ReactNode, useContext} from 'react'; +import {DividerContext} from './Divider'; +import {DOMProps, DOMRef, DOMRefValue, GlobalDOMAttributes} from '@react-types/shared'; +import {filterDOMProps} from 'react-aria/filterDOMProps'; +import {getAllowedOverrides, StyleProps, UnsafeStyles} from './style-utils' with {type: 'macro'}; +import {GridListItem, GridListItemProps} from 'react-aria-components/GridList'; +import {IllustrationContext} from './Icon'; +import {ImageContext} from './Image'; +import {ImageCoordinator} from './ImageCoordinator'; +import {inertValue} from 'react-aria/private/utils/inertValue'; +import {Link} from 'react-aria-components/Link'; +import {mergeStyles} from '../style/runtime'; +import {pressScale} from './pressScale'; +import {SkeletonContext, SkeletonWrapper, useIsSkeleton} from './Skeleton'; +import {useDOMRef} from './useDOMRef'; +import {useSpectrumContextProps} from './useSpectrumContextProps'; + +interface CardRenderProps { + /** The size of the Card. */ + size: 'XS' | 'S' | 'M' | 'L' | 'XL' +} + +export interface CardProps extends Omit, 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. + +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' + }, + width: 'full', + '--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 + } + } + } + } + }, + '--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, layout} = 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} + + ))} + + ); +}); diff --git a/packages/@react-spectrum/s2/stories/Card.stories.tsx b/packages/@react-spectrum/s2/stories/Card.stories.tsx index 2dec1d6b5da..391e347ddbb 100644 --- a/packages/@react-spectrum/s2/stories/Card.stories.tsx +++ b/packages/@react-spectrum/s2/stories/Card.stories.tsx @@ -21,10 +21,16 @@ import { ProductCard, UserCard } from '../src/Card'; +import { + 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'; @@ -38,6 +44,7 @@ import Select from '../s2wf-icons/S2_Icon_Select_20_N.svg'; import {Skeleton} from '../src/Skeleton'; import {StatusLight} from '../src/StatusLight'; import {style} from '../style/spectrum-theme' with {type: 'macro'}; +import { ActionButton } from '../src/ActionButton'; const meta: Meta = { component: Card, @@ -342,3 +349,78 @@ 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. + + +
+ ) +}; From 5f4203ab805cdb6606fc708ca0a283f922787691 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Thu, 7 May 2026 15:10:37 +1000 Subject: [PATCH 2/7] fix lint --- packages/@react-spectrum/s2/src/HorizontalCard.tsx | 10 ++++------ packages/@react-spectrum/s2/stories/Card.stories.tsx | 12 ++++++------ 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/packages/@react-spectrum/s2/src/HorizontalCard.tsx b/packages/@react-spectrum/s2/src/HorizontalCard.tsx index 2ccfa09bdb2..49f1673cf9e 100644 --- a/packages/@react-spectrum/s2/src/HorizontalCard.tsx +++ b/packages/@react-spectrum/s2/src/HorizontalCard.tsx @@ -11,27 +11,25 @@ */ import {ActionMenuContext} from './ActionMenu'; -import {AvatarContext} from './Avatar'; import {ButtonContext, LinkButtonContext} from './Button'; import {Checkbox} from './Checkbox'; import {color, focusRing, lightDark, space, style} from '../style' with {type: 'macro'}; import {composeRenderProps} from 'react-aria-components/composeRenderProps'; import {ContentContext, FooterContext, TextContext} from './Content'; import {ContextValue, DEFAULT_SLOT, Provider} from 'react-aria-components/slots'; -import {createContext, CSSProperties, forwardRef, ReactNode, useContext} from 'react'; +import {createContext, forwardRef, ReactNode, useContext} from 'react'; import {DividerContext} from './Divider'; import {DOMProps, DOMRef, DOMRefValue, GlobalDOMAttributes} from '@react-types/shared'; import {filterDOMProps} from 'react-aria/filterDOMProps'; import {getAllowedOverrides, StyleProps, UnsafeStyles} from './style-utils' with {type: 'macro'}; import {GridListItem, GridListItemProps} from 'react-aria-components/GridList'; -import {IllustrationContext} from './Icon'; import {ImageContext} from './Image'; import {ImageCoordinator} from './ImageCoordinator'; import {inertValue} from 'react-aria/private/utils/inertValue'; import {Link} from 'react-aria-components/Link'; import {mergeStyles} from '../style/runtime'; import {pressScale} from './pressScale'; -import {SkeletonContext, SkeletonWrapper, useIsSkeleton} from './Skeleton'; +import {SkeletonContext, useIsSkeleton} from './Skeleton'; import {useDOMRef} from './useDOMRef'; import {useSpectrumContextProps} from './useSpectrumContextProps'; @@ -146,7 +144,7 @@ let card = style({ M: 200, L: 220, XL: 240 - }, + } }, isBasic: 68, isCardView: 'full' @@ -401,7 +399,7 @@ const actionButtonSize = { const Card = forwardRef(function Card(props: CardProps & {isBasic?: boolean}, ref: DOMRef) { [props] = useSpectrumContextProps(props, ref, CardContext); - let {ElementType, layout} = useContext(InternalCardViewContext); + 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'; diff --git a/packages/@react-spectrum/s2/stories/Card.stories.tsx b/packages/@react-spectrum/s2/stories/Card.stories.tsx index 391e347ddbb..5a25b5a2a85 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, @@ -21,14 +22,14 @@ import { ProductCard, UserCard } from '../src/Card'; + +import {Avatar} from '../src/Avatar'; +import {Badge} from '../src/Badge'; import { 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'; @@ -44,7 +45,6 @@ import Select from '../s2wf-icons/S2_Icon_Select_20_N.svg'; import {Skeleton} from '../src/Skeleton'; import {StatusLight} from '../src/StatusLight'; import {style} from '../style/spectrum-theme' with {type: 'macro'}; -import { ActionButton } from '../src/ActionButton'; const meta: Meta = { component: Card, @@ -379,7 +379,7 @@ export const Horizontal: Story = { - Card title + Card title Card description. Give a concise overview of the context or functionality that's mentioned in the card title. From 95d50f09889291f0188d20fafca248e17d4a8d9a Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Thu, 7 May 2026 15:55:36 +1000 Subject: [PATCH 3/7] Add asset removable button and sort of variants --- .../@react-spectrum/s2/src/HorizontalCard.tsx | 103 ++++++++++++++++-- .../s2/stories/Card.stories.tsx | 17 ++- 2 files changed, 108 insertions(+), 12 deletions(-) diff --git a/packages/@react-spectrum/s2/src/HorizontalCard.tsx b/packages/@react-spectrum/s2/src/HorizontalCard.tsx index 49f1673cf9e..d0e031eb144 100644 --- a/packages/@react-spectrum/s2/src/HorizontalCard.tsx +++ b/packages/@react-spectrum/s2/src/HorizontalCard.tsx @@ -13,15 +13,15 @@ import {ActionMenuContext} from './ActionMenu'; import {ButtonContext, LinkButtonContext} from './Button'; import {Checkbox} from './Checkbox'; -import {color, focusRing, lightDark, space, style} from '../style' with {type: 'macro'}; +import {baseColor, color, focusRing, lightDark, space, style} from '../style' with {type: 'macro'}; import {composeRenderProps} from 'react-aria-components/composeRenderProps'; import {ContentContext, FooterContext, TextContext} from './Content'; import {ContextValue, DEFAULT_SLOT, Provider} from 'react-aria-components/slots'; -import {createContext, forwardRef, ReactNode, useContext} from 'react'; +import {createContext, forwardRef, ReactNode, useContext, useRef} from 'react'; import {DividerContext} from './Divider'; -import {DOMProps, DOMRef, DOMRefValue, GlobalDOMAttributes} from '@react-types/shared'; +import {DOMProps, DOMRef, DOMRefValue, FocusableRefValue, GlobalDOMAttributes} from '@react-types/shared'; import {filterDOMProps} from 'react-aria/filterDOMProps'; -import {getAllowedOverrides, StyleProps, UnsafeStyles} from './style-utils' with {type: 'macro'}; +import {controlSize, getAllowedOverrides, staticColor, StyleProps, UnsafeStyles} from './style-utils' with {type: 'macro'}; import {GridListItem, GridListItemProps} from 'react-aria-components/GridList'; import {ImageContext} from './Image'; import {ImageCoordinator} from './ImageCoordinator'; @@ -33,6 +33,13 @@ import {SkeletonContext, useIsSkeleton} from './Skeleton'; import {useDOMRef} from './useDOMRef'; import {useSpectrumContextProps} from './useSpectrumContextProps'; +import {Button} from 'react-aria-components/Button'; +import CrossIcon from '../ui-icons/Cross'; +import {useFocusableRef} from './useDOMRef'; +// @ts-ignore +import intlMessages from '../intl/*.json'; +import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter'; + interface CardRenderProps { /** The size of the Card. */ size: 'XS' | 'S' | 'M' | 'L' | 'XL' @@ -75,6 +82,8 @@ const borderRadius = { // 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', @@ -126,7 +135,8 @@ let card = style({ default: 'clip', variant: { quiet: 'visible' - } + }, + isBasic: 'visible' }, contain: 'layout', disableTapHighlight: true, @@ -147,9 +157,16 @@ let card = style({ } }, isBasic: 68, - isCardView: 'full' + isCardView: 'full', + [onlyPreview]: 'auto' + }, + width: { + default: 'full', + [onlyPreview]: 'auto' + }, + aspectRatio: { + [onlyPreview]: '1/1' }, - width: 'full', '--card-spacing': { type: 'paddingTop', value: { @@ -181,7 +198,8 @@ let card = style({ XL: 28 } } - } + }, + [onlyPreview]: 0 } }, '--card-padding-y': { @@ -415,8 +433,11 @@ const Card = forwardRef(function Card(props: CardProps & {isBasic?: boolean}, re description: {styles: description({size})} } }], - [ContentContext, {styles: content({size})}], - [DividerContext, {size: 'S'}], + [ContentContext, { + styles: content({size}), + // @ts-ignore + 'data-slot': 'content' + }], [FooterContext, {styles: footer}], [ActionMenuContext, { isQuiet: true, @@ -679,8 +700,9 @@ export const HorizontalCard = forwardRef(function HorizontalCard(props: CardProp ); }); -export const BasicHorizontalCard = forwardRef(function BasicHorizontalCard(props: CardProps, ref: DOMRef) { +export const BasicHorizontalCard = forwardRef(function BasicHorizontalCard(props: CardProps & {isRemovable?: boolean}, ref: DOMRef) { let {size = 'M'} = props; + let {isRemovable = false} = props; return ( {composeRenderProps(props.children, children => ( @@ -733,8 +755,67 @@ export const BasicHorizontalCard = forwardRef(function BasicHorizontalCard(props [LinkButtonContext, {size: buttonSize[size]}] ]}> {children} + {/** definitely not a close button, though looks like one */} + {isRemovable &&
} ))}
); }); + + +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 ( + + ); +}; diff --git a/packages/@react-spectrum/s2/stories/Card.stories.tsx b/packages/@react-spectrum/s2/stories/Card.stories.tsx index 5a25b5a2a85..321a2519191 100644 --- a/packages/@react-spectrum/s2/stories/Card.stories.tsx +++ b/packages/@react-spectrum/s2/stories/Card.stories.tsx @@ -28,7 +28,8 @@ import {Badge} from '../src/Badge'; import { BasicHorizontalCard, HorizontalCard, - CardPreview as HorizontalCardPreview + CardPreview as HorizontalCardPreview, + PreviewOnlyHorizontalCard } from '../src/HorizontalCard'; import {Button} from '../src/Button'; import ChevronRight from '../s2wf-icons/S2_Icon_ChevronRight_20_N.svg'; @@ -421,6 +422,20 @@ export const Horizontal: Story = { Card description. + + + + Card title + Card description. + + + + +
) }; From 2e9e8d9f50b02f20a13d6717ad572a8ca6e8cfc0 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Thu, 7 May 2026 15:59:41 +1000 Subject: [PATCH 4/7] fix lint --- .../@react-spectrum/s2/src/HorizontalCard.tsx | 20 +++++++++---------- .../s2/stories/Card.stories.tsx | 3 +-- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/packages/@react-spectrum/s2/src/HorizontalCard.tsx b/packages/@react-spectrum/s2/src/HorizontalCard.tsx index d0e031eb144..eb741295ab2 100644 --- a/packages/@react-spectrum/s2/src/HorizontalCard.tsx +++ b/packages/@react-spectrum/s2/src/HorizontalCard.tsx @@ -11,34 +11,32 @@ */ 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 {baseColor, color, focusRing, lightDark, space, style} from '../style' with {type: 'macro'}; 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 {DividerContext} from './Divider'; +import CrossIcon from '../ui-icons/Cross'; import {DOMProps, DOMRef, DOMRefValue, FocusableRefValue, GlobalDOMAttributes} from '@react-types/shared'; import {filterDOMProps} from 'react-aria/filterDOMProps'; -import {controlSize, getAllowedOverrides, staticColor, StyleProps, UnsafeStyles} from './style-utils' with {type: 'macro'}; 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 {useDOMRef} from './useDOMRef'; -import {useSpectrumContextProps} from './useSpectrumContextProps'; - -import {Button} from 'react-aria-components/Button'; -import CrossIcon from '../ui-icons/Cross'; import {useFocusableRef} from './useDOMRef'; -// @ts-ignore -import intlMessages from '../intl/*.json'; import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter'; +import {useSpectrumContextProps} from './useSpectrumContextProps'; interface CardRenderProps { /** The size of the Card. */ @@ -755,8 +753,8 @@ export const BasicHorizontalCard = forwardRef(function BasicHorizontalCard(props [LinkButtonContext, {size: buttonSize[size]}] ]}> {children} - {/** definitely not a close button, though looks like one */} - {isRemovable &&
} + {/** Definitely not a close button, though looks like one */} + {isRemovable &&
} ))} diff --git a/packages/@react-spectrum/s2/stories/Card.stories.tsx b/packages/@react-spectrum/s2/stories/Card.stories.tsx index 321a2519191..0115bbd4b50 100644 --- a/packages/@react-spectrum/s2/stories/Card.stories.tsx +++ b/packages/@react-spectrum/s2/stories/Card.stories.tsx @@ -28,8 +28,7 @@ import {Badge} from '../src/Badge'; import { BasicHorizontalCard, HorizontalCard, - CardPreview as HorizontalCardPreview, - PreviewOnlyHorizontalCard + CardPreview as HorizontalCardPreview } from '../src/HorizontalCard'; import {Button} from '../src/Button'; import ChevronRight from '../s2wf-icons/S2_Icon_ChevronRight_20_N.svg'; From d23bac27d5ede356556b5fb38eb720297b2af27a Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Thu, 7 May 2026 17:01:47 +1000 Subject: [PATCH 5/7] Add asset list first pass --- .../@react-spectrum/s2/src/HorizontalCard.tsx | 37 ++++++++++++--- .../s2/stories/Card.stories.tsx | 46 ++++++++++++++----- 2 files changed, 65 insertions(+), 18 deletions(-) diff --git a/packages/@react-spectrum/s2/src/HorizontalCard.tsx b/packages/@react-spectrum/s2/src/HorizontalCard.tsx index eb741295ab2..448448e68cd 100644 --- a/packages/@react-spectrum/s2/src/HorizontalCard.tsx +++ b/packages/@react-spectrum/s2/src/HorizontalCard.tsx @@ -33,6 +33,7 @@ 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'; @@ -133,8 +134,7 @@ let card = style({ default: 'clip', variant: { quiet: 'visible' - }, - isBasic: 'visible' + } }, contain: 'layout', disableTapHighlight: true, @@ -698,9 +698,8 @@ export const HorizontalCard = forwardRef(function HorizontalCard(props: CardProp ); }); -export const BasicHorizontalCard = forwardRef(function BasicHorizontalCard(props: CardProps & {isRemovable?: boolean}, ref: DOMRef) { +export const BasicHorizontalCard = forwardRef(function BasicHorizontalCard(props: CardProps, ref: DOMRef) { let {size = 'M'} = props; - let {isRemovable = false} = props; return ( {composeRenderProps(props.children, children => ( @@ -753,8 +752,6 @@ export const BasicHorizontalCard = forwardRef(function BasicHorizontalCard(props [LinkButtonContext, {size: buttonSize[size]}] ]}> {children} - {/** Definitely not a close button, though looks like one */} - {isRemovable &&
} ))}
@@ -809,7 +806,7 @@ const CloseButton = function CloseButton(props) { ); }; + +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 0115bbd4b50..6a6704c1034 100644 --- a/packages/@react-spectrum/s2/stories/Card.stories.tsx +++ b/packages/@react-spectrum/s2/stories/Card.stories.tsx @@ -13,6 +13,17 @@ import {ActionButton} from '../src/ActionButton'; import {ActionMenu} from '../src/ActionMenu'; +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 { AssetCard, Card, @@ -22,15 +33,6 @@ import { ProductCard, UserCard } from '../src/Card'; - -import {Avatar} from '../src/Avatar'; -import {Badge} from '../src/Badge'; -import { - BasicHorizontalCard, - HorizontalCard, - CardPreview as HorizontalCardPreview -} from '../src/HorizontalCard'; -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'; @@ -421,7 +423,7 @@ export const Horizontal: Story = { Card description. - + @@ -430,7 +432,7 @@ export const Horizontal: Story = { Card description. - + @@ -438,3 +440,25 @@ export const Horizontal: Story = { ) }; + +export const AIAssetList: Story = { + render: (args) => ( + + + + + + + + + + + + ) +}; From f623a8f8723e4d5479b17ad7ce3bfe4a946e327a Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Fri, 8 May 2026 08:42:10 +1000 Subject: [PATCH 6/7] fix lint --- .../s2/stories/Card.stories.tsx | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/@react-spectrum/s2/stories/Card.stories.tsx b/packages/@react-spectrum/s2/stories/Card.stories.tsx index 6a6704c1034..81cd27ebae0 100644 --- a/packages/@react-spectrum/s2/stories/Card.stories.tsx +++ b/packages/@react-spectrum/s2/stories/Card.stories.tsx @@ -13,6 +13,16 @@ import {ActionButton} from '../src/ActionButton'; import {ActionMenu} from '../src/ActionMenu'; +import { + AssetCard, + Card, + CardPreview, + CardProps, + CollectionCardPreview, + ProductCard, + UserCard +} from '../src/Card'; + import { Asset as AssetComponent, AssetList, @@ -20,19 +30,9 @@ import { HorizontalCard, CardPreview as HorizontalCardPreview } from '../src/HorizontalCard'; - import {Avatar} from '../src/Avatar'; import {Badge} from '../src/Badge'; import {Button} from '../src/Button'; -import { - AssetCard, - Card, - CardPreview, - CardProps, - CollectionCardPreview, - ProductCard, - UserCard -} from '../src/Card'; import ChevronRight from '../s2wf-icons/S2_Icon_ChevronRight_20_N.svg'; import {Content, Footer, Text} from '../src/Content'; import {Divider} from '../src/Divider'; From 6a700abcdf2420bf8ab7fc4a9159d79521c43513 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Thu, 14 May 2026 15:07:24 +1000 Subject: [PATCH 7/7] fix formatting --- .../@react-spectrum/s2/src/HorizontalCard.tsx | 805 ++++++++++-------- .../s2/stories/Card.stories.tsx | 62 +- 2 files changed, 509 insertions(+), 358 deletions(-) diff --git a/packages/@react-spectrum/s2/src/HorizontalCard.tsx b/packages/@react-spectrum/s2/src/HorizontalCard.tsx index 448448e68cd..e8f798d884c 100644 --- a/packages/@react-spectrum/s2/src/HorizontalCard.tsx +++ b/packages/@react-spectrum/s2/src/HorizontalCard.tsx @@ -18,10 +18,21 @@ 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 { + 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 { + 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'; @@ -41,27 +52,44 @@ import {useSpectrumContextProps} from './useSpectrumContextProps'; interface CardRenderProps { /** The size of the Card. */ - size: 'XS' | 'S' | 'M' | 'L' | 'XL' + size: 'XS' | 'S' | 'M' | 'L' | 'XL'; } -export interface CardProps extends Omit, StyleProps { +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), + children: ReactNode | ((renderProps: CardRenderProps) => ReactNode); /** * The size of the Card. + * * @default 'M' */ - size?: 'XS' | 'S' | 'M' | 'L' | 'XL', + size?: 'XS' | 'S' | 'M' | 'L' | 'XL'; /** * The amount of internal padding within the Card. + * * @default 'regular' */ - density?: 'compact' | 'regular' | 'spacious', + density?: 'compact' | 'regular' | 'spacious'; /** * The visual style of the Card. + * * @default 'primary' */ - variant?: 'primary' | 'secondary' | 'tertiary' | 'quiet' + variant?: 'primary' | 'secondary' | 'tertiary' | 'quiet'; } const borderRadius = { @@ -83,154 +111,157 @@ const borderRadius = { 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: { +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: { - 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 + tertiary: 'transparent', + quiet: 'transparent' } }, - 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 - } + // 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)]' }, - regular: { - size: { - XS: 8, - S: 12, - M: 16, - L: 20, - XL: 24 + 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 + } } }, - 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 } - }, - [onlyPreview]: 0 - } - }, - '--card-padding-y': { - type: 'paddingTop', - value: { - default: '--card-spacing', - variant: { - quiet: 0 } - } - }, - '--card-padding-x': { - type: 'paddingStart', - value: { - default: '--card-spacing', + }, + '--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: 0 + quiet: 'none' } } }, - 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()); + getAllowedOverrides() +); let selectionIndicator = style({ position: 'absolute', @@ -332,14 +363,8 @@ let content = style({ ':has([data-slot=menu])': ['minmax(0, 1fr)', 'auto'] }, gridTemplateAreas: { - default: [ - 'title', - 'description' - ], - ':has([data-slot=menu])': [ - 'title menu', - 'description description' - ] + default: ['title', 'description'], + ':has([data-slot=menu])': ['title menu', 'description description'] }, columnGap: 4, flexGrow: 1, @@ -383,16 +408,17 @@ export const InternalCardViewContext = createContext({ ElementType: 'div' as 'div' | typeof GridListItem, layout: 'grid' as 'grid' | 'waterfall' }); -export const CardContext = createContext, DOMRefValue>>(null); +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 + isQuiet: boolean; + size: 'XS' | 'S' | 'M' | 'L' | 'XL'; + isSelected: boolean; + isHovered: boolean; + isFocusVisible: boolean; + isPressed: boolean; + isCheckboxSelection: boolean; } const InternalCardContext = createContext({ @@ -413,38 +439,60 @@ const actionButtonSize = { XL: 'L' } as const; -const Card = forwardRef(function Card(props: CardProps & {isBasic?: boolean}, ref: DOMRef) { +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 { + 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 = ( @@ -461,13 +509,20 @@ const Card = forwardRef(function Card(props: CardProps & {isBasic?: boolean}, re UNSAFE_className + card({...renderProps, size, density, variant, isBasic, isCardView: false, isLink: true}, styles)} + className={renderProps => + 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) => ( - + {renderProps => ( + {children} )} @@ -483,9 +538,21 @@ const Card = forwardRef(function Card(props: CardProps & {isBasic?: boolean}, re // @ts-ignore - React < 19 compat inert={inertValue(isSkeleton)} ref={domRef} - className={UNSAFE_className + card({size, density, variant, isBasic, isCardView: ElementType !== 'div'}, styles)} + className={ + UNSAFE_className + + card({size, density, variant, isBasic, isCardView: ElementType !== 'div'}, styles) + } style={UNSAFE_style}> - + {children} @@ -496,22 +563,35 @@ const Card = forwardRef(function Card(props: CardProps & {isBasic?: boolean}, re UNSAFE_className + card({...renderProps, isCardView: true, isLink: !!props.href, size, density, variant, isBasic}, styles)} + className={renderProps => + 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' && + {!isQuiet && selectionMode !== 'none' && selectionBehavior === 'toggle' && ( - } + )} {/* this makes the :first-child selector work even with the checkbox */} -
- {children} -
+
{children}
)}
@@ -528,7 +608,8 @@ function SelectionIndicator() { // Add an inner stroke only for quiet cards with no checkbox to // help distinguish the selected state from the preview. isStrokeInner: isQuiet && !isCheckboxSelection - })} /> + })} + /> ); } @@ -546,20 +627,21 @@ function CardCheckbox() { borderRadius: 'default', boxShadow: 'emphasized' })}> - + ); } export interface CardPreviewProps extends UnsafeStyles, DOMProps { - children: ReactNode + children: ReactNode; } -export const CardPreview = forwardRef(function CardPreview(props: CardPreviewProps, ref: DOMRef) { - let {size, isQuiet, isHovered, isFocusVisible, isSelected, isPressed, isCheckboxSelection} = useContext(InternalCardContext); +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 ( @@ -604,7 +686,10 @@ const collectionImage = style({ userSelect: 'none' }); -export const CollectionCardPreview = forwardRef(function CollectionCardPreview(props: CardPreviewProps, ref: DOMRef) { +export const CollectionCardPreview = forwardRef(function CollectionCardPreview( + props: CardPreviewProps, + ref: DOMRef +) { let {size} = useContext(InternalCardContext)!; return ( @@ -625,69 +710,81 @@ const buttonSize = { XL: 'XL' } as const; -export const HorizontalCard = forwardRef(function HorizontalCard(props: CardProps, ref: DOMRef) { +export const HorizontalCard = forwardRef(function HorizontalCard( + props: CardProps, + ref: DOMRef +) { let {size = 'M'} = props; return ( {composeRenderProps(props.children, children => ( @@ -698,56 +795,68 @@ export const HorizontalCard = forwardRef(function HorizontalCard(props: CardProp ); }); -export const BasicHorizontalCard = forwardRef(function BasicHorizontalCard(props: CardProps, ref: DOMRef) { +export const BasicHorizontalCard = forwardRef(function BasicHorizontalCard( + props: CardProps, + ref: DOMRef +) { let {size = 'M'} = props; return ( {composeRenderProps(props.children, children => ( @@ -758,46 +867,54 @@ export const BasicHorizontalCard = forwardRef(function BasicHorizontalCard(props ); }); - 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' +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' + }, + outlineColor: { + default: 'focus-ring', + forcedColors: 'Highlight' + }, + disableTapHighlight: true }, - disableTapHighlight: true -}, getAllowedOverrides()); + getAllowedOverrides() +); const CloseButton = function CloseButton(props) { let ref = useRef>(null); let domRef = useFocusableRef(ref); @@ -810,7 +927,9 @@ const CloseButton = function CloseButton(props) { aria-label={props['aria-label'] || stringFormatter.format('dialog.dismiss')} style={pressScale(domRef, {})} className={renderProps => styles({...renderProps, size: props.size || 'M'}, props.styles)}> - + ); }; @@ -821,7 +940,15 @@ export const AssetList = forwardRef(function AssetList(props: any, ref: DOMRef - + {props.children} @@ -832,11 +959,17 @@ export const Asset = forwardRef(function Asset(props: CardProps, ref: DOMRef - - {props.children} - + {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 fd6abb56406..e866b279c0c 100644 --- a/packages/@react-spectrum/s2/stories/Card.stories.tsx +++ b/packages/@react-spectrum/s2/stories/Card.stories.tsx @@ -467,44 +467,55 @@ export const Custom: Story = { } }; - export const Horizontal: Story = { - render: (args) => ( -
+ render: args => ( +
- + + src={new URL('assets/placeholder.png', import.meta.url).toString()} + /> Card title - Card description. Give a concise overview of the context or functionality that's mentioned in the card title. + + Card description. Give a concise overview of the context or functionality that's + mentioned in the card title. + - + + src={new URL('assets/placeholder.png', import.meta.url).toString()} + /> Card title - Card description. Give a concise overview of the context or functionality that's mentioned in the card title. + + Card description. Give a concise overview of the context or functionality that's + mentioned in the card title. + + src={new URL('assets/placeholder.png', import.meta.url).toString()} + /> Card title Card description. @@ -518,7 +529,8 @@ export const Horizontal: Story = { + src={new URL('assets/placeholder.png', import.meta.url).toString()} + /> Card title Card description. @@ -532,7 +544,8 @@ export const Horizontal: Story = { + src={new URL('assets/placeholder.png', import.meta.url).toString()} + /> Card title Card description. @@ -541,7 +554,8 @@ export const Horizontal: Story = { + src={new URL('assets/placeholder.png', import.meta.url).toString()} + /> Card title Card description. @@ -550,29 +564,33 @@ export const Horizontal: Story = { + src={new URL('assets/placeholder.png', import.meta.url).toString()} + />
) }; export const AIAssetList: Story = { - render: (args) => ( + render: args => ( + src={new URL('assets/placeholder.png', import.meta.url).toString()} + /> + src={new URL('assets/placeholder.png', import.meta.url).toString()} + /> + src={new URL('assets/placeholder.png', import.meta.url).toString()} + /> )