From 5ef258bdc998216dd3b258dfc996df12e6329088 Mon Sep 17 00:00:00 2001 From: Marco Noleto Date: Tue, 16 Jun 2026 13:54:25 +0200 Subject: [PATCH 1/7] feat(Card.Banner): add image padding and banner tone props Two related additions to the Card.Banner family, addressing gaps hit while rebuilding web-app's library cards on the Card.* primitives: - Card.BannerImage gains a `padding` prop ('none' | 'small' | 'medium' | 'large'; none by default). Padding is applied inside the banner and pairs with `fit="contain"`, giving logo/icon previews 12/24/32px of breathing room without dropping out of the component. Tokens are mapped by value, not by same-named spacing tokens (--spacing-medium is 16px, not 24px). - Card.Banner gains a `tone` prop ('dim' | 'active' | 'inverted'). 'inverted' renders the dark drop-target state (near-black background + white icon); 'dim'/'active' pin the background and opt out of the implicit hover/active shift applied when a Card.BannerIcon is nested. Leaving `tone` unset preserves the previous behavior. This supersedes a separate BannerIcon dark variant: background is a banner concern, so one tone prop covers both the dark state and the "don't auto-change background" request. Includes Storybook stories (padded image, inverted/dim tones), unit tests, and JSDoc. Backward compatible. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/card-banner-image-padding.md | 11 +++ .changeset/card-banner-tone.md | 12 +++ .../src/components/Card/Card.stories.tsx | 84 +++++++++++++++++++ .../components/src/components/Card/Card.ts | 4 + .../src/components/Card/CardBanner.tsx | 60 ++++++++++++- .../components/Card/__tests__/Card.spec.tsx | 52 ++++++++++++ .../components/Card/styles/card.module.scss | 46 +++++++++- 7 files changed, 262 insertions(+), 7 deletions(-) create mode 100644 .changeset/card-banner-image-padding.md create mode 100644 .changeset/card-banner-tone.md diff --git a/.changeset/card-banner-image-padding.md b/.changeset/card-banner-image-padding.md new file mode 100644 index 0000000000..f169f66c85 --- /dev/null +++ b/.changeset/card-banner-image-padding.md @@ -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`. diff --git a/.changeset/card-banner-tone.md b/.changeset/card-banner-tone.md new file mode 100644 index 0000000000..e87bb2afa5 --- /dev/null +++ b/.changeset/card-banner-tone.md @@ -0,0 +1,12 @@ +--- +"@frontify/fondue-components": minor +--- + +feat(Card.Banner): add a `tone` prop + +`Card.Banner` now accepts `tone="dim" | "active" | "inverted"`. `inverted` +renders the dark drop-target state (near-black background, white icon), while +`dim`/`active` pin the background and opt 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. diff --git a/packages/components/src/components/Card/Card.stories.tsx b/packages/components/src/components/Card/Card.stories.tsx index c611a559a5..95fdf34119 100644 --- a/packages/components/src/components/Card/Card.stories.tsx +++ b/packages/components/src/components/Card/Card.stories.tsx @@ -175,6 +175,36 @@ export const WithBadges: Story = { }, }; +export const BannerImageWithPadding: Story = { + decorators: singleCardDecorators, + render: () => { + const [selected, setSelected] = useState(false); + + return ( + setSelected((s) => !s)}> + + {/* Padding gives a logo/icon preview breathing room. Pairs with fit="contain". */} + + + + [Logo asset] + SVG · 24px padding + + + + + + + + ); + }, +}; + export const SmallBannerWithImages: Story = { decorators: singleCardDecorators, render: () => { @@ -237,6 +267,60 @@ export const SmallBannerWithIcon: Story = { }, }; +export const BannerToneInverted: Story = { + decorators: singleCardDecorators, + render: () => { + const [selected, setSelected] = useState(false); + + return ( + setSelected((s) => !s)}> + {/* tone="inverted" renders the drop-target state: dark banner + white icon. */} + + + + + + + [Drop files here] + Release to move into folder + + + + + + + + ); + }, +}; + +export const BannerToneDim: Story = { + decorators: singleCardDecorators, + render: () => { + const [selected, setSelected] = useState(false); + + return ( + setSelected((s) => !s)}> + {/* tone="dim" pins surface-dim, opting out of the implicit hover/active shift. */} + + + + + + + [Collection name] + Background stays dim on hover + + + + + + + + ); + }, +}; + export const BannerIconWithThumbnail: Story = { decorators: singleCardDecorators, render: () => { diff --git a/packages/components/src/components/Card/Card.ts b/packages/components/src/components/Card/Card.ts index 685a89e989..406df1459a 100644 --- a/packages/components/src/components/Card/Card.ts +++ b/packages/components/src/components/Card/Card.ts @@ -16,9 +16,11 @@ import { type CardBannerFit, type CardBannerIconProps, type CardBannerIconVariant, + type CardBannerImagePadding, type CardBannerImageProps, type CardBannerProps, type CardBannerSize, + type CardBannerTone, } from './CardBanner'; import { CardBadges, @@ -56,9 +58,11 @@ export type { CardBannerFit, CardBannerIconProps, CardBannerIconVariant, + CardBannerImagePadding, CardBannerImageProps, CardBannerProps, CardBannerSize, + CardBannerTone, CardDescriptionProps, CardRootProps, CardThumbnailIconProps, diff --git a/packages/components/src/components/Card/CardBanner.tsx b/packages/components/src/components/Card/CardBanner.tsx index 5b39f679fb..23583d5487 100644 --- a/packages/components/src/components/Card/CardBanner.tsx +++ b/packages/components/src/components/Card/CardBanner.tsx @@ -7,6 +7,31 @@ 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. + * - `active` – pins `surface-active`. + * - `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' | 'active' | 'inverted'; export type CardBannerProps = { 'data-test-id'?: string; @@ -15,15 +40,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, ) => { return ( -
+
{children}
, ) => { return ( - {alt} + {alt} ); }; CardBannerImage.displayName = 'Card.BannerImage'; diff --git a/packages/components/src/components/Card/__tests__/Card.spec.tsx b/packages/components/src/components/Card/__tests__/Card.spec.tsx index 7f5cafde5b..5448f4a43d 100644 --- a/packages/components/src/components/Card/__tests__/Card.spec.tsx +++ b/packages/components/src/components/Card/__tests__/Card.spec.tsx @@ -331,6 +331,58 @@ describe('Card Component', () => { expect(navigateStub).not.toHaveBeenCalled(); }); + it('should default the banner image to no padding and cover fit', () => { + render( + + + + + , + ); + const image = screen.getByTestId('img'); + expect(image.dataset.padding).toBe('none'); + expect(image.dataset.fit).toBe('cover'); + }); + + it('should expose the padding and fit on the banner image as data attributes', () => { + render( + + + + + , + ); + const image = screen.getByTestId('img'); + expect(image.dataset.padding).toBe('medium'); + expect(image.dataset.fit).toBe('contain'); + }); + + it('should not set a data-tone on the banner by default', () => { + render( + + + + + + + , + ); + expect(screen.getByTestId('banner').dataset.tone).toBeUndefined(); + }); + + it('should expose the banner tone as a data attribute when set', () => { + render( + + + + + + + , + ); + expect(screen.getByTestId('banner').dataset.tone).toBe('inverted'); + }); + it('should allow tabbing through interactive elements', async () => { const handleSelect = vi.fn(); renderWithRouter( diff --git a/packages/components/src/components/Card/styles/card.module.scss b/packages/components/src/components/Card/styles/card.module.scss index 27a89de9fb..a3bcf1e56b 100644 --- a/packages/components/src/components/Card/styles/card.module.scss +++ b/packages/components/src/components/Card/styles/card.module.scss @@ -190,15 +190,31 @@ @include border-overlay($z-index: 1); } - .root[data-interactive='true']:hover &:has(> .bannerIcon), - .root[data-interactive='true']:has([data-state='open']) &:has(> .bannerIcon) { + // Implicit hover/active shift when a BannerIcon is nested. Skipped once a + // `tone` is set (data-tone present), so consumers can pin the background. + .root[data-interactive='true']:hover &:not([data-tone]):has(> .bannerIcon), + .root[data-interactive='true']:has([data-state='open']) &:not([data-tone]):has(> .bannerIcon) { background-color: var(--color-surface-hover); } - .root[data-interactive='true']:active &:has(> .bannerIcon) { + .root[data-interactive='true']:active &:not([data-tone]):has(> .bannerIcon) { background-color: var(--color-surface-active); } + // Explicit background tones. These pin the background and (for `inverted`) + // flip the nested icon color — see the .bannerIcon rules below. + &[data-tone='dim'] { + background-color: var(--color-surface-dim); + } + + &[data-tone='active'] { + background-color: var(--color-surface-active); + } + + &[data-tone='inverted'] { + background-color: var(--color-primary-default); + } + &[data-size='small'] { aspect-ratio: 3 / 1; } @@ -212,6 +228,7 @@ flex: 1; min-width: 0; display: block; + box-sizing: border-box; & + & { border-left: 1px solid var(--color-line-subtle); @@ -224,6 +241,21 @@ &[data-fit='contain'] { object-fit: contain; } + + // Inner padding tokens. Mapped by value to the design spec + // (small 12px, medium 24px, large 32px), not by matching spacing-token names + // (--spacing-medium is 16px). Pairs best with data-fit='contain'. + &[data-padding='small'] { + padding: var(--spacing-small); + } + + &[data-padding='medium'] { + padding: var(--spacing-large); + } + + &[data-padding='large'] { + padding: var(--spacing-x-large); + } } .bannerIcon { @@ -245,6 +277,14 @@ color: var(--color-primary-default); } + // Inverted banner tone forces a white icon on the dark background, winning + // over the hover and semantic-variant colors below. + .banner[data-tone='inverted'] &, + .root[data-interactive='true']:hover .banner[data-tone='inverted'] &, + .root[data-interactive='true']:has([data-state='open']) .banner[data-tone='inverted'] & { + color: var(--color-primary-on-primary); + } + &[data-variant='primary'] { color: var(--color-primary-default); } From 4503e03766bffc66a5509ab9689d6efae51964cb Mon Sep 17 00:00:00 2001 From: Marco Noleto Date: Tue, 16 Jun 2026 13:54:53 +0200 Subject: [PATCH 2/7] feat(Card.Root): forward className to the root element MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Card.Root now accepts a `className`, merged after the internal styles onto its root element (same pattern as Button/Notice/Divider). This lets consumers apply layout hooks such as Tailwind's `group` — e.g. to drive `group-hover:` styles on descendants — without wrapping the card in an extra element that could break the surrounding grid. Backward compatible. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/card-root-classname.md | 9 +++++++++ .../components/src/components/Card/CardRoot.tsx | 9 ++++++++- .../src/components/Card/__tests__/Card.spec.tsx | 13 +++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 .changeset/card-root-classname.md diff --git a/.changeset/card-root-classname.md b/.changeset/card-root-classname.md new file mode 100644 index 0000000000..0a71eaacc3 --- /dev/null +++ b/.changeset/card-root-classname.md @@ -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. diff --git a/packages/components/src/components/Card/CardRoot.tsx b/packages/components/src/components/Card/CardRoot.tsx index a7e4d15155..d7835fd3cc 100644 --- a/packages/components/src/components/Card/CardRoot.tsx +++ b/packages/components/src/components/Card/CardRoot.tsx @@ -23,6 +23,12 @@ import styles from './styles/card.module.scss'; type CardRootBaseProps = { 'data-test-id'?: string; + /** + * Additional class name(s) merged onto the card's root element. Useful for + * layout hooks such as Tailwind's `group` (e.g. to drive `group-hover:` + * styles on descendants). Merged after the internal styles. + */ + className?: string; /** * Called when the pointer enters the card. */ @@ -94,6 +100,7 @@ export const CardRoot = ( 'data-test-id': dataTestId = 'fondue-card', 'aria-label': ariaLabel, 'aria-describedby': ariaDescribedby, + className = '', selected = false, href, onNavigate, @@ -152,7 +159,7 @@ export const CardRoot = ( return (
{ expect(image.dataset.fit).toBe('contain'); }); + it('should merge a forwarded className onto the root element', () => { + render( + + {CARD_TITLE_TEXT} + , + ); + const root = screen.getByTestId(CARD_TEST_ID); + expect(root).toHaveClass('group'); + expect(root).toHaveClass('custom-class'); + // Internal styles are preserved alongside the forwarded class. + expect(root.className.split(' ').length).toBeGreaterThan(2); + }); + it('should not set a data-tone on the banner by default', () => { render( From e82bf4e5a75350dcfa75ed18ae555d5252f1bb36 Mon Sep 17 00:00:00 2001 From: Marco Noleto Date: Tue, 16 Jun 2026 13:55:05 +0200 Subject: [PATCH 3/7] feat(Card.Action): forward arbitrary data-* attributes Card.Action now spreads unknown props onto its rendered
(className and the internal data-test-id default are still owned by the component). This lets consumers attach attributes such as an analytics/onboarding-tour selector (e.g. data-intercom-tour-selector) directly on Card.Action instead of wrapping its contents in an extra element. Backward compatible. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/card-action-data-attributes.md | 9 +++++++++ .../src/components/Card/CardAction.tsx | 11 ++++++++--- .../src/components/Card/__tests__/Card.spec.tsx | 16 ++++++++++++++++ 3 files changed, 33 insertions(+), 3 deletions(-) create mode 100644 .changeset/card-action-data-attributes.md diff --git a/.changeset/card-action-data-attributes.md b/.changeset/card-action-data-attributes.md new file mode 100644 index 0000000000..413df0657c --- /dev/null +++ b/.changeset/card-action-data-attributes.md @@ -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. diff --git a/packages/components/src/components/Card/CardAction.tsx b/packages/components/src/components/Card/CardAction.tsx index b8f78a86e6..6465d6fc2e 100644 --- a/packages/components/src/components/Card/CardAction.tsx +++ b/packages/components/src/components/Card/CardAction.tsx @@ -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 `
`, so consumers don't need an extra wrapper. + */ export type CardActionProps = { 'data-test-id'?: string; children?: ReactNode; -}; +} & Omit, '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, ) => { return ( -
+
{children}
); diff --git a/packages/components/src/components/Card/__tests__/Card.spec.tsx b/packages/components/src/components/Card/__tests__/Card.spec.tsx index 4f406ef6a6..76ad2f2678 100644 --- a/packages/components/src/components/Card/__tests__/Card.spec.tsx +++ b/packages/components/src/components/Card/__tests__/Card.spec.tsx @@ -357,6 +357,22 @@ describe('Card Component', () => { expect(image.dataset.fit).toBe('contain'); }); + it('should forward arbitrary data-* attributes on Card.Action', () => { + render( + + + + + + + , + ); + expect(screen.getByTestId('action')).toHaveAttribute( + 'data-intercom-tour-selector', + 'set-action-button', + ); + }); + it('should merge a forwarded className onto the root element', () => { render( From 65c5dbc7fa904732843f50185ffb8a27ee239bf9 Mon Sep 17 00:00:00 2001 From: Marco Noleto Date: Tue, 16 Jun 2026 14:35:57 +0200 Subject: [PATCH 4/7] test(Card): add component tests for banner padding and tone The unit tests assert the data-* plumbing; these Playwright component tests verify the actual computed CSS, which jsdom can't resolve: - BannerImage `padding="medium"` applies --spacing-large (24px) inner padding and keeps object-fit: contain. - Banner `tone="inverted"` paints --color-primary-default and flips the nested icon to --color-primary-on-primary. - Banner `tone="dim"` stays dim on hover (opts out of the implicit shift), while an un-toned banner still shifts to --color-surface-hover (backward-compat). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/Card/__tests__/Card.ct.tsx | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/packages/components/src/components/Card/__tests__/Card.ct.tsx b/packages/components/src/components/Card/__tests__/Card.ct.tsx index 3eb003c986..25d212c496 100644 --- a/packages/components/src/components/Card/__tests__/Card.ct.tsx +++ b/packages/components/src/components/Card/__tests__/Card.ct.tsx @@ -8,6 +8,24 @@ import { Card } from '../Card'; const CARD_TEST_ID = 'test-card'; +// 1x1 transparent gif, so the image element lays out without a network request. +const TRANSPARENT_GIF = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='; + +// Resolves a CSS custom property to its computed value (e.g. a token color or +// spacing length) using a throwaway probe element, mirroring readBorder below. +const resolveToken = (locator: Locator, property: 'color' | 'width', cssVariable: string) => + locator.evaluate( + (_element, { property, cssVariable }) => { + const probe = document.createElement('span'); + probe.style.setProperty(property, `var(${cssVariable})`); + document.body.append(probe); + const value = getComputedStyle(probe).getPropertyValue(property); + probe.remove(); + return value; + }, + { property, cssVariable }, + ); + const readBorder = (card: Locator) => card.evaluate((element: Element) => { const probe = document.createElement('span'); @@ -71,3 +89,87 @@ test('should not render the card border when not selected', async ({ mount }) => expect(boxShadow).toBe('none'); }); + +test('should apply inner padding to a padded banner image', async ({ mount }) => { + const wrapper = await mount( + + + + + , + ); + const image = wrapper.getByTestId('banner-image'); + + // `medium` maps to --spacing-large (24px) by value, not the same-named token. + const expectedPadding = await resolveToken(image, 'width', '--spacing-large'); + expect(await image.evaluate((element) => getComputedStyle(element).paddingTop)).toBe(expectedPadding); + expect(await image.evaluate((element) => getComputedStyle(element).objectFit)).toBe('contain'); +}); + +test('should render the inverted banner tone with a dark background and light icon', async ({ mount }) => { + const wrapper = await mount( + + + + + + + , + ); + const banner = wrapper.getByTestId('banner'); + const icon = wrapper.getByTestId('banner-icon'); + + const expectedBackground = await resolveToken(banner, 'color', '--color-primary-default'); + const expectedIconColor = await resolveToken(icon, 'color', '--color-primary-on-primary'); + + expect(await banner.evaluate((element) => getComputedStyle(element).backgroundColor)).toBe(expectedBackground); + expect(await icon.evaluate((element) => getComputedStyle(element).color)).toBe(expectedIconColor); +}); + +test('should keep the dim banner tone on hover instead of the implicit shift', async ({ mount }) => { + const wrapper = await mount( + {}} useHref={(path) => path}> + + + + + + + + , + ); + const banner = wrapper.getByTestId('banner'); + const expectedDim = await resolveToken(banner, 'color', '--color-surface-dim'); + + await wrapper.getByTestId(CARD_TEST_ID).hover(); + + // Pinned tone opts out of the surface-hover shift that a tone-less banner gets. + // Poll past the banner's color transition before asserting the settled value. + await expect + .poll(() => banner.evaluate((element) => getComputedStyle(element).backgroundColor)) + .toBe(expectedDim); +}); + +test('should still shift an un-toned banner background on hover', async ({ mount }) => { + const wrapper = await mount( + {}} useHref={(path) => path}> + + + + + + + + , + ); + const banner = wrapper.getByTestId('banner'); + const expectedHover = await resolveToken(banner, 'color', '--color-surface-hover'); + + await wrapper.getByTestId(CARD_TEST_ID).hover(); + + // Backward-compatibility guard: without `tone`, the legacy hover shift remains. + // Poll past the banner's color transition before asserting the settled value. + await expect + .poll(() => banner.evaluate((element) => getComputedStyle(element).backgroundColor)) + .toBe(expectedHover); +}); From 3c539094c89b3dcc3119e7fbc74df3adf3d8d98f Mon Sep 17 00:00:00 2001 From: Marco Noleto Date: Wed, 17 Jun 2026 09:51:33 +0200 Subject: [PATCH 5/7] refactor(Card.Banner): drop the unused 'active' tone Narrow CardBannerTone to 'dim' | 'inverted'. The 'active' value had no consuming use case (the dark drop-target state is 'inverted'; 'dim' covers pinning the resting background), so removing it keeps the public API focused. Updates the type, JSDoc, SCSS, and changeset. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/card-banner-tone.md | 11 +++++------ .../components/src/components/Card/CardBanner.tsx | 3 +-- .../src/components/Card/styles/card.module.scss | 4 ---- 3 files changed, 6 insertions(+), 12 deletions(-) diff --git a/.changeset/card-banner-tone.md b/.changeset/card-banner-tone.md index e87bb2afa5..c706af6700 100644 --- a/.changeset/card-banner-tone.md +++ b/.changeset/card-banner-tone.md @@ -4,9 +4,8 @@ feat(Card.Banner): add a `tone` prop -`Card.Banner` now accepts `tone="dim" | "active" | "inverted"`. `inverted` -renders the dark drop-target state (near-black background, white icon), while -`dim`/`active` pin the background and opt 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. +`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. diff --git a/packages/components/src/components/Card/CardBanner.tsx b/packages/components/src/components/Card/CardBanner.tsx index 23583d5487..fe9dce571e 100644 --- a/packages/components/src/components/Card/CardBanner.tsx +++ b/packages/components/src/components/Card/CardBanner.tsx @@ -24,14 +24,13 @@ export type CardBannerImagePadding = 'none' | 'small' | 'medium' | 'large'; * - `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. - * - `active` – pins `surface-active`. * - `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' | 'active' | 'inverted'; +export type CardBannerTone = 'dim' | 'inverted'; export type CardBannerProps = { 'data-test-id'?: string; diff --git a/packages/components/src/components/Card/styles/card.module.scss b/packages/components/src/components/Card/styles/card.module.scss index a3bcf1e56b..51e67dd787 100644 --- a/packages/components/src/components/Card/styles/card.module.scss +++ b/packages/components/src/components/Card/styles/card.module.scss @@ -207,10 +207,6 @@ background-color: var(--color-surface-dim); } - &[data-tone='active'] { - background-color: var(--color-surface-active); - } - &[data-tone='inverted'] { background-color: var(--color-primary-default); } From b85ddf01d11c9b95f4eb7097667f6a5d4a8830a0 Mon Sep 17 00:00:00 2001 From: Marco Noleto Date: Wed, 17 Jun 2026 09:51:34 +0200 Subject: [PATCH 6/7] docs(Card): refine banner stories for padding and tone - BannerImageWithPadding: add live `padding` and `fit` controls and use an inline SVG logo as the image, so the breathing room is adjustable and clear. - BannerToneInverted: use a white arrow-align-down icon (the drop-target glyph), which inherits the inverted tone's on-primary color automatically. - BannerToneDim: show a default vs tone="dim" pair, since the difference only appears on hover (default brightens, dim stays). Hover to compare. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/Card/Card.stories.tsx | 134 +++++++++++++----- 1 file changed, 102 insertions(+), 32 deletions(-) diff --git a/packages/components/src/components/Card/Card.stories.tsx b/packages/components/src/components/Card/Card.stories.tsx index 95fdf34119..9c742bf5be 100644 --- a/packages/components/src/components/Card/Card.stories.tsx +++ b/packages/components/src/components/Card/Card.stories.tsx @@ -1,6 +1,7 @@ /* (c) Copyright Frontify Ltd., all rights reserved. */ import { + IconArrowAlignDown, IconCog, IconDotsVertical, IconExclamationMarkTriangle, @@ -33,6 +34,8 @@ import { CardThumbnailIcon, CardThumbnailImage, CardTitle, + type CardBannerFit, + type CardBannerImagePadding, } from './Card'; type Story = StoryObj; @@ -79,6 +82,15 @@ const singleCardDecorators: Story['decorators'] = [ ), ]; +// Inline SVG used as an source, so the padding story shows breathing room +// around a logo-like graphic (a logo/icon library preview) without a network request. +const LOGO_ICON_DATA_URI = `data:image/svg+xml,${encodeURIComponent( + '', +)}`; + +// The padding story exposes interactive controls for `padding` and `fit`. +type BannerImagePaddingControls = { padding: CardBannerImagePadding; fit: CardBannerFit }; + export const Default: Story = { decorators: singleCardDecorators, render: () => { @@ -175,25 +187,43 @@ export const WithBadges: Story = { }, }; -export const BannerImageWithPadding: Story = { - decorators: singleCardDecorators, - render: () => { +export const BannerImageWithPadding: StoryObj = { + decorators: [ + (Story) => ( +
+ +
+ ), + ], + 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: ({ padding, fit }) => { const [selected, setSelected] = useState(false); return ( setSelected((s) => !s)}> - {/* Padding gives a logo/icon preview breathing room. Pairs with fit="contain". */} - + {/* Padding gives a logo/icon preview breathing room. Pairs with fit="contain". + Use the Controls panel to change `padding` and `fit` live. */} + [Logo asset] - SVG · 24px padding + {`padding="${padding}" · fit="${fit}"`} @@ -274,10 +304,11 @@ export const BannerToneInverted: Story = { return ( setSelected((s) => !s)}> - {/* tone="inverted" renders the drop-target state: dark banner + white icon. */} + {/* tone="inverted" renders the drop-target state: dark banner + white icon. + The icon inherits the white (on-primary) color automatically. */} - + @@ -295,28 +326,67 @@ export const BannerToneInverted: Story = { }; export const BannerToneDim: Story = { - decorators: singleCardDecorators, + 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: () => { - const [selected, setSelected] = useState(false); + const [selectedDefault, setSelectedDefault] = useState(false); + const [selectedDim, setSelectedDim] = useState(false); return ( - setSelected((s) => !s)}> - {/* tone="dim" pins surface-dim, opting out of the implicit hover/active shift. */} - - - - - - - [Collection name] - Background stays dim on hover - - - - - - - +
+
+ setSelectedDefault((s) => !s)} + > + {/* No tone: the banner background brightens on hover. */} + + + + + + + Default (no tone) + Hover → banner brightens + + + + + + + +
+ +
+ setSelectedDim((s) => !s)}> + {/* tone="dim" pins surface-dim, opting out of the implicit hover/active shift. */} + + + + + + + tone="dim" + Hover → banner stays dim + + + + + + + +
+
); }, }; From 41d779dacb46cc32ce872ff0bfa1a956f3cf8715 Mon Sep 17 00:00:00 2001 From: Marco Noleto Date: Wed, 17 Jun 2026 10:50:45 +0200 Subject: [PATCH 7/7] refactor(Card): extract story bodies into named components SonarCloud flagged the `useState` calls in the inline `render` arrows of the new banner stories (a lowercase `render` isn't recognized as a React component or a hook). Move each stateful story body into an uppercase-named component so the hooks run inside a real component, and drop the now-redundant inline comments. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/Card/Card.stories.tsx | 197 +++++++++--------- 1 file changed, 96 insertions(+), 101 deletions(-) diff --git a/packages/components/src/components/Card/Card.stories.tsx b/packages/components/src/components/Card/Card.stories.tsx index 9c742bf5be..05cb32aec1 100644 --- a/packages/components/src/components/Card/Card.stories.tsx +++ b/packages/components/src/components/Card/Card.stories.tsx @@ -82,15 +82,105 @@ const singleCardDecorators: Story['decorators'] = [ ), ]; -// Inline SVG used as an source, so the padding story shows breathing room -// around a logo-like graphic (a logo/icon library preview) without a network request. +// 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( '', )}`; -// The padding story exposes interactive controls for `padding` and `fit`. type BannerImagePaddingControls = { padding: CardBannerImagePadding; fit: CardBannerFit }; +// Story bodies are extracted into named components so `useState` runs inside a React component. +const BannerImagePaddingDemo = ({ padding, fit }: BannerImagePaddingControls) => { + const [selected, setSelected] = useState(false); + + return ( + setSelected((s) => !s)}> + + + + + [Logo asset] + {`padding="${padding}" · fit="${fit}"`} + + + + + + + + ); +}; + +const BannerToneInvertedDemo = () => { + const [selected, setSelected] = useState(false); + + return ( + setSelected((s) => !s)}> + + + + + + + [Drop files here] + Release to move into folder + + + + + + + + ); +}; + +const BannerToneDimComparison = () => { + const [selectedDefault, setSelectedDefault] = useState(false); + const [selectedDim, setSelectedDim] = useState(false); + + return ( +
+
+ setSelectedDefault((s) => !s)}> + + + + + + + Default (no tone) + Hover → banner brightens + + + + + + + +
+ +
+ setSelectedDim((s) => !s)}> + + + + + + + tone="dim" + Hover → banner stays dim + + + + + + + +
+
+ ); +}; + export const Default: Story = { decorators: singleCardDecorators, render: () => { @@ -211,28 +301,7 @@ export const BannerImageWithPadding: StoryObj = { description: 'How the image fits the banner. Padding pairs best with `contain`.', }, }, - render: ({ padding, fit }) => { - const [selected, setSelected] = useState(false); - - return ( - setSelected((s) => !s)}> - - {/* Padding gives a logo/icon preview breathing room. Pairs with fit="contain". - Use the Controls panel to change `padding` and `fit` live. */} - - - - [Logo asset] - {`padding="${padding}" · fit="${fit}"`} - - - - - - - - ); - }, + render: (args) => , }; export const SmallBannerWithImages: Story = { @@ -299,30 +368,7 @@ export const SmallBannerWithIcon: Story = { export const BannerToneInverted: Story = { decorators: singleCardDecorators, - render: () => { - const [selected, setSelected] = useState(false); - - return ( - setSelected((s) => !s)}> - {/* tone="inverted" renders the drop-target state: dark banner + white icon. - The icon inherits the white (on-primary) color automatically. */} - - - - - - - [Drop files here] - Release to move into folder - - - - - - - - ); - }, + render: () => , }; export const BannerToneDim: Story = { @@ -337,58 +383,7 @@ export const BannerToneDim: Story = { }, }, }, - render: () => { - const [selectedDefault, setSelectedDefault] = useState(false); - const [selectedDim, setSelectedDim] = useState(false); - - return ( -
-
- setSelectedDefault((s) => !s)} - > - {/* No tone: the banner background brightens on hover. */} - - - - - - - Default (no tone) - Hover → banner brightens - - - - - - - -
- -
- setSelectedDim((s) => !s)}> - {/* tone="dim" pins surface-dim, opting out of the implicit hover/active shift. */} - - - - - - - tone="dim" - Hover → banner stays dim - - - - - - - -
-
- ); - }, + render: () => , }; export const BannerIconWithThumbnail: Story = {