Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 9 additions & 0 deletions .changeset/card-action-data-attributes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@frontify/fondue-components": minor
---

feat(Card.Action): forward arbitrary `data-*` attributes

`Card.Action` now spreads unknown props (notably `data-*` attributes, e.g. an
analytics or onboarding-tour selector) onto the rendered element, so consumers
no longer need an extra wrapper to carry them.
11 changes: 11 additions & 0 deletions .changeset/card-banner-image-padding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@frontify/fondue-components": minor
---

feat(Card.BannerImage): add a `padding` prop

`Card.BannerImage` now accepts `padding="none" | "small" | "medium" | "large"`
(none by default; small 12px, medium 24px, large 32px). Padding is applied
inside the banner and pairs best with `fit="contain"`, giving previews such as
logos or icons breathing room without dropping out of the component. Backward
compatible — existing images default to `none`.
11 changes: 11 additions & 0 deletions .changeset/card-banner-tone.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@frontify/fondue-components": minor
---

feat(Card.Banner): add a `tone` prop

`Card.Banner` now accepts `tone="dim" | "inverted"`. `inverted` renders the dark
drop-target state (near-black background, white icon), while `dim` pins the
background and opts out of the implicit hover/active background shift that
otherwise applies when a `Card.BannerIcon` is nested. Leaving `tone` unset
preserves the previous behavior, so existing consumers are unaffected.
9 changes: 9 additions & 0 deletions .changeset/card-root-classname.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@frontify/fondue-components": minor
---

feat(Card.Root): forward `className`

`Card.Root` now forwards a `className` onto its root element, merged after the
internal styles. This enables layout hooks such as Tailwind's `group` (e.g. to
drive `group-hover:` styles on descendants) without an extra wrapper element.
149 changes: 149 additions & 0 deletions packages/components/src/components/Card/Card.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/* (c) Copyright Frontify Ltd., all rights reserved. */

import {
IconArrowAlignDown,
IconCog,
IconDotsVertical,
IconExclamationMarkTriangle,
Expand Down Expand Up @@ -33,6 +34,8 @@ import {
CardThumbnailIcon,
CardThumbnailImage,
CardTitle,
type CardBannerFit,
type CardBannerImagePadding,
} from './Card';

type Story = StoryObj<typeof meta>;
Expand Down Expand Up @@ -79,6 +82,105 @@ const singleCardDecorators: Story['decorators'] = [
),
];

// Inline SVG logo used as the padded image source, so the story has no network dependency.
const LOGO_ICON_DATA_URI = `data:image/svg+xml,${encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#4c43d6"><path d="M12 2 3 7v10l9 5 9-5V7zm0 2.31L18.18 8 12 11.69 5.82 8zM5 9.7l6 3.58v6.32l-6-3.33zm14 0v6.57l-6 3.33v-6.32z"/></svg>',
)}`;

type BannerImagePaddingControls = { padding: CardBannerImagePadding; fit: CardBannerFit };

// Story bodies are extracted into named components so `useState` runs inside a React component.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this comment needed?

const BannerImagePaddingDemo = ({ padding, fit }: BannerImagePaddingControls) => {
const [selected, setSelected] = useState(false);

return (
<Card.Root href="#" selected={selected} onSelect={() => setSelected((s) => !s)}>
<Card.Banner>
<Card.BannerImage src={LOGO_ICON_DATA_URI} alt="Logo preview" fit={fit} padding={padding} />
</Card.Banner>

<Card.Title>[Logo asset]</Card.Title>
<Card.Description>{`padding="${padding}" · fit="${fit}"`}</Card.Description>

<Card.Action>
<Card.ActionButton aria-label="More actions">
<IconDotsVertical size={20} />
</Card.ActionButton>
</Card.Action>
</Card.Root>
);
};

const BannerToneInvertedDemo = () => {
const [selected, setSelected] = useState(false);

return (
<Card.Root href="#" selected={selected} onSelect={() => setSelected((s) => !s)}>
<Card.Banner tone="inverted">
<Card.BannerIcon>
<IconArrowAlignDown size={32} />
</Card.BannerIcon>
</Card.Banner>

<Card.Title>[Drop files here]</Card.Title>
<Card.Description>Release to move into folder</Card.Description>

<Card.Action>
<Card.ActionButton aria-label="More actions">
<IconDotsVertical size={20} />
</Card.ActionButton>
</Card.Action>
</Card.Root>
);
};

const BannerToneDimComparison = () => {
const [selectedDefault, setSelectedDefault] = useState(false);
const [selectedDim, setSelectedDim] = useState(false);

return (
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
<div style={{ width: 280 }}>
<Card.Root href="#" selected={selectedDefault} onSelect={() => setSelectedDefault((s) => !s)}>
<Card.Banner>
<Card.BannerIcon>
<IconImageStack size={32} />
</Card.BannerIcon>
</Card.Banner>

<Card.Title>Default (no tone)</Card.Title>
<Card.Description>Hover → banner brightens</Card.Description>

<Card.Action>
<Card.ActionButton aria-label="More actions">
<IconDotsVertical size={20} />
</Card.ActionButton>
</Card.Action>
</Card.Root>
</div>

<div style={{ width: 280 }}>
<Card.Root href="#" selected={selectedDim} onSelect={() => setSelectedDim((s) => !s)}>
<Card.Banner tone="dim">
<Card.BannerIcon>
<IconImageStack size={32} />
</Card.BannerIcon>
</Card.Banner>

<Card.Title>tone=&quot;dim&quot;</Card.Title>
<Card.Description>Hover → banner stays dim</Card.Description>

<Card.Action>
<Card.ActionButton aria-label="More actions">
<IconDotsVertical size={20} />
</Card.ActionButton>
</Card.Action>
</Card.Root>
</div>
</div>
);
};

export const Default: Story = {
decorators: singleCardDecorators,
render: () => {
Expand Down Expand Up @@ -175,6 +277,33 @@ export const WithBadges: Story = {
},
};

export const BannerImageWithPadding: StoryObj<BannerImagePaddingControls> = {
decorators: [
(Story) => (
<div style={{ width: 280 }}>
<Story />
</div>
),
],
args: {
padding: 'medium',
fit: 'contain',
},
argTypes: {
padding: {
control: 'select',
options: ['none', 'small', 'medium', 'large'],
description: 'Inner padding between the image and the banner edges (small 12px, medium 24px, large 32px).',
},
fit: {
control: 'inline-radio',
options: ['cover', 'contain'],
description: 'How the image fits the banner. Padding pairs best with `contain`.',
},
},
render: (args) => <BannerImagePaddingDemo {...args} />,
};

export const SmallBannerWithImages: Story = {
decorators: singleCardDecorators,
render: () => {
Expand Down Expand Up @@ -237,6 +366,26 @@ export const SmallBannerWithIcon: Story = {
},
};

export const BannerToneInverted: Story = {
decorators: singleCardDecorators,
render: () => <BannerToneInvertedDemo />,
};

export const BannerToneDim: Story = {
name: 'BannerTone dim (hover to compare)',
parameters: {
docs: {
description: {
story:
'The effect of `tone="dim"` only shows on hover. A tone-less banner that contains a ' +
'`Card.BannerIcon` brightens to `surface-hover` when the interactive card is hovered; ' +
'`tone="dim"` pins `surface-dim` and opts out of that shift. Hover each card to compare.',
},
},
},
render: () => <BannerToneDimComparison />,
};

export const BannerIconWithThumbnail: Story = {
decorators: singleCardDecorators,
render: () => {
Expand Down
4 changes: 4 additions & 0 deletions packages/components/src/components/Card/Card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@
type CardBannerFit,
type CardBannerIconProps,
type CardBannerIconVariant,
type CardBannerImagePadding,
type CardBannerImageProps,
type CardBannerProps,
type CardBannerSize,
type CardBannerTone,
} from './CardBanner';
import {
CardBadges,
Expand Down Expand Up @@ -56,9 +58,11 @@
CardBannerFit,
CardBannerIconProps,
CardBannerIconVariant,
CardBannerImagePadding,

Check warning on line 61 in packages/components/src/components/Card/Card.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use `export…from` to re-export `CardBannerImagePadding`.

See more on https://sonarcloud.io/project/issues?id=Frontify_arcade&issues=AZ7Um8GVLQH8OMVrXq7C&open=AZ7Um8GVLQH8OMVrXq7C&pullRequest=2769
CardBannerImageProps,
CardBannerProps,
CardBannerSize,
CardBannerTone,

Check warning on line 65 in packages/components/src/components/Card/Card.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use `export…from` to re-export `CardBannerTone`.

See more on https://sonarcloud.io/project/issues?id=Frontify_arcade&issues=AZ7Um8GVLQH8OMVrXq7D&open=AZ7Um8GVLQH8OMVrXq7D&pullRequest=2769
CardDescriptionProps,
CardRootProps,
CardThumbnailIconProps,
Expand Down
11 changes: 8 additions & 3 deletions packages/components/src/components/Card/CardAction.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,22 @@ import { forwardRef, type ComponentProps, type ForwardedRef, type MouseEventHand

import styles from './styles/card.module.scss';

/**
* Wraps a single action in the card's action area. Any extra props — notably
* `data-*` attributes (e.g. an analytics/onboarding-tour selector) — are
* forwarded to the underlying `<div>`, so consumers don't need an extra wrapper.
*/
export type CardActionProps = {
'data-test-id'?: string;
children?: ReactNode;
};
} & Omit<ComponentProps<'div'>, 'children' | 'ref' | 'className'>;

export const CardAction = (
{ 'data-test-id': dataTestId = 'fondue-card-action', children }: CardActionProps,
{ 'data-test-id': dataTestId = 'fondue-card-action', children, ...rest }: CardActionProps,
ref: ForwardedRef<HTMLDivElement>,
) => {
return (
<div ref={ref} className={styles.action} data-test-id={dataTestId}>
<div ref={ref} className={styles.action} data-test-id={dataTestId} {...rest}>
{children}
</div>
);
Expand Down
59 changes: 55 additions & 4 deletions packages/components/src/components/Card/CardBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,30 @@ import styles from './styles/card.module.scss';

export type CardBannerSize = 'small' | 'large';
export type CardBannerFit = 'cover' | 'contain';
/**
* Inner padding applied to a banner image, giving the preview breathing room
* inside the banner (e.g. logo or icon libraries).
*
* - `none` – no padding (default)
* - `small` – 12px
* - `medium` – 24px
* - `large` – 32px
*/
export type CardBannerImagePadding = 'none' | 'small' | 'medium' | 'large';

/**
* Background tone of the banner.
*
* - `dim` – pins `surface-dim` (the resting default) and opts out of the
* implicit hover/active background shift that otherwise kicks in when a
* `Card.BannerIcon` is nested.
* - `inverted` – near-black background (`primary-default`) with a white icon,
* for states like a folder drop target.
*
* When omitted, the banner keeps its legacy behavior: `surface-dim` at rest,
* shifting to hover/active surfaces on interaction when a `Card.BannerIcon` is present.
*/
export type CardBannerTone = 'dim' | 'inverted';

export type CardBannerProps = {
'data-test-id'?: string;
Expand All @@ -15,15 +39,21 @@ export type CardBannerProps = {
* @default 'large'
*/
size?: CardBannerSize;
/**
* Pins the banner background, overriding the implicit hover/active shift
* applied when a `Card.BannerIcon` is nested. Leave unset to keep the
* default behavior.
*/
tone?: CardBannerTone;
children?: ReactNode;
};

export const CardBanner = (
{ 'data-test-id': dataTestId = 'fondue-card-banner', size = 'large', children }: CardBannerProps,
{ 'data-test-id': dataTestId = 'fondue-card-banner', size = 'large', tone, children }: CardBannerProps,
ref: ForwardedRef<HTMLDivElement>,
) => {
return (
<div ref={ref} className={styles.banner} data-test-id={dataTestId} data-size={size}>
<div ref={ref} className={styles.banner} data-test-id={dataTestId} data-size={size} data-tone={tone}>
{children}
<div
className={styles.selectionIndicator}
Expand Down Expand Up @@ -53,14 +83,35 @@ export type CardBannerImageProps = {
* @default 'cover'
*/
fit?: CardBannerFit;
/**
* Inner padding between the image and the banner edges, giving the preview
* breathing room (e.g. logo or icon libraries). Pairs best with `fit="contain"`,
* which lets the padded image scale down without cropping.
* @default 'none'
*/
padding?: CardBannerImagePadding;
};

export const CardBannerImage = (
{ 'data-test-id': dataTestId = 'fondue-card-banner-image', src, alt = '', fit = 'cover' }: CardBannerImageProps,
{
'data-test-id': dataTestId = 'fondue-card-banner-image',
src,
alt = '',
fit = 'cover',
padding = 'none',
}: CardBannerImageProps,
ref: ForwardedRef<HTMLImageElement>,
) => {
return (
<img ref={ref} className={styles.bannerImage} data-test-id={dataTestId} data-fit={fit} src={src} alt={alt} />
<img
ref={ref}
className={styles.bannerImage}
data-test-id={dataTestId}
data-fit={fit}
data-padding={padding}
src={src}
alt={alt}
/>
);
};
CardBannerImage.displayName = 'Card.BannerImage';
Expand Down
Loading