Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
fb91e0d
chore(sb10): bump storybook and related packages to ^10.0.0
snowystinger Apr 24, 2026
657bada
chore(sb10): adopt S2 provider addon restructure and dark-mode rewrit…
snowystinger Apr 24, 2026
ac448c1
chore(sb10): convert storybook-builder-parcel to ESM
snowystinger Apr 24, 2026
0343aad
chore(sb10): declare @parcel/utils dep and mark broken preview helper…
snowystinger Apr 24, 2026
f17d5a0
chore(sb10): convert storybook-react-parcel to ESM and resolve core.b…
snowystinger Apr 24, 2026
e1a72ef
docs(sb10): note why core.renderer stays bare (not path-resolved)
snowystinger Apr 24, 2026
1465235
chore(sb10): convert .storybook/main.js to ESM with resolved addon paths
snowystinger Apr 24, 2026
ac40475
fix(sb10): restore story globs dropped during main.mjs conversion
snowystinger Apr 24, 2026
7aca8ea
chore(sb10): resolve local addon path in .storybook-s2/main.ts
snowystinger Apr 24, 2026
a092284
chore(sb10): convert .chromatic and .chromatic-fc main to ESM
snowystinger Apr 24, 2026
ccceb0d
fix(sb10): emit valid ESM importers map in preview generator
snowystinger Apr 24, 2026
354adbe
fix(sb10): rename register.js → manager.js for local addons, point ma…
snowystinger Apr 24, 2026
deaa652
fix(sb10): add missing useEffect deps and guard select value in S2 pr…
snowystinger Apr 24, 2026
84b4608
chore: normalize storybook-react-parcel package.json formatting (yarn…
snowystinger Apr 24, 2026
3deb928
chore(sb10): remove dead localAddon helpers and unused isPreview import
snowystinger Apr 24, 2026
f3cd214
fix(sb10): disable FEATURES.highlight to avoid Parcel CJS circular-de…
snowystinger Apr 24, 2026
0a49c83
fix(sb10): patch preview-api lazy getters to use deferred init; resto…
snowystinger Apr 25, 2026
346d234
feat(parcel-resolver-storybook): externalize storybook runtime module…
snowystinger Apr 25, 2026
68dee0c
fix(storybook-builder-parcel): hoist runtime import, use public previ…
snowystinger Apr 25, 2026
edbfeca
refactor(storybook-builder-parcel): align iframe & preview entry with…
snowystinger Apr 25, 2026
818a6fa
fix(sb10): remove highlight-init defer patch (no longer needed after …
snowystinger Apr 25, 2026
ccf302d
chore(storybook-builder-parcel): drop unused previewPresets export, f…
snowystinger Apr 25, 2026
6aeb231
chore(storybook-builder-parcel): move iframe template to standalone H…
snowystinger Apr 25, 2026
bfd38df
chore(parcel-resolver-storybook): emit ESM for story: virtual modules
snowystinger Apr 25, 2026
5f3eb1c
fix(sb10): wire dark/light mode switching in .storybook (v3/rac) preview
snowystinger Apr 25, 2026
bcd1e55
docs(builder): annotate key decisions with upstream references
snowystinger Apr 25, 2026
bf62a86
Merge branch 'main' of github.com:adobe/react-spectrum into storybook…
snowystinger Apr 25, 2026
86c8a3b
Revert "Merge branch 'main' of github.com:adobe/react-spectrum into s…
snowystinger Apr 25, 2026
0ab4169
fix(s2/Card): fire press and action callbacks on standalone Card
starboyvarun Apr 26, 2026
fc3a567
fix: sort imports alphabetically to satisfy rsp-rules/sort-imports li…
starboyvarun Apr 26, 2026
d7176d5
fix(s2/Card): add pointer cursor and userSelect none for interactive …
starboyvarun May 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 88 additions & 3 deletions packages/@react-spectrum/s2/src/Card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,14 @@ import {ImageContext} from './Image';
import {ImageCoordinator} from './ImageCoordinator';
import {inertValue} from 'react-aria/private/utils/inertValue';
import {Link} from 'react-aria-components/Link';
import {mergeProps} from 'react-aria/mergeProps';
import {mergeStyles} from '../style/runtime';
import {pressScale} from './pressScale';
import {SkeletonContext, SkeletonWrapper, useIsSkeleton} from './Skeleton';
import {useDOMRef} from './useDOMRef';
import {useFocusRing} from 'react-aria/useFocusRing';
import {useHover} from 'react-aria/useHover';
import {usePress} from 'react-aria/usePress';
import {useSpectrumContextProps} from './useSpectrumContextProps';

interface CardRenderProps {
Expand Down Expand Up @@ -121,10 +125,12 @@ let card = style({
contain: 'layout',
disableTapHighlight: true,
userSelect: {
isCardView: 'none'
isCardView: 'none',
isInteractive: 'none'
},
cursor: {
isLink: 'pointer'
isLink: 'pointer',
isInteractive: 'pointer'
},
width: {
size: {
Expand Down Expand Up @@ -396,9 +402,51 @@ export const Card = forwardRef(function Card(props: CardProps, ref: DOMRef<HTMLD
[props] = useSpectrumContextProps(props, ref, CardContext);
let {ElementType, layout} = useContext(InternalCardViewContext);
let domRef = useDOMRef(ref);
let {density = 'regular', size = 'M', variant = 'primary', UNSAFE_className = '', UNSAFE_style, styles, id, ...otherProps} = props;
let {
density = 'regular',
size = 'M',
variant = 'primary',
UNSAFE_className = '',
UNSAFE_style,
styles,
id,
onPress,
onPressStart,
onPressEnd,
onPressChange,
onPressUp,
onAction,
isDisabled,
...otherProps
} = props;
let isQuiet = variant === 'quiet';
let isSkeleton = useIsSkeleton();

// True when the card is used standalone (not inside CardView) and the caller
// has provided at least one press/action callback.
let isInteractiveStandalone = ElementType === 'div' && !isSkeleton && !props.href &&
!!(onPress || onPressStart || onPressEnd || onPressChange || onPressUp || onAction);

// Hooks must be called unconditionally (React rules of hooks).
// isDisabled is set to true when not in interactive standalone mode so the
// hooks are effectively no-ops in those code paths.
let {pressProps, isPressed: isInteractivePressed} = usePress({
ref: domRef,
onPress: (e) => {
onPress?.(e);
onAction?.();
},
onPressStart,
onPressEnd,
onPressChange,
onPressUp,
isDisabled: isDisabled || !isInteractiveStandalone
});
let {hoverProps, isHovered: isInteractiveHovered} = useHover({
isDisabled: isDisabled || !isInteractiveStandalone
});
let {focusProps, isFocusVisible: isInteractiveFocusVisible} = useFocusRing();

let children = (
<Provider
values={[
Expand Down Expand Up @@ -452,6 +500,43 @@ export const Card = forwardRef(function Card(props: CardProps, ref: DOMRef<HTMLD
}

if (ElementType === 'div' || isSkeleton) {
if (isInteractiveStandalone) {
return (
<div
{...mergeProps(filterDOMProps(otherProps), pressProps, hoverProps, focusProps)}
id={id != null ? String(id) : undefined}
ref={domRef}
role="button"
tabIndex={isDisabled ? undefined : 0}
aria-disabled={isDisabled ? true : undefined}
className={
UNSAFE_className + card({
size,
density,
variant,
isCardView: false,
isInteractive: true,
isHovered: isInteractiveHovered,
isFocusVisible: isInteractiveFocusVisible,
isSelected: false
}, styles)
}
style={variant === 'quiet' ? UNSAFE_style : press({isPressed: isInteractivePressed})}>
<InternalCardContext.Provider value={{
size,
isQuiet,
isCheckboxSelection: false,
isHovered: isInteractiveHovered,
isFocusVisible: isInteractiveFocusVisible,
isSelected: false,
isPressed: isInteractivePressed
}}>
{children}
</InternalCardContext.Provider>
</div>
);
}

return (
<div
{...filterDOMProps(otherProps)}
Expand Down
141 changes: 141 additions & 0 deletions packages/@react-spectrum/s2/test/Card.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/*
* Copyright 2025 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 {act, pointerMap, render} from '@react-spectrum/test-utils-internal';
import {Card} from '../src/Card';
import {Content, Text} from '../src/Content';
import React from 'react';
import userEvent from '@testing-library/user-event';

describe('Card', () => {
let user;
beforeAll(() => {
jest.useFakeTimers();
user = userEvent.setup({delay: null, pointerMap});
});

afterEach(() => {
jest.clearAllMocks();
act(() => jest.runAllTimers());
});

afterAll(() => {
jest.restoreAllMocks();
});

it('renders as a plain div when no press callbacks are provided', () => {
let {getByText} = render(
<Card>
<Content><Text slot="title">Static Card</Text></Content>
</Card>
);
let el = getByText('Static Card').closest('[class]')!.parentElement!;
expect(el.tagName).toBe('DIV');
expect(el).not.toHaveAttribute('role');
expect(el).not.toHaveAttribute('tabindex');
});

it('renders as role=button and fires onPress when onPress is provided', async () => {
let onPress = jest.fn();
let {getByRole} = render(
<Card onPress={onPress}>
<Content><Text slot="title">Interactive Card</Text></Content>
</Card>
);

let card = getByRole('button');
expect(card).toBeInTheDocument();
expect(card).toHaveAttribute('tabindex', '0');

await user.click(card);
expect(onPress).toHaveBeenCalledTimes(1);
});

it('fires onAction when onAction is provided', async () => {
let onAction = jest.fn();
let {getByRole} = render(
<Card onAction={onAction}>
<Content><Text slot="title">Action Card</Text></Content>
</Card>
);

let card = getByRole('button');
await user.click(card);
expect(onAction).toHaveBeenCalledTimes(1);
});

it('fires both onPress and onAction when both are provided', async () => {
let onPress = jest.fn();
let onAction = jest.fn();
let {getByRole} = render(
<Card onPress={onPress} onAction={onAction}>
<Content><Text slot="title">Both Callbacks Card</Text></Content>
</Card>
);

let card = getByRole('button');
await user.click(card);
expect(onPress).toHaveBeenCalledTimes(1);
expect(onAction).toHaveBeenCalledTimes(1);
});

it('fires onPressStart and onPressEnd when provided', async () => {
let onPressStart = jest.fn();
let onPressEnd = jest.fn();
let {getByRole} = render(
<Card onPressStart={onPressStart} onPressEnd={onPressEnd}>
<Content><Text slot="title">Press Events Card</Text></Content>
</Card>
);

let card = getByRole('button');
await user.click(card);
expect(onPressStart).toHaveBeenCalledTimes(1);
expect(onPressEnd).toHaveBeenCalledTimes(1);
});

it('does not fire press callbacks when disabled', async () => {
let onPress = jest.fn();
let {getByRole} = render(
<Card onPress={onPress} isDisabled>
<Content><Text slot="title">Disabled Card</Text></Content>
</Card>
);

let card = getByRole('button');
expect(card).not.toHaveAttribute('tabindex');
expect(card).toHaveAttribute('aria-disabled', 'true');

await user.click(card);
expect(onPress).not.toHaveBeenCalled();
});

it('does not expose role=button when no press callbacks are provided', () => {
let {queryByRole} = render(
<Card>
<Content><Text slot="title">Static Card</Text></Content>
</Card>
);
expect(queryByRole('button')).toBeNull();
});

it('has pointer cursor and no user-select when interactive', async () => {
let onPress = jest.fn();
let {getByRole} = render(
<Card onPress={onPress}>
<Content><Text slot="title">Interactive Card</Text></Content>
</Card>
);
let card = getByRole('button');
expect(card).toHaveStyle({cursor: 'pointer', userSelect: 'none'});
});
});
Loading