diff --git a/.eslintrc.js b/.eslintrc.js index d6fce72a85b..d40afd976c7 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -120,7 +120,8 @@ module.exports = { 'AsyncIterable': 'readonly', 'FileSystemFileEntry': 'readonly', 'FileSystemDirectoryEntry': 'readonly', - 'FileSystemEntry': 'readonly' + 'FileSystemEntry': 'readonly', + 'IS_REACT_ACT_ENVIRONMENT': 'readonly' }, settings: { jsdoc: { diff --git a/packages/@react-aria/selection/src/ListKeyboardDelegate.ts b/packages/@react-aria/selection/src/ListKeyboardDelegate.ts index 8bda10460f0..7a8a5f6ed34 100644 --- a/packages/@react-aria/selection/src/ListKeyboardDelegate.ts +++ b/packages/@react-aria/selection/src/ListKeyboardDelegate.ts @@ -74,32 +74,27 @@ export class ListKeyboardDelegate implements KeyboardDelegate { return this.disabledBehavior === 'all' && (item.props?.isDisabled || this.disabledKeys.has(item.key)); } - getNextKey(key: Key) { - key = this.collection.getKeyAfter(key); + private findNextNonDisabled(key: Key, getNext: (key: Key) => Key | null): Key | null { while (key != null) { let item = this.collection.getItem(key); - if (item.type === 'item' && !this.isDisabled(item)) { + if (item?.type === 'item' && !this.isDisabled(item)) { return key; } - key = this.collection.getKeyAfter(key); + key = getNext(key); } return null; } + getNextKey(key: Key) { + key = this.collection.getKeyAfter(key); + return this.findNextNonDisabled(key, key => this.collection.getKeyAfter(key)); + } + getPreviousKey(key: Key) { key = this.collection.getKeyBefore(key); - while (key != null) { - let item = this.collection.getItem(key); - if (item.type === 'item' && !this.isDisabled(item)) { - return key; - } - - key = this.collection.getKeyBefore(key); - } - - return null; + return this.findNextNonDisabled(key, key => this.collection.getKeyBefore(key)); } private findKey( @@ -151,6 +146,14 @@ export class ListKeyboardDelegate implements KeyboardDelegate { } getKeyRightOf(key: Key) { + // This is a temporary solution for CardView until we refactor useSelectableCollection. + // https://github.com/orgs/adobe/projects/19/views/32?pane=issue&itemId=77825042 + let layoutDelegateMethod = this.direction === 'ltr' ? 'getKeyRightOf' : 'getKeyLeftOf'; + if (this.layoutDelegate[layoutDelegateMethod]) { + key = this.layoutDelegate[layoutDelegateMethod](key); + return this.findNextNonDisabled(key, key => this.layoutDelegate[layoutDelegateMethod](key)); + } + if (this.layout === 'grid') { if (this.orientation === 'vertical') { return this.getNextColumn(key, this.direction === 'rtl'); @@ -165,6 +168,12 @@ export class ListKeyboardDelegate implements KeyboardDelegate { } getKeyLeftOf(key: Key) { + let layoutDelegateMethod = this.direction === 'ltr' ? 'getKeyLeftOf' : 'getKeyRightOf'; + if (this.layoutDelegate[layoutDelegateMethod]) { + key = this.layoutDelegate[layoutDelegateMethod](key); + return this.findNextNonDisabled(key, key => this.layoutDelegate[layoutDelegateMethod](key)); + } + if (this.layout === 'grid') { if (this.orientation === 'vertical') { return this.getNextColumn(key, this.direction === 'ltr'); @@ -180,30 +189,12 @@ export class ListKeyboardDelegate implements KeyboardDelegate { getFirstKey() { let key = this.collection.getFirstKey(); - while (key != null) { - let item = this.collection.getItem(key); - if (item?.type === 'item' && !this.isDisabled(item)) { - return key; - } - - key = this.collection.getKeyAfter(key); - } - - return null; + return this.findNextNonDisabled(key, key => this.collection.getKeyAfter(key)); } getLastKey() { let key = this.collection.getLastKey(); - while (key != null) { - let item = this.collection.getItem(key); - if (item.type === 'item' && !this.isDisabled(item)) { - return key; - } - - key = this.collection.getKeyBefore(key); - } - - return null; + return this.findNextNonDisabled(key, key => this.collection.getKeyBefore(key)); } getKeyPageAbove(key: Key) { @@ -280,7 +271,7 @@ export class ListKeyboardDelegate implements KeyboardDelegate { return key; } - key = this.getKeyBelow(key); + key = this.getNextKey(key); } return null; diff --git a/packages/@react-aria/utils/src/useLoadMore.ts b/packages/@react-aria/utils/src/useLoadMore.ts index e00d04a041f..2dd7135e586 100644 --- a/packages/@react-aria/utils/src/useLoadMore.ts +++ b/packages/@react-aria/utils/src/useLoadMore.ts @@ -29,7 +29,7 @@ export interface LoadMoreProps { */ scrollOffset?: number, /** The data currently loaded. */ - items?: any[] + items?: any } export function useLoadMore(props: LoadMoreProps, ref: RefObject) { diff --git a/packages/@react-aria/virtualizer/src/ScrollView.tsx b/packages/@react-aria/virtualizer/src/ScrollView.tsx index 363adaa0058..67e17ece306 100644 --- a/packages/@react-aria/virtualizer/src/ScrollView.tsx +++ b/packages/@react-aria/virtualizer/src/ScrollView.tsx @@ -143,12 +143,17 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject { let dom = ref.current; - if (!dom) { + if (!dom && !isUpdatingSize.current) { return; } + // Prevent reentrancy when resize observer fires, triggers re-layout that results in + // content size update, causing below layout effect to fire. This avoids infinite loops. + isUpdatingSize.current = true; + let isTestEnv = process.env.NODE_ENV === 'test' && !process.env.VIRT_ON; let isClientWidthMocked = Object.getOwnPropertyNames(window.HTMLElement.prototype).includes('clientWidth'); let isClientHeightMocked = Object.getOwnPropertyNames(window.HTMLElement.prototype).includes('clientHeight'); @@ -177,27 +182,31 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject(null); useLayoutEffect(() => { - // React doesn't allow flushSync inside effects, so queue a microtask. - // We also need to wait until all refs are set (e.g. when passing a ref down from a parent). - queueMicrotask(() => { - if (!didUpdateSize.current) { - didUpdateSize.current = true; - updateSize(flushSync); + if (!isUpdatingSize.current && (lastContentSize.current == null || !contentSize.equals(lastContentSize.current))) { + // React doesn't allow flushSync inside effects, so queue a microtask. + // We also need to wait until all refs are set (e.g. when passing a ref down from a parent). + // If we are in an `act` environment, update immediately without a microtask so you don't need + // to mock timers in tests. In this case, the update is synchronous already. + // IS_REACT_ACT_ENVIRONMENT is used by React 18. Previous versions checked for the `jest` global. + // https://github.com/reactwg/react-18/discussions/102 + // @ts-ignore + if (typeof IS_REACT_ACT_ENVIRONMENT === 'boolean' ? IS_REACT_ACT_ENVIRONMENT : typeof jest !== 'undefined') { + updateSize(fn => fn()); + } else { + queueMicrotask(() => updateSize(flushSync)); } - }); - }, [updateSize]); - useEffect(() => { - if (!didUpdateSize.current) { - // If useEffect ran before the above microtask, we are in a synchronous render (e.g. act). - // Update the size here so that you don't need to mock timers in tests. - didUpdateSize.current = true; - updateSize(fn => fn()); } - }, [updateSize]); + + lastContentSize.current = contentSize; + }); + let onResize = useCallback(() => { updateSize(flushSync); }, [updateSize]); diff --git a/packages/@react-spectrum/s2/package.json b/packages/@react-spectrum/s2/package.json index 995f2c631ce..0e6d106c816 100644 --- a/packages/@react-spectrum/s2/package.json +++ b/packages/@react-spectrum/s2/package.json @@ -129,6 +129,7 @@ "@react-aria/interactions": "^3.22.2", "@react-aria/utils": "^3.25.2", "@react-spectrum/utils": "^3.11.10", + "@react-stately/virtualizer": "^4.0.1", "@react-types/color": "3.0.0-rc.1", "@react-types/dialog": "^3.5.8", "@react-types/provider": "^3.7.2", diff --git a/packages/@react-spectrum/s2/src/ActionButton.tsx b/packages/@react-spectrum/s2/src/ActionButton.tsx index 31c74c58ac3..e02fb5b3b91 100644 --- a/packages/@react-spectrum/s2/src/ActionButton.tsx +++ b/packages/@react-spectrum/s2/src/ActionButton.tsx @@ -11,15 +11,17 @@ */ import {baseColor, fontRelative, style} from '../style/spectrum-theme' with { type: 'macro' }; -import {ButtonProps, ButtonRenderProps, ContextValue, OverlayTriggerStateContext, Provider, Button as RACButton} from 'react-aria-components'; +import {ButtonProps, ButtonRenderProps, ContextValue, OverlayTriggerStateContext, Provider, Button as RACButton, Text} from 'react-aria-components'; import {centerBaseline} from './CenterBaseline'; import {createContext, forwardRef, ReactNode, useContext} from 'react'; import {FocusableRef, FocusableRefValue} from '@react-types/shared'; import {focusRing, getAllowedOverrides, StyleProps} from './style-utils' with { type: 'macro' }; import {IconContext} from './Icon'; import {pressScale} from './pressScale'; -import {Text, TextContext} from './Content'; +import {SkeletonContext} from './Skeleton'; +import {TextContext} from './Content'; import {useFocusableRef} from '@react-spectrum/utils'; +import {useFormProps} from './Form'; import {useSpectrumContextProps} from './useSpectrumContextProps'; export interface ActionButtonStyleProps { @@ -175,6 +177,7 @@ export const ActionButtonContext = createContext) { [props, ref] = useSpectrumContextProps(props, ref, ActionButtonContext); + props = useFormProps(props as any); let domRef = useFocusableRef(ref); let overlayTriggerState = useContext(OverlayTriggerStateContext); @@ -193,6 +196,7 @@ function ActionButton(props: ActionButtonProps, ref: FocusableRef extends Pick, - Pick, 'children' | 'items' | 'disabledKeys' | 'onAction' | 'size'>, - Pick, + Pick, 'children' | 'items' | 'disabledKeys' | 'onAction'>, + Pick, StyleProps, DOMProps, AriaLabelingProps { - } + menuSize?: 'S' | 'M' | 'L' | 'XL' +} export const ActionMenuContext = createContext, FocusableRefValue>>(null); @@ -41,7 +42,6 @@ function ActionMenu(props: ActionMenuProps, ref: FocusableR buttonProps['aria-label'] = stringFormatter.format('menu.moreActions'); } - // size independently controlled? return ( (props: ActionMenuProps, ref: FocusableR shouldFlip={props.shouldFlip}> @@ -64,7 +64,7 @@ function ActionMenu(props: ActionMenuProps, ref: FocusableR items={props.items} disabledKeys={props.disabledKeys} onAction={props.onAction} - size={props.size}> + size={props.menuSize}> {/* @ts-ignore TODO: fix type, right now this component is the same as Menu */} {props.children} diff --git a/packages/@react-spectrum/s2/src/Avatar.tsx b/packages/@react-spectrum/s2/src/Avatar.tsx index da7ba47049b..7b8790ef928 100644 --- a/packages/@react-spectrum/s2/src/Avatar.tsx +++ b/packages/@react-spectrum/s2/src/Avatar.tsx @@ -15,6 +15,7 @@ import {createContext, forwardRef} from 'react'; import {DOMProps, DOMRef, DOMRefValue} from '@react-types/shared'; import {filterDOMProps} from '@react-aria/utils'; import {getAllowedOverrides, StylesPropWithoutWidth, UnsafeStyles} from './style-utils' with {type: 'macro'}; +import {Image} from './Image'; import {style} from '../style/spectrum-theme' with { type: 'macro' }; import {useDOMRef} from '@react-spectrum/utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; @@ -71,16 +72,17 @@ function Avatar(props: AvatarProps, ref: DOMRef) { let remSize = size / 16 + 'rem'; let isLarge = size >= 64; return ( - {alt} ); } diff --git a/packages/@react-spectrum/s2/src/Badge.tsx b/packages/@react-spectrum/s2/src/Badge.tsx index 44c79b0d2cf..6ba6216dcf0 100644 --- a/packages/@react-spectrum/s2/src/Badge.tsx +++ b/packages/@react-spectrum/s2/src/Badge.tsx @@ -18,6 +18,7 @@ import {filterDOMProps} from '@react-aria/utils'; import {fontRelative, style} from '../style/spectrum-theme' with {type: 'macro'}; import {IconContext} from './Icon'; import React, {createContext, forwardRef, ReactNode} from 'react'; +import {SkeletonWrapper} from './Skeleton'; import {Text, TextContext} from './Content'; import {useDOMRef} from '@react-spectrum/utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; @@ -25,13 +26,13 @@ import {useSpectrumContextProps} from './useSpectrumContextProps'; export interface BadgeStyleProps { /** * The size of the badge. - * + * * @default 'S' */ size?: 'S' | 'M' | 'L' | 'XL', /** * The variant changes the background color of the badge. When badge has a semantic meaning, they should use the variant for semantic colors. - * + * * @default 'neutral' */ variant?: 'accent' | 'informative' | 'neutral' | 'positive' | 'notice' | 'negative' | 'gray' | 'red' | 'orange' | 'yellow' | 'charteuse' | 'celery' | 'green' | 'seafoam' | 'cyan' | 'blue' | 'indigo' | 'purple' | 'fuchsia' | 'magenta' | 'pink' | 'turquoise' | 'brown' | 'cinnamon' | 'silver', @@ -201,17 +202,20 @@ function Badge(props: BadgeProps, ref: DOMRef) { styles: style({size: fontRelative(20), marginStart: '--iconMargin', flexShrink: 0}) }] ]}> - - { - typeof children === 'string' || isTextOnly - ? {children} - : children - } - + + + { + typeof children === 'string' || isTextOnly + ? {children} + : children + } + + ); } diff --git a/packages/@react-spectrum/s2/src/Button.tsx b/packages/@react-spectrum/s2/src/Button.tsx index fa29d9e14d8..6c12a7e0d1e 100644 --- a/packages/@react-spectrum/s2/src/Button.tsx +++ b/packages/@react-spectrum/s2/src/Button.tsx @@ -18,8 +18,10 @@ import {createContext, forwardRef, ReactNode, useContext} from 'react'; import {FocusableRef, FocusableRefValue} from '@react-types/shared'; import {IconContext} from './Icon'; import {pressScale} from './pressScale'; +import {SkeletonContext} from './Skeleton'; import {Text, TextContext} from './Content'; import {useFocusableRef} from '@react-spectrum/utils'; +import {useFormProps} from './Form'; import {useSpectrumContextProps} from './useSpectrumContextProps'; interface ButtonStyleProps { @@ -272,6 +274,7 @@ const button = style({ function Button(props: ButtonProps, ref: FocusableRef) { [props, ref] = useSpectrumContextProps(props, ref, ButtonContext); + props = useFormProps(props); let domRef = useFocusableRef(ref); let overlayTriggerState = useContext(OverlayTriggerStateContext); @@ -291,6 +294,7 @@ function Button(props: ButtonProps, ref: FocusableRef) { }, props.styles)}> ) { [props, ref] = useSpectrumContextProps(props, ref, LinkButtonContext); + props = useFormProps(props); let domRef = useFocusableRef(ref); let overlayTriggerState = useContext(OverlayTriggerStateContext); @@ -332,6 +337,7 @@ function LinkButton(props: LinkButtonProps, ref: FocusableRef }, props.styles)}> , StyleProps { + /** The children of the Card. */ + children: 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' + } +} as const; + +let card = style({ + display: 'flex', + flexDirection: 'column', + position: 'relative', + borderRadius, + '--s2-container-bg': { + type: 'backgroundColor', + value: { + variant: { + primary: 'elevated', + secondary: 'layer-1' + }, + forcedColors: 'ButtonFace' + } + }, + backgroundColor: { + default: '--s2-container-bg', + variant: { + tertiary: 'transparent', + quiet: 'transparent' + } + }, + boxShadow: { + default: 'emphasized', + isHovered: 'elevated', + isFocusVisible: 'elevated', + isSelected: 'elevated', + forcedColors: '[0 0 0 1px ButtonBorder]', + variant: { + tertiary: { + // Render border with box-shadow to avoid affecting layout. + default: `[0 0 0 1px ${colorToken('gray-100')}]`, + isHovered: `[0 0 0 1px ${colorToken('gray-200')}]`, + isFocusVisible: `[0 0 0 1px ${colorToken('gray-200')}]`, + isSelected: 'none', + forcedColors: '[0 0 0 1px ButtonBorder]' + }, + quiet: 'none' + } + }, + forcedColorAdjust: 'none', + transition: 'default', + fontFamily: 'sans', + overflow: { + default: 'clip', + variant: { + quiet: 'visible' + } + }, + disableTapHighlight: true, + userSelect: { + isCardView: 'none' + }, + cursor: { + isLink: 'pointer' + }, + width: { + size: { + XS: 112, + S: 192, + M: 240, + L: 320, + XL: size(400) + }, + isCardView: 'full' + }, + height: 'full', + '--card-spacing': { + type: 'paddingTop', + value: { + density: { + compact: { + size: { + XS: size(6), + 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: 'gray-25', + outlineOffset: -4, + outlineStyle: { + default: 'none', + isStrokeInner: 'solid' + }, + outlineWidth: 2 +}); + +let preview = style({ + position: 'relative', + transition: 'default', + overflow: 'clip', + marginX: '[calc(var(--card-padding-x) * -1)]', + marginTop: '[calc(var(--card-padding-y) * -1)]', + marginBottom: { + ':last-child': '[calc(var(--card-padding-y) * -1)]' + }, + borderRadius: { + isQuiet: borderRadius + }, + boxShadow: { + isQuiet: { + isHovered: 'elevated', + isFocusVisible: 'elevated', + isSelected: 'elevated' + } + }, + ...focusRing(), + outlineStyle: { + default: 'none', + isQuiet: { + isFocusVisible: 'solid' + } + } +}); + +const image = style({ + width: 'full', + aspectRatio: '[3/2]', + 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: 'space-between', + rowGap: { + size: { + XS: 4, + S: 4, + M: size(6), + L: size(6), + XL: 8 + } + }, + paddingTop: { + default: '--card-spacing', + ':first-child': 0 + }, + paddingBottom: { + default: '[calc(var(--card-spacing) * 1.5 / 2)]', + ':last-child': 0 + } +}); + +let footer = style({ + display: 'flex', + flexDirection: 'row', + alignItems: 'end', + justifyContent: 'space-between', + gap: 8, + paddingTop: '[calc(var(--card-spacing) * 1.5 / 2)]' +}); + +export const CardViewContext = createContext<'div' | typeof GridListItem>('div'); +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; + +export const Card = forwardRef(function Card(props: CardProps, ref: DOMRef) { + [props] = useSpectrumContextProps(props, ref, CardContext); + let domRef = useDOMRef(ref); + let {density = 'regular', size = 'M', variant = 'primary', UNSAFE_className = '', UNSAFE_style, styles, id, ...otherProps} = props; + let isQuiet = variant === 'quiet'; + let isSkeleton = useIsSkeleton(); + let children = ( + + + {props.children} + + + ); + + let ElementType = useContext(CardViewContext); + if (ElementType === 'div' || isSkeleton) { + return ( +
+ + {children} + +
+ ); + } + + let press = pressScale(domRef, UNSAFE_style); + return ( + UNSAFE_className + card({...renderProps, isCardView: true, isLink: !!props.href, size, density, variant}, 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} + +
+
+ ); +}); + +export interface AssetCardProps extends Omit {} + +export const AssetCard = forwardRef(function AssetCard(props: AssetCardProps, ref: DOMRef) { + return ( + + +
+ {icon} +
+ + ); + }, + styles: style({ + height: 'auto', + maxSize: 160, + // TODO: this is made up. + width: '[50%]' + }) + }] + ]}> + {props.children} +
+
+ ); +}); + +const avatarSize = { + XS: 24, + S: 48, + M: 64, + L: 64, + XL: 80 +} as const; + +export interface UserCardProps extends Omit { + // Quiet is not supported due to lack of indent between preview and avatar. + variant?: 'primary' | 'secondary' | 'tertiary' +} + +export const UserCard = forwardRef(function UserCard(props: CardProps, ref: DOMRef) { + let {size = 'M'} = props; + return ( + + + {props.children} + + + ); +}); + +const buttonSize = { + XS: 'S', + S: 'S', + M: 'M', + L: 'L', + XL: 'XL' +} as const; + +export interface ProductCardProps extends Omit { + // Quiet is not supported due to lack of indent between preview and thumbnail. + variant?: 'primary' | 'secondary' | 'tertiary' +} + +export const ProductCard = forwardRef(function ProductCard(props: ProductCardProps, ref: DOMRef) { + let {size = 'M'} = props; + return ( + + + {props.children} + + + ); +}); diff --git a/packages/@react-spectrum/s2/src/CardView.tsx b/packages/@react-spectrum/s2/src/CardView.tsx new file mode 100644 index 00000000000..d1291b6ef43 --- /dev/null +++ b/packages/@react-spectrum/s2/src/CardView.tsx @@ -0,0 +1,542 @@ +/* + * 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 { + GridList as AriaGridList, + GridLayoutOptions, + GridListItem, + GridListProps, + UNSTABLE_Virtualizer +} from 'react-aria-components'; +import {CardContext, CardViewContext} from './Card'; +import {focusRing, getAllowedOverrides, StylesPropWithHeight, UnsafeStyles} from './style-utils' with {type: 'macro'}; +import {ImageCoordinator} from './ImageCoordinator'; +import {InvalidationContext, Layout, LayoutInfo, Rect, Size} from '@react-stately/virtualizer'; +import {Key, LoadingState, Node} from '@react-types/shared'; +import {style} from '../style/spectrum-theme' with {type: 'macro'}; +import {useLoadMore} from '@react-aria/utils'; +import {useMemo, useRef} from 'react'; + +export interface CardViewProps extends Omit, 'layout' | 'keyboardNavigationBehavior' | 'selectionBehavior' | 'className' | 'style'>, UnsafeStyles { + /** + * The layout of the cards. + * @default 'grid' + */ + layout?: 'grid' | 'waterfall', + /** + * The size of the cards. + * @default 'M' + */ + size?: 'XS' | 'S' | 'M' | 'L' | 'XL', + /** + * The amount of space between the cards. + * @default 'regular' + */ + density?: 'compact' | 'regular' | 'spacious', + /** + * The visual style of the cards. + * @default 'primary' + */ + variant?: 'primary' | 'secondary' | 'tertiary' | 'quiet', + /** + * How selection should be displayed. + * @default 'checkbox' + */ + selectionStyle?: 'checkbox' | 'highlight', + /** The loading state of the CardView. */ + loadingState?: LoadingState, + /** Handler that is called when more items should be loaded, e.g. while scrolling near the bottom. */ + onLoadMore?: () => void, + /** Spectrum-defined styles, returned by the `style()` macro. */ + styles?: StylesPropWithHeight +} + +class FlexibleGridLayout extends Layout, O> { + protected minItemSize: Size; + protected maxItemSize: Size; + protected minSpace: Size; + protected maxColumns: number; + protected dropIndicatorThickness: number; + protected contentSize: Size = new Size(); + protected layoutInfos: Map = new Map(); + + constructor(options: GridLayoutOptions) { + super(); + this.minItemSize = options.minItemSize || new Size(200, 200); + this.maxItemSize = options.maxItemSize || new Size(Infinity, Infinity); + this.minSpace = options.minSpace || new Size(18, 18); + this.maxColumns = options.maxColumns || Infinity; + this.dropIndicatorThickness = options.dropIndicatorThickness || 2; + } + + update(invalidationContext: InvalidationContext): void { + let visibleWidth = this.virtualizer.visibleRect.width; + + // The max item width is always the entire viewport. + // If the max item height is infinity, scale in proportion to the max width. + let maxItemWidth = Math.min(this.maxItemSize.width, visibleWidth); + let maxItemHeight = Number.isFinite(this.maxItemSize.height) + ? this.maxItemSize.height + : Math.floor((this.minItemSize.height / this.minItemSize.width) * maxItemWidth); + + // Compute the number of rows and columns needed to display the content + let columns = Math.floor(visibleWidth / (this.minItemSize.width + this.minSpace.width)); + let numColumns = Math.max(1, Math.min(this.maxColumns, columns)); + + // Compute the available width (minus the space between items) + let width = visibleWidth - (this.minSpace.width * Math.max(0, numColumns)); + + // Compute the item width based on the space available + let itemWidth = Math.floor(width / numColumns); + itemWidth = Math.max(this.minItemSize.width, Math.min(maxItemWidth, itemWidth)); + + // Compute the item height, which is proportional to the item width + let t = ((itemWidth - this.minItemSize.width) / Math.max(1, maxItemWidth - this.minItemSize.width)); + let itemHeight = this.minItemSize.height + Math.floor((maxItemHeight - this.minItemSize.height) * t); + itemHeight = Math.max(this.minItemSize.height, Math.min(maxItemHeight, itemHeight)); + + // Compute the horizontal spacing and content height + let horizontalSpacing = Math.floor((visibleWidth - numColumns * itemWidth) / (numColumns + 1)); + + let rows = Math.ceil(this.virtualizer.collection.size / numColumns); + let iterator = this.virtualizer.collection[Symbol.iterator](); + let y = rows > 0 ? this.minSpace.height : 0; + let newLayoutInfos = new Map(); + let skeleton: Node | null = null; + let skeletonCount = 0; + for (let row = 0; row < rows; row++) { + let maxHeight = 0; + let rowLayoutInfos: LayoutInfo[] = []; + for (let col = 0; col < numColumns; col++) { + // Repeat skeleton until the end of the current row. + let node = skeleton || iterator.next().value; + if (!node) { + break; + } + + if (node.type === 'skeleton') { + skeleton = node; + } + + let key = skeleton ? `${skeleton.key}-${skeletonCount++}` : node.key; + let x = horizontalSpacing + col * (itemWidth + horizontalSpacing); + let oldLayoutInfo = this.layoutInfos.get(key); + let height = itemHeight; + let estimatedSize = true; + if (oldLayoutInfo) { + height = oldLayoutInfo.rect.height; + estimatedSize = invalidationContext.sizeChanged || oldLayoutInfo.estimatedSize; + } + + let rect = new Rect(x, y, itemWidth, height); + let layoutInfo = new LayoutInfo(node.type, key, rect); + layoutInfo.estimatedSize = estimatedSize; + layoutInfo.allowOverflow = true; + if (skeleton) { + layoutInfo.content = {...skeleton}; + } + newLayoutInfos.set(key, layoutInfo); + rowLayoutInfos.push(layoutInfo); + + maxHeight = Math.max(maxHeight, rect.height); + } + + for (let layoutInfo of rowLayoutInfos) { + layoutInfo.rect.height = maxHeight; + } + + y += maxHeight + this.minSpace.height; + + // Keep adding skeleton rows until we fill the viewport + if (skeleton && row === rows - 1 && y < this.virtualizer.visibleRect.height) { + rows++; + } + } + + this.layoutInfos = newLayoutInfos; + this.contentSize = new Size(this.virtualizer.visibleRect.width, y); + } + + getLayoutInfo(key: Key): LayoutInfo { + return this.layoutInfos.get(key)!; + } + + getContentSize(): Size { + return this.contentSize; + } + + getVisibleLayoutInfos(rect: Rect): LayoutInfo[] { + let layoutInfos: LayoutInfo[] = []; + for (let layoutInfo of this.layoutInfos.values()) { + if (layoutInfo.rect.intersects(rect) || this.virtualizer.isPersistedKey(layoutInfo.key)) { + layoutInfos.push(layoutInfo); + } + } + return layoutInfos; + } + + updateItemSize(key: Key, size: Size) { + let layoutInfo = this.layoutInfos.get(key); + if (!size || !layoutInfo) { + return false; + } + + if (size.height !== layoutInfo.rect.height) { + let newLayoutInfo = layoutInfo.copy(); + newLayoutInfo.rect.height = size.height; + newLayoutInfo.estimatedSize = false; + this.layoutInfos.set(key, newLayoutInfo); + return true; + } + + return false; + } +} + +class WaterfallLayout extends Layout, O> { + protected minItemSize: Size; + protected maxItemSize: Size; + protected minSpace: Size; + protected maxColumns: number; + protected dropIndicatorThickness: number; + protected contentSize: Size = new Size(); + protected layoutInfos: Map = new Map(); + + constructor(options: GridLayoutOptions) { + super(); + this.minItemSize = options.minItemSize || new Size(200, 200); + this.maxItemSize = options.maxItemSize || new Size(Infinity, Infinity); + this.minSpace = options.minSpace || new Size(18, 18); + this.maxColumns = options.maxColumns || Infinity; + this.dropIndicatorThickness = options.dropIndicatorThickness || 2; + } + + update(invalidationContext: InvalidationContext): void { + let visibleWidth = this.virtualizer.visibleRect.width; + + // The max item width is always the entire viewport. + // If the max item height is infinity, scale in proportion to the max width. + let maxItemWidth = Math.min(this.maxItemSize.width, visibleWidth); + let maxItemHeight = Number.isFinite(this.maxItemSize.height) + ? this.maxItemSize.height + : Math.floor((this.minItemSize.height / this.minItemSize.width) * maxItemWidth); + + // Compute the number of rows and columns needed to display the content + let columns = Math.floor(visibleWidth / (this.minItemSize.width + this.minSpace.width)); + let numColumns = Math.max(1, Math.min(this.maxColumns, columns)); + + // Compute the available width (minus the space between items) + let width = visibleWidth - (this.minSpace.width * Math.max(0, numColumns)); + + // Compute the item width based on the space available + let itemWidth = Math.floor(width / numColumns); + itemWidth = Math.max(this.minItemSize.width, Math.min(maxItemWidth, itemWidth)); + + // Compute the item height, which is proportional to the item width + let t = ((itemWidth - this.minItemSize.width) / Math.max(1, maxItemWidth - this.minItemSize.width)); + let itemHeight = this.minItemSize.height + Math.floor((maxItemHeight - this.minItemSize.height) * t); + itemHeight = Math.max(this.minItemSize.height, Math.min(maxItemHeight, itemHeight)); + + // Compute the horizontal spacing and content height + let horizontalSpacing = Math.floor((visibleWidth - numColumns * itemWidth) / (numColumns + 1)); + + // Setup an array of column heights + let columnHeights = Array(numColumns).fill(this.minSpace.height); + let newLayoutInfos = new Map(); + let addNode = (key, node) => { + let oldLayoutInfo = this.layoutInfos.get(key); + let height = itemHeight; + let estimatedSize = true; + if (oldLayoutInfo) { + height = oldLayoutInfo.rect.height; + estimatedSize = invalidationContext.sizeChanged || oldLayoutInfo.estimatedSize; + } + + // Figure out which column to place the item in, and compute its position. + let column = columnHeights.reduce((minIndex, h, i) => h < columnHeights[minIndex] ? i : minIndex, 0); + let x = horizontalSpacing + column * (itemWidth + horizontalSpacing); + let y = columnHeights[column]; + + let rect = new Rect(x, y, itemWidth, height); + let layoutInfo = new LayoutInfo(node.type, key, rect); + layoutInfo.estimatedSize = estimatedSize; + layoutInfo.allowOverflow = true; + newLayoutInfos.set(key, layoutInfo); + + columnHeights[column] += layoutInfo.rect.height + this.minSpace.height; + return layoutInfo; + }; + + let skeletonCount = 0; + for (let node of this.virtualizer.collection) { + if (node.type === 'skeleton') { + // Add skeleton cards until every column has at least one, and we fill the viewport. + let startingHeights = [...columnHeights]; + while ( + !columnHeights.every((h, i) => h !== startingHeights[i]) || + Math.min(...columnHeights) < this.virtualizer.visibleRect.height + ) { + let layoutInfo = addNode(`${node.key}-${skeletonCount++}`, node); + layoutInfo.content = this.layoutInfos.get(layoutInfo.key)?.content || {...node}; + } + break; + } else { + addNode(node.key, node); + } + } + + // Reset all columns to the maximum for the next section + let maxHeight = Math.max(...columnHeights); + this.contentSize = new Size(this.virtualizer.visibleRect.width, maxHeight); + this.layoutInfos = newLayoutInfos; + } + + getLayoutInfo(key: Key): LayoutInfo { + return this.layoutInfos.get(key)!; + } + + getContentSize(): Size { + return this.contentSize; + } + + getVisibleLayoutInfos(rect: Rect): LayoutInfo[] { + let layoutInfos: LayoutInfo[] = []; + for (let layoutInfo of this.layoutInfos.values()) { + if (layoutInfo.rect.intersects(rect) || this.virtualizer.isPersistedKey(layoutInfo.key)) { + layoutInfos.push(layoutInfo); + } + } + return layoutInfos; + } + + updateItemSize(key: Key, size: Size) { + let layoutInfo = this.layoutInfos.get(key); + if (!size || !layoutInfo) { + return false; + } + + if (size.height !== layoutInfo.rect.height) { + let newLayoutInfo = layoutInfo.copy(); + newLayoutInfo.rect.height = size.height; + newLayoutInfo.estimatedSize = false; + this.layoutInfos.set(key, newLayoutInfo); + return true; + } + + return false; + } + + // Override keyboard navigation to work spacially. + getKeyRightOf(key: Key): Key | null { + let layoutInfo = this.getLayoutInfo(key); + if (!layoutInfo) { + return null; + } + + let rect = new Rect(layoutInfo.rect.maxX, layoutInfo.rect.y, this.virtualizer.visibleRect.maxX - layoutInfo.rect.maxX, layoutInfo.rect.height); + let layoutInfos = this.getVisibleLayoutInfos(rect); + let bestKey: Key | null = null; + let bestDistance = Infinity; + for (let candidate of layoutInfos) { + if (candidate.key === key) { + continue; + } + + // Find the closest item in the x direction with the most overlap in the y direction. + let deltaX = candidate.rect.x - rect.x; + let overlapY = Math.min(candidate.rect.maxY, rect.maxY) - Math.max(candidate.rect.y, rect.y); + let distance = deltaX - overlapY; + if (distance < bestDistance) { + bestDistance = distance; + bestKey = candidate.key; + } + } + + return bestKey; + } + + getKeyLeftOf(key: Key): Key | null { + let layoutInfo = this.getLayoutInfo(key); + if (!layoutInfo) { + return null; + } + + let rect = new Rect(0, layoutInfo.rect.y, layoutInfo.rect.x, layoutInfo.rect.height); + let layoutInfos = this.getVisibleLayoutInfos(rect); + let bestKey: Key | null = null; + let bestDistance = Infinity; + for (let candidate of layoutInfos) { + if (candidate.key === key) { + continue; + } + + // Find the closest item in the x direction with the most overlap in the y direction. + let deltaX = rect.maxX - candidate.rect.maxX; + let overlapY = Math.min(candidate.rect.maxY, rect.maxY) - Math.max(candidate.rect.y, rect.y); + let distance = deltaX - overlapY; + if (distance < bestDistance) { + bestDistance = distance; + bestKey = candidate.key; + } + } + + return bestKey; + } +} + +const layoutOptions = { + XS: { + compact: { + minSpace: new Size(6, 6), + minItemSize: new Size(100, 100), + maxItemSize: new Size(140, 140) + }, + regular: { + minSpace: new Size(8, 8), + minItemSize: new Size(100, 100), + maxItemSize: new Size(140, 140) + }, + spacious: { + minSpace: new Size(12, 12), + minItemSize: new Size(100, 100), + maxItemSize: new Size(140, 140) + } + }, + S: { + compact: { + minSpace: new Size(8, 8), + minItemSize: new Size(150, 150), + maxItemSize: new Size(210, 210) + }, + regular: { + minSpace: new Size(12, 12), + minItemSize: new Size(150, 150), + maxItemSize: new Size(210, 210) + }, + spacious: { + minSpace: new Size(16, 16), + minItemSize: new Size(150, 150), + maxItemSize: new Size(210, 210) + } + }, + M: { + compact: { + minSpace: new Size(12, 12), + minItemSize: new Size(200, 200), + maxItemSize: new Size(280, 280) + }, + regular: { + minSpace: new Size(16, 16), + minItemSize: new Size(200, 200), + maxItemSize: new Size(280, 280) + }, + spacious: { + minSpace: new Size(20, 20), + minItemSize: new Size(200, 200), + maxItemSize: new Size(280, 280) + } + }, + L: { + compact: { + minSpace: new Size(16, 16), + minItemSize: new Size(270, 270), + maxItemSize: new Size(370, 370) + }, + regular: { + minSpace: new Size(20, 20), + minItemSize: new Size(270, 270), + maxItemSize: new Size(370, 370) + }, + spacious: { + minSpace: new Size(24, 24), + minItemSize: new Size(270, 270), + maxItemSize: new Size(370, 370) + } + }, + XL: { + compact: { + minSpace: new Size(20, 20), + minItemSize: new Size(340, 340), + maxItemSize: new Size(460, 460) + }, + regular: { + minSpace: new Size(24, 24), + minItemSize: new Size(340, 340), + maxItemSize: new Size(460, 460) + }, + spacious: { + minSpace: new Size(28, 28), + minItemSize: new Size(340, 340), + maxItemSize: new Size(460, 460) + } + } +}; + +const cardViewStyles = style({ + overflowY: { + default: 'auto', + isLoading: 'hidden' + }, + display: { + isEmpty: 'flex' + }, + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + ...focusRing(), + outlineStyle: { + default: 'none', + isEmpty: { + isFocusVisible: 'solid' + } + }, + outlineOffset: -2 +}, getAllowedOverrides({height: true})); + +export function CardView(props: CardViewProps) { + let {children, layout: layoutName = 'grid', size = 'M', density = 'regular', variant = 'primary', selectionStyle = 'checkbox', UNSAFE_className = '', UNSAFE_style, styles, ...otherProps} = props; + let options = layoutOptions[size][density]; + let layout = useMemo(() => { + variant; // needed to invalidate useMemo + return layoutName === 'waterfall' ? new WaterfallLayout(options) : new FlexibleGridLayout(options); + }, [options, variant, layoutName]); + + let ref = useRef(null); + useLoadMore({ + isLoading: props.loadingState !== 'idle' && props.loadingState !== 'error', + items: props.items, // TODO: ideally this would be the collection. items won't exist for static collections, or those using + onLoadMore: props.onLoadMore + }, ref); + + return ( + + + + + UNSAFE_className + cardViewStyles({...renderProps, isLoading: props.loadingState === 'loading'}, styles)}> + {children} + + + + + + ); +} diff --git a/packages/@react-spectrum/s2/src/Checkbox.tsx b/packages/@react-spectrum/s2/src/Checkbox.tsx index 041317d10f2..393e063c7ea 100644 --- a/packages/@react-spectrum/s2/src/Checkbox.tsx +++ b/packages/@react-spectrum/s2/src/Checkbox.tsx @@ -141,29 +141,41 @@ function Checkbox({children, ...props}: CheckboxProps, ref: FocusableRef (props.UNSAFE_className || '') + wrapper({...renderProps, isInForm, size: props.size || 'M'}, props.styles)}> - {renderProps => ( - <> - -
- {renderProps.isIndeterminate && - - } - {renderProps.isSelected && !renderProps.isIndeterminate && - - } -
-
- {children} - - )} + {renderProps => { + let checkbox = ( +
+ {renderProps.isIndeterminate && + + } + {renderProps.isSelected && !renderProps.isIndeterminate && + + } +
+ ); + + // Only render checkbox without center baseline if no label. + // This avoids expanding the checkbox height to the font's line height. + if (!children) { + return checkbox; + } + + return ( + <> + + {checkbox} + + {children} + + ); + }} ); } diff --git a/packages/@react-spectrum/s2/src/Content.tsx b/packages/@react-spectrum/s2/src/Content.tsx index 168e7fdb3db..13b27bdc563 100644 --- a/packages/@react-spectrum/s2/src/Content.tsx +++ b/packages/@react-spectrum/s2/src/Content.tsx @@ -10,12 +10,13 @@ * governing permissions and limitations under the License. */ -import {ContextValue, Keyboard as KeyboardAria, Header as RACHeader, Heading as RACHeading, SlotProps, Text as TextAria, useContextProps} from 'react-aria-components'; -import {createContext, forwardRef, ImgHTMLAttributes, ReactNode} from 'react'; +import {ContextValue, Keyboard as KeyboardAria, Header as RACHeader, Heading as RACHeading, TextContext as RACTextContext, SlotProps, Text as TextAria} from 'react-aria-components'; +import {createContext, forwardRef, ImgHTMLAttributes, ReactNode, useContext} from 'react'; import {DOMRef, DOMRefValue} from '@react-types/shared'; import {StyleString} from '../style/types'; import {UnsafeStyles} from './style-utils'; import {useDOMRef} from '@react-spectrum/utils'; +import {useIsSkeleton, useSkeletonText} from './Skeleton'; import {useSpectrumContextProps} from './useSpectrumContextProps'; interface ContentProps extends UnsafeStyles, SlotProps { @@ -102,17 +103,26 @@ export const TextContext = createContext function Text(props: ContentProps, ref: DOMRef) { [props, ref] = useSpectrumContextProps(props, ref, TextContext); let domRef = useDOMRef(ref); - let {UNSAFE_className = '', UNSAFE_style, styles, isHidden, slot, ...otherProps} = props; + let {UNSAFE_className = '', UNSAFE_style, styles, isHidden, slot, children, ...otherProps} = props; + let racContext = useContext(RACTextContext); + let isSkeleton = useIsSkeleton(); + [children, UNSAFE_style] = useSkeletonText(children, UNSAFE_style); if (isHidden) { return null; } + + slot = slot && racContext && 'slots' in racContext && !racContext.slots?.[slot] ? undefined : slot; return ( + slot={slot || undefined}> + {children} + ); } @@ -164,24 +174,3 @@ const _Footer = forwardRef(Footer); export {_Footer as Footer}; export const ImageContext = createContext, HTMLImageElement>>({}); - -function Image(props: ImgHTMLAttributes, ref: DOMRef) { - let domRef = useDOMRef(ref); - [props, domRef] = useContextProps(props, domRef, ImageContext); - if (props.hidden) { - return null; - } - - if (props.alt == null) { - console.warn( - 'The `alt` prop was not provided to an image. ' + - 'Add `alt` text for screen readers, or set `alt=""` prop to indicate that the image ' + - 'is decorative or redundant with displayed text and should not be announced by screen readers.' - ); - } - - return {props.alt}; -} - -const _Image = forwardRef(Image); -export {_Image as Image}; diff --git a/packages/@react-spectrum/s2/src/Form.tsx b/packages/@react-spectrum/s2/src/Form.tsx index 4bd30020f23..1732520f5c1 100644 --- a/packages/@react-spectrum/s2/src/Form.tsx +++ b/packages/@react-spectrum/s2/src/Form.tsx @@ -10,12 +10,13 @@ * governing permissions and limitations under the License. */ -import {createContext, forwardRef, ReactNode, useContext} from 'react'; +import {createContext, forwardRef, ReactNode, useContext, useMemo} from 'react'; import {DOMRef, SpectrumLabelableProps} from '@react-types/shared'; import {getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; import {Form as RACForm, FormProps as RACFormProps} from 'react-aria-components'; import {style} from '../style/spectrum-theme' with {type: 'macro'}; import {useDOMRef} from '@react-spectrum/utils'; +import {useIsSkeleton} from './Skeleton'; interface FormStyleProps extends Omit { /** @@ -36,11 +37,29 @@ export interface FormProps extends FormStyleProps, Omit(null); export function useFormProps(props: T): T { let ctx = useContext(FormContext); - if (ctx) { - return {...ctx, ...props}; - } + let isSkeleton = useIsSkeleton(); + return useMemo(() => { + let result: T = props; + if (ctx || isSkeleton) { + result = {...props}; + } - return props; + if (ctx) { + // This is a subset of mergeProps. We just need to merge non-undefined values. + for (let key in ctx) { + if (result[key] === undefined) { + result[key] = ctx[key]; + } + } + } + + // Skeleton always wins over local props. + if (isSkeleton) { + result.isDisabled = true; + } + + return result; + }, [ctx, props, isSkeleton]); } function Form(props: FormProps, ref: DOMRef) { diff --git a/packages/@react-spectrum/s2/src/GridList.tsx b/packages/@react-spectrum/s2/src/GridList.tsx deleted file mode 100644 index 9231720bbcf..00000000000 --- a/packages/@react-spectrum/s2/src/GridList.tsx +++ /dev/null @@ -1,50 +0,0 @@ -/* - * 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 { - GridList as AriaGridList, - GridListItem as AriaGridListItem, - Button, - GridListItemProps, - GridListProps -} from 'react-aria-components'; - -import {Checkbox} from './Checkbox'; - - -export function GridList( - {children, ...props}: GridListProps -) { - return ( - - {children} - - ); -} - -export function GridListItem({children, ...props}: GridListItemProps) { - let textValue = typeof children === 'string' ? children : undefined; - return ( - - {({selectionMode, selectionBehavior, allowsDragging}) => ( - <> - {/* Add elements for drag and drop and selection. */} - {allowsDragging && } - {selectionMode === 'multiple' && selectionBehavior === 'toggle' && ( - - )} - {children} - - )} - - ); -} diff --git a/packages/@react-spectrum/s2/src/Image.tsx b/packages/@react-spectrum/s2/src/Image.tsx new file mode 100644 index 00000000000..339fb4c6066 --- /dev/null +++ b/packages/@react-spectrum/s2/src/Image.tsx @@ -0,0 +1,240 @@ +import {ContextValue, SlotProps} from 'react-aria-components'; +import {createContext, ForwardedRef, forwardRef, HTMLAttributeReferrerPolicy, ReactNode, useCallback, useContext, useMemo, useReducer, useRef} from 'react'; +import {DefaultImageGroup, ImageGroup} from './ImageCoordinator'; +import {loadingStyle, useIsSkeleton, useLoadingAnimation} from './Skeleton'; +import {mergeStyles} from '../style/runtime'; +import {style} from '../style/spectrum-theme' with {type: 'macro'}; +import {StyleString} from '../style/types'; +import {UnsafeStyles} from './style-utils'; +import {useLayoutEffect} from '@react-aria/utils'; +import {useSpectrumContextProps} from './useSpectrumContextProps'; + +export interface ImageProps extends UnsafeStyles, SlotProps { + /** The URL of the image. */ + src?: string, + // TODO + // srcSet?: string, + // sizes?: string, + /** Accessible alt text for the image. */ + alt?: string, + /** + * Indicates if the fetching of the image must be done using a CORS request. + * [See MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/crossorigin). + */ + crossOrigin?: 'anonymous' | 'use-credentials', + /** + * Whether the browser should decode images synchronously or asynchronously. + * [See MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#decoding). + */ + decoding?: 'async' | 'auto' | 'sync', + // Only supported in React 19... + // fetchPriority?: 'high' | 'low' | 'auto', + /** + * Whether the image should be loaded immediately or lazily when scrolled into view. + * [See MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#loading). + */ + loading?: 'eager' | 'lazy', + /** + * A string indicating which referrer to use when fetching the resource. + * [See MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#referrerpolicy). + */ + referrerPolicy?: HTMLAttributeReferrerPolicy, + /** Spectrum-defined styles, returned by the `style()` macro. */ + styles?: StyleString, + /** A function that is called to render a fallback when the image fails to load. */ + renderError?: () => ReactNode, + /** + * A group of images to coordinate between, matching the group passed to the `` component. + * If not provided, the default image group is used. + */ + group?: ImageGroup +} + +interface ImageContextValue extends ImageProps { + hidden?: boolean +} + +export const ImageContext = createContext>(null); + +type ImageState = 'loading' | 'loaded' | 'revealed' | 'error'; +interface State { + state: ImageState, + src: string, + startTime: number, + loadTime: number +} + +type Action = + | {type: 'update', src: string} + | {type: 'loaded'} + | {type: 'revealed'} + | {type: 'error'}; + +function createState(src: string): State { + return { + state: 'loading', + src, + startTime: Date.now(), + loadTime: 0 + }; +} + +function reducer(state: State, action: Action): State { + switch (action.type) { + case 'update': { + return { + state: 'loading', + src: action.src, + startTime: Date.now(), + loadTime: 0 + }; + } + case 'loaded': + case 'error': { + return { + ...state, + state: action.type + }; + } + case 'revealed': { + return { + ...state, + state: 'revealed', + loadTime: Date.now() - state.startTime + }; + } + default: + return state; + } +} + +const wrapperStyles = style({ + backgroundColor: 'gray-100', + overflow: 'hidden' +}); + +const imgStyles = style({ + display: 'block', + width: 'full', + height: 'full', + objectFit: '[inherit]', + objectPosition: '[inherit]', + opacity: { + default: 0, + isRevealed: 1 + }, + transition: { + default: 'none', + isTransitioning: 'opacity' + }, + transitionDuration: 500 +}); + +function Image(props: ImageProps, domRef: ForwardedRef) { + [props, domRef] = useSpectrumContextProps(props, domRef, ImageContext); + + let { + src = '', + styles, + UNSAFE_className = '', + UNSAFE_style, + renderError, + group = DefaultImageGroup, + // TODO + // srcSet, + // sizes, + alt, + crossOrigin, + decoding, + loading, + referrerPolicy + } = props; + let hidden = (props as ImageContextValue).hidden; + + let {revealAll, register, unregister, load} = useContext(group); + let [{state, src: lastSrc, loadTime}, dispatch] = useReducer(reducer, src, createState); + + if (src !== lastSrc && !hidden) { + dispatch({type: 'update', src}); + } + + if (state === 'loaded' && revealAll && !hidden) { + dispatch({type: 'revealed'}); + } + + let imgRef = useRef(null); + useLayoutEffect(() => { + if (hidden) { + return; + } + + register(src); + return () => { + unregister(src); + }; + }, [hidden, register, unregister, src]); + + let onLoad = useCallback(() => { + load(src); + dispatch({type: 'loaded'}); + }, [load, src]); + + let onError = useCallback(() => { + dispatch({type: 'error'}); + unregister(src); + }, [unregister, src]); + + let isSkeleton = useIsSkeleton(); + let isAnimating = isSkeleton || state === 'loading' || state === 'loaded'; + let animation = useLoadingAnimation(isAnimating); + useLayoutEffect(() => { + if (hidden) { + return; + } + + // If the image is already loaded, update state immediately instead of waiting for onLoad. + if (state === 'loading' && imgRef.current?.complete) { + // Queue a microtask so we don't hit React's update limit. + // TODO: is this necessary? + queueMicrotask(onLoad); + } + + animation(domRef.current); + }); + + if (props.alt == null) { + console.warn( + 'The `alt` prop was not provided to an image. ' + + 'Add `alt` text for screen readers, or set `alt=""` prop to indicate that the image ' + + 'is decorative or redundant with displayed text and should not be announced by screen readers.' + ); + } + + let errorState = !isSkeleton && state === 'error' && renderError?.(); + let isRevealed = state === 'revealed' && !isSkeleton; + let isTransitioning = isRevealed && loadTime > 200; + return useMemo(() => hidden ? null : ( +
+ {errorState} + {!errorState && ( + {alt} + )} +
+ ), [hidden, domRef, UNSAFE_style, UNSAFE_className, styles, isAnimating, errorState, src, alt, crossOrigin, decoding, loading, referrerPolicy, onLoad, onError, isRevealed, isTransitioning]); +} + +const _Image = forwardRef(Image); +export {_Image as Image}; diff --git a/packages/@react-spectrum/s2/src/ImageCoordinator.tsx b/packages/@react-spectrum/s2/src/ImageCoordinator.tsx new file mode 100644 index 00000000000..b0497bc28d4 --- /dev/null +++ b/packages/@react-spectrum/s2/src/ImageCoordinator.tsx @@ -0,0 +1,163 @@ +import {Context, createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useReducer} from 'react'; + +export interface ImageCoordinatorProps { + /** Children within the ImageCoordinator. */ + children: ReactNode, + /** + * Time in milliseconds after which images are always displayed, even if all images are not yet loaded. + * @default 5000 + */ + timeout?: number, + /** + * A group of images to coordinate between, matching the group passed to the `` component. + * If not provided, the default image group is used. + */ + group?: ImageGroup +} + +export type ImageGroup = Context; + +interface ImageGroupValue { + revealAll: boolean, + register(url: string): void, + unregister(url: string): void, + load(url: string): void +} + +const defaultContext: ImageGroupValue = { + revealAll: true, + register() {}, + unregister() {}, + load() {} +}; + +export const DefaultImageGroup = createImageGroup(); + +export function createImageGroup(): ImageGroup { + return createContext(defaultContext); +} + +interface State { + loadedAll: boolean, + timedOut: boolean, + loadStartTime: number, + loaded: Map +} + +type Action = + | {type: 'register', url: string} + | {type: 'unregister', url: string} + | {type: 'load', url: string} + | {type: 'timeout'}; + +function reducer(state: State, action: Action): State { + switch (action.type) { + case 'register': { + if (state.loaded.get(action.url) !== false) { + let loaded = new Map(state.loaded); + loaded.set(action.url, false); + return { + loadedAll: false, + // If we had previously loaded all items, then reset the timed out state + // since this is the first item of a new batch. + timedOut: state.loadedAll ? false : state.timedOut, + loadStartTime: state.loadedAll ? Date.now() : state.loadStartTime, + loaded + }; + } + return state; + } + case 'unregister': { + if (state.loaded.has(action.url)) { + let loaded = new Map(state.loaded); + loaded.delete(action.url); + return { + loadedAll: isAllLoaded(loaded), + timedOut: state.timedOut, + loadStartTime: state.loadStartTime, + loaded + }; + } + return state; + } + case 'load': { + if (state.loaded.get(action.url) === false) { + let loaded = new Map(state.loaded); + loaded.set(action.url, true); + return { + loadedAll: isAllLoaded(loaded), + timedOut: state.timedOut, + loadStartTime: state.loadStartTime, + loaded + }; + } + return state; + } + case 'timeout': { + if (!state.loadedAll && !state.timedOut) { + return { + ...state, + timedOut: true + }; + } + return state; + } + default: + return state; + } +} + +function isAllLoaded(loaded: Map) { + for (let isLoaded of loaded.values()) { + if (!isLoaded) { + return false; + } + } + return true; +} + +/** + * An ImageCoordinator coordinates loading behavior for a group of images. + * Images within an ImageCoordinator are revealed together once all of them have loaded. + */ +export function ImageCoordinator(props: ImageCoordinatorProps) { + // If we are already inside another ImageCoordinator, just pass + // through children and coordinate loading at the root. + let ctx = useContext(props.group || DefaultImageGroup); + if (ctx !== defaultContext) { + return props.children; + } + + return ; +} + +function ImageCoordinatorRoot(props: ImageCoordinatorProps) { + let {children, timeout = 5000, group = DefaultImageGroup} = props; + let [{loadedAll, timedOut, loadStartTime}, dispatch] = useReducer(reducer, { + loadedAll: true, + timedOut: false, + loadStartTime: 0, + loaded: new Map() + }); + + let register = useCallback((url: string) => dispatch({type: 'register', url}), []); + let unregister = useCallback((url: string) => dispatch({type: 'unregister', url}), []); + let load = useCallback((url: string) => dispatch({type: 'load', url}), []); + + useEffect(() => { + if (!loadedAll) { + let timeoutId = setTimeout(() => { + dispatch({type: 'timeout'}); + }, loadStartTime + timeout - Date.now()); + + return () => clearTimeout(timeoutId); + } + }, [loadStartTime, loadedAll, timeout]); + + let revealAll = loadedAll || timedOut; + return useMemo(() => ( + + {children} + + ), [group, children, revealAll, register, unregister, load]); +} diff --git a/packages/@react-spectrum/s2/src/Link.tsx b/packages/@react-spectrum/s2/src/Link.tsx index 63b5d578046..88855cab268 100644 --- a/packages/@react-spectrum/s2/src/Link.tsx +++ b/packages/@react-spectrum/s2/src/Link.tsx @@ -11,11 +11,13 @@ */ import {ContextValue, LinkRenderProps, Link as RACLink, LinkProps as RACLinkProps} from 'react-aria-components'; -import {createContext, forwardRef, ReactNode} from 'react'; +import {createContext, forwardRef, ReactNode, useContext} from 'react'; import {FocusableRef, FocusableRefValue} from '@react-types/shared'; import {focusRing, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; +import {SkeletonContext, useSkeletonText} from './Skeleton'; import {style} from '../style/spectrum-theme' with {type: 'macro'}; import {useFocusableRef} from '@react-spectrum/utils'; +import {useLayoutEffect} from '@react-aria/utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; interface LinkStyleProps { @@ -38,7 +40,7 @@ export interface LinkProps extends Omit>>(null); -const link = style({ +const link = style({ ...focusRing(), borderRadius: 'sm', font: { @@ -83,15 +85,27 @@ const link = style({ function Link(props: LinkProps, ref: FocusableRef) { [props, ref] = useSpectrumContextProps(props, ref, LinkContext); - let {variant = 'primary', staticColor, isQuiet, isStandalone, UNSAFE_style, UNSAFE_className = '', styles} = props; + let {variant = 'primary', staticColor, isQuiet, isStandalone, UNSAFE_style, UNSAFE_className = '', styles, children} = props; let domRef = useFocusableRef(ref); + let isSkeleton = useContext(SkeletonContext) || false; + [children, UNSAFE_style] = useSkeletonText(children, UNSAFE_style); + + useLayoutEffect(() => { + if (domRef.current) { + // TODO: should RAC Link pass through inert? + domRef.current.inert = isSkeleton; + } + }, [domRef, isSkeleton]); + return ( UNSAFE_className + link({...renderProps, variant, staticColor, isQuiet, isStandalone}, styles)} /> + className={renderProps => UNSAFE_className + link({...renderProps, variant, staticColor, isQuiet, isStandalone, isSkeleton}, styles)}> + {children} + ); } diff --git a/packages/@react-spectrum/s2/src/Menu.tsx b/packages/@react-spectrum/s2/src/Menu.tsx index 63864e4e1fa..bafa0263622 100644 --- a/packages/@react-spectrum/s2/src/Menu.tsx +++ b/packages/@react-spectrum/s2/src/Menu.tsx @@ -36,8 +36,9 @@ import {createContext, forwardRef, JSX, ReactNode, useContext, useRef} from 'rea import {divider} from './Divider'; import {DOMRef, DOMRefValue} from '@react-types/shared'; import {forwardRefType} from './types'; -import {HeaderContext, HeadingContext, ImageContext, KeyboardContext, Text, TextContext} from './Content'; +import {HeaderContext, HeadingContext, KeyboardContext, Text, TextContext} from './Content'; import {IconContext} from './Icon'; // chevron right removed?? +import {ImageContext} from './Image'; import LinkOutIcon from '../ui-icons/LinkOut'; import {mergeStyles} from '../style/runtime'; import {Placement, useLocale} from 'react-aria'; @@ -228,6 +229,7 @@ let image = style({ marginEnd: 'text-to-visual', marginTop: fontRelative(6), // made up, need feedback alignSelf: 'center', + borderRadius: 'sm', size: { default: 40, size: { @@ -470,7 +472,7 @@ export function MenuItem(props: MenuItemProps) { } }], [KeyboardContext, {styles: keyboard({size, isDisabled: renderProps.isDisabled})}], - [ImageContext, {className: image({size})}] + [ImageContext, {styles: image({size})}] ]}> {renderProps.selectionMode === 'single' && !isLink && !renderProps.hasSubmenu && } {renderProps.selectionMode === 'multiple' && !isLink && !renderProps.hasSubmenu && ( diff --git a/packages/@react-spectrum/s2/src/Meter.tsx b/packages/@react-spectrum/s2/src/Meter.tsx index 0c599491951..9bf6ef24dd6 100644 --- a/packages/@react-spectrum/s2/src/Meter.tsx +++ b/packages/@react-spectrum/s2/src/Meter.tsx @@ -21,6 +21,8 @@ import {DOMRef, DOMRefValue} from '@react-types/shared'; import {FieldLabel} from './Field'; import {fieldLabel, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; import {size, style} from '../style/spectrum-theme' with {type: 'macro'}; +import {SkeletonWrapper} from './Skeleton'; +import {Text} from './Content'; import {useDOMRef} from '@react-spectrum/utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; @@ -121,10 +123,12 @@ function Meter(props: MeterProps, ref: DOMRef) { {({percentage, valueText}) => ( <> {label && {label}} - {label && {valueText}} -
-
-
+ {label && {valueText}} + +
+
+
+ )} diff --git a/packages/@react-spectrum/s2/src/Skeleton.tsx b/packages/@react-spectrum/s2/src/Skeleton.tsx new file mode 100644 index 00000000000..ff218feefd3 --- /dev/null +++ b/packages/@react-spectrum/s2/src/Skeleton.tsx @@ -0,0 +1,142 @@ +/* + * 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 {cloneElement, createContext, CSSProperties, ReactElement, ReactNode, Ref, useCallback, useContext, useRef} from 'react'; +import {colorToken} from '../style/tokens' with {type: 'macro'}; +import {mergeRefs} from '@react-aria/utils'; +import {mergeStyles} from '../style/runtime'; +import {raw} from '../style/style-macro' with {type: 'macro'}; +import {style} from '../style/spectrum-theme' with {type: 'macro'}; +import {StyleString} from '../style/types'; + +let reduceMotion = typeof window?.matchMedia === 'function' + ? window.matchMedia('(prefers-reduced-motion: reduce)').matches + : false; + +export function useLoadingAnimation(isAnimating: boolean) { + let animationRef = useRef(null); + return useCallback((element: HTMLElement | null) => { + if (isAnimating && !animationRef.current && element && !reduceMotion) { + // Use web animation API instead of CSS animations so that we can + // synchronize it between all loading elements on the page (via startTime). + animationRef.current = element.animate( + [ + {backgroundPosition: '100%'}, + {backgroundPosition: '0%'} + ], + { + duration: 2000, + iterations: Infinity, + easing: 'ease-in-out' + } + ); + animationRef.current.startTime = 0; + } else if (!isAnimating && animationRef.current) { + animationRef.current.cancel(); + animationRef.current = null; + } + }, [isAnimating]); +} + +export type SkeletonElement = ReactElement<{ + children?: ReactNode, + className?: string, + ref?: Ref, + inert?: boolean | 'true' +}>; + +export const SkeletonContext = createContext(null); +export function useIsSkeleton(): boolean { + return useContext(SkeletonContext) || false; +} + +export interface SkeletonProps { + children: ReactNode, + isLoading: boolean +} + +export function Skeleton({children, isLoading}: SkeletonProps) { + // Disable all form components inside a skeleton. + return ( + + {children} + + ); +} + +export const loadingStyle = raw(` + background-image: linear-gradient(to right, ${colorToken('gray-100')} 33%, light-dark(${colorToken('gray-25')}, ${colorToken('gray-300')}), ${colorToken('gray-100')} 66%); + background-size: 300%; + * { + visibility: hidden; + } +`, 'UNSAFE_overrides'); + +export function useSkeletonText(children: ReactNode, style: CSSProperties | undefined): [ReactNode, CSSProperties | undefined] { + let isSkeleton = useContext(SkeletonContext); + if (isSkeleton) { + children = {children}; + style = { + ...style, + // This ensures the ellipsis on truncated text is also hidden. + // -webkit-text-fill-color overrides any `color` property that is also set. + WebkitTextFillColor: 'transparent' + }; + } + return [children, style]; +} + +// Rendered inside to create skeleton line boxes via box-decoration-break. +export function SkeletonText({children}) { + return ( + + {children} + + ); +} + +// Clones the child element and displays it with skeleton styling. +export function SkeletonWrapper({children}: {children: SkeletonElement}) { + let isLoading = useContext(SkeletonContext); + let animation = useLoadingAnimation(isLoading || false); + if (isLoading == null) { + return children; + } + + let childRef = 'ref' in children ? children.ref as any : children.props.ref; + return ( + + {isLoading ? cloneElement(children, { + ref: mergeRefs(childRef, animation), + className: (children.props.className || '') + ' ' + loadingStyle, + inert: 'true' + }) : children} + + ); +} + +// Adds default border radius around icons when displayed in a skeleton. +export function useSkeletonIcon(styles: StyleString): StyleString { + let isSkeleton = useContext(SkeletonContext); + if (isSkeleton) { + return mergeStyles(style({borderRadius: 'sm'}), styles); + } + return styles || '' as StyleString; +} diff --git a/packages/@react-spectrum/s2/src/SkeletonCollection.tsx b/packages/@react-spectrum/s2/src/SkeletonCollection.tsx new file mode 100644 index 00000000000..fc6c5ea7be9 --- /dev/null +++ b/packages/@react-spectrum/s2/src/SkeletonCollection.tsx @@ -0,0 +1,36 @@ +/* + * 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 {createLeafComponent} from '@react-aria/collections'; +import {ReactNode} from 'react'; +import {Skeleton} from './Skeleton'; + +export interface SkeletonCollectionProps { + children: () => ReactNode +} + +let cache = new WeakMap(); +export const SkeletonCollection = createLeafComponent('skeleton', (props: SkeletonCollectionProps, ref, node) => { + // Cache rendering based on node object identity. This allows the children function to randomize + // its content (e.g. heights) and preserve on re-renders. + // TODO: do we need a `dependencies` prop here? + let cached = cache.get(node); + if (!cached) { + cached = ( + + {props.children()} + + ); + cache.set(node, cached); + } + return cached; +}); diff --git a/packages/@react-spectrum/s2/src/StatusLight.tsx b/packages/@react-spectrum/s2/src/StatusLight.tsx index 5e5c5b60c04..662c23fdf39 100644 --- a/packages/@react-spectrum/s2/src/StatusLight.tsx +++ b/packages/@react-spectrum/s2/src/StatusLight.tsx @@ -17,7 +17,9 @@ import {createContext, forwardRef, ReactNode} from 'react'; import {filterDOMProps} from '@react-aria/utils'; import {getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; import {size, style} from '../style/spectrum-theme' with {type: 'macro'}; +import {Text} from './Content'; import {useDOMRef} from '@react-spectrum/utils'; +import {useIsSkeleton} from './Skeleton'; import {useSpectrumContextProps} from './useSpectrumContextProps'; interface StatusLightStyleProps { @@ -64,7 +66,7 @@ const wrapper = style({ disableTapHighlight: true }, getAllowedOverrides()); -const light = style({ +const light = style({ size: { size: { S: 8, @@ -94,7 +96,8 @@ const light = style({ cinnamon: 'cinnamon', brown: 'brown', silver: 'silver' - } + }, + isSkeleton: 'gray-200' } }); @@ -102,6 +105,7 @@ function StatusLight(props: StatusLightProps, ref: DOMRef) { [props, ref] = useSpectrumContextProps(props, ref, StatusLightContext); let {children, size = 'M', variant, role, UNSAFE_className = '', UNSAFE_style, styles} = props; let domRef = useDOMRef(ref); + let isSkeleton = useIsSkeleton(); if (!children && !props['aria-label']) { console.warn('If no children are provided, an aria-label must be specified'); @@ -119,11 +123,11 @@ function StatusLight(props: StatusLightProps, ref: DOMRef) { style={UNSAFE_style} className={UNSAFE_className + wrapper({size, variant}, styles)}> - - {children} + {children}
); } diff --git a/packages/@react-spectrum/s2/src/ToggleButton.tsx b/packages/@react-spectrum/s2/src/ToggleButton.tsx index 85599b7ce99..e5bc90b4ab7 100644 --- a/packages/@react-spectrum/s2/src/ToggleButton.tsx +++ b/packages/@react-spectrum/s2/src/ToggleButton.tsx @@ -18,9 +18,11 @@ import {FocusableRef, FocusableRefValue} from '@react-types/shared'; import {fontRelative, style} from '../style/spectrum-theme' with {type: 'macro'}; import {IconContext} from './Icon'; import {pressScale} from './pressScale'; +import {SkeletonContext} from './Skeleton'; import {StyleProps} from './style-utils'; import {Text, TextContext} from './Content'; import {useFocusableRef} from '@react-spectrum/utils'; +import {useFormProps} from './Form'; import {useSpectrumContextProps} from './useSpectrumContextProps'; export interface ToggleButtonProps extends Omit, StyleProps, ActionButtonStyleProps { @@ -34,6 +36,7 @@ export const ToggleButtonContext = createContext) { [props, ref] = useSpectrumContextProps(props, ref, ToggleButtonContext); + props = useFormProps(props as any); let domRef = useFocusableRef(ref); return ( ; -export type StylesPropWithHeight = StyleString<(typeof allowedOverrides)[number] | (typeof heightProperties)[number]>; +export type StylesPropWithHeight = StyleString<(typeof allowedOverrides)[number] | (typeof widthProperties)[number] | (typeof heightProperties)[number]>; export type StylesPropWithoutWidth = StyleString<(typeof allowedOverrides)[number]>; export interface UnsafeStyles { /** Sets the CSS [className](https://developer.mozilla.org/en-US/docs/Web/API/Element/className) for the element. Only use as a **last resort**. Use the `style` macro via the `styles` prop instead. */ diff --git a/packages/@react-spectrum/s2/stories/Card.stories.tsx b/packages/@react-spectrum/s2/stories/Card.stories.tsx new file mode 100644 index 00000000000..9f781e86d8f --- /dev/null +++ b/packages/@react-spectrum/s2/stories/Card.stories.tsx @@ -0,0 +1,309 @@ +/* + * 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 {ActionMenu, AssetCard, Avatar, Badge, Button, Card, CardPreview, CardProps, CollectionCardPreview, Content, Divider, Footer, Image, MenuItem, Meter, ProductCard, Skeleton, StatusLight, Text, UserCard} from '../src'; +import Folder from '../s2wf-icons/S2_Icon_Folder_20_N.svg'; +import FolderGradient from 'illustration:../spectrum-illustrations/gradient/S2_fill_folderClose_generic2_160.svg'; +import type {Meta} from '@storybook/react'; +import Project from '../s2wf-icons/S2_Icon_Project_20_N.svg'; +import Select from '../s2wf-icons/S2_Icon_Select_20_N.svg'; +import {style} from '../style/spectrum-theme' with {type: 'macro'}; + +const meta: Meta = { + component: Card, + parameters: { + layout: 'centered' + }, + tags: ['autodocs'], + args: { + isLoading: false + }, + argTypes: { + href: {table: {disable: true}}, + download: {table: {disable: true}}, + hrefLang: {table: {disable: true}}, + referrerPolicy: {table: {disable: true}}, + rel: {table: {disable: true}}, + routerOptions: {table: {disable: true}}, + ping: {table: {disable: true}}, + target: {table: {disable: true}}, + value: {table: {disable: true}}, + textValue: {table: {disable: true}}, + onAction: {table: {disable: true}}, + isDisabled: {table: {disable: true}} + }, + decorators: (children, {args}) => ( + + {children(args)} + + ) +}; + +export default meta; + +export const Example = (args: any) => ( +
+ + + + + + Card title + + Test + + Card description. Give a concise overview of the context or functionality that's mentioned in the card title. + + {args.size !== 'XS' && <> + +
+ Published +
+ } +
+ + + Card title + Card description. Give a concise overview of the context or functionality that's mentioned in the card title. + + {args.size !== 'XS' && <> + +
+ Published +
+ } +
+
+); + +const specificArgTypes = { + density: { + table: { + disable: true + } + } +}; + +export const Asset = (args: any) => ( +
+ + + + + + Desert Sunset + PNG • 2/3/2024 + + + + + + + + Projects + 10 items • 6/14/2024 + + +
+); + +Asset.argTypes = specificArgTypes; + +export const User = (args: any) => ( +
+ + + + + + + Card title + Card description. Give a concise overview of the context or functionality that's mentioned in the card title. + +
+ Available +
+
+ + + + Card title + Card description. Give a concise overview of the context or functionality that's mentioned in the card title. + +
+ Available +
+
+
+); + +User.argTypes = { + ...specificArgTypes, + variant: { + control: 'radio', + options: ['primary', 'secondary', 'tertiary'] + } +}; + +export const Product = (args: any) => ( +
+ + + + + + + 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. + +
+ +
+
+
+); + +Product.argTypes = { + ...specificArgTypes, + variant: { + control: 'radio', + options: ['primary', 'secondary', 'tertiary'] + } +}; + +export const Collection = (args: any) => ( +
+ + + + + + + + + Travel +
+ + 20 photos +
+
+
+ + + + + + + + Architecture +
+ + 15 photos +
+
+
+
+); + +export const PreviewOverlay = (args: any) => ( + + + + + Free + + + + +); + +export const Custom = (args: any) => ( +
+ + + + + +
+
+