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/.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..c706af6700 --- /dev/null +++ b/.changeset/card-banner-tone.md @@ -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. 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/Card.stories.tsx b/packages/components/src/components/Card/Card.stories.tsx index c611a559a5..05cb32aec1 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,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( + '', +)}`; + +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: () => { @@ -175,6 +277,33 @@ export const WithBadges: Story = { }, }; +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: (args) => , +}; + export const SmallBannerWithImages: Story = { decorators: singleCardDecorators, render: () => { @@ -237,6 +366,26 @@ export const SmallBannerWithIcon: Story = { }, }; +export const BannerToneInverted: Story = { + decorators: singleCardDecorators, + render: () => , +}; + +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: () => , +}; + 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/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/CardBanner.tsx b/packages/components/src/components/Card/CardBanner.tsx index 5b39f679fb..fe9dce571e 100644 --- a/packages/components/src/components/Card/CardBanner.tsx +++ b/packages/components/src/components/Card/CardBanner.tsx @@ -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; @@ -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, ) => { return ( -
+
{children}
, ) => { return ( - {alt} + {alt} ); }; CardBannerImage.displayName = 'Card.BannerImage'; 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 (
+ 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); +}); diff --git a/packages/components/src/components/Card/__tests__/Card.spec.tsx b/packages/components/src/components/Card/__tests__/Card.spec.tsx index 7f5cafde5b..76ad2f2678 100644 --- a/packages/components/src/components/Card/__tests__/Card.spec.tsx +++ b/packages/components/src/components/Card/__tests__/Card.spec.tsx @@ -331,6 +331,87 @@ 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 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( + + {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( + + + + + + + , + ); + 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..51e67dd787 100644 --- a/packages/components/src/components/Card/styles/card.module.scss +++ b/packages/components/src/components/Card/styles/card.module.scss @@ -190,15 +190,27 @@ @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='inverted'] { + background-color: var(--color-primary-default); + } + &[data-size='small'] { aspect-ratio: 3 / 1; } @@ -212,6 +224,7 @@ flex: 1; min-width: 0; display: block; + box-sizing: border-box; & + & { border-left: 1px solid var(--color-line-subtle); @@ -224,6 +237,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 +273,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); }