From a14d7822b67ff194709ee4eba6497081fc3b3be3 Mon Sep 17 00:00:00 2001 From: Pavel Klibani Date: Tue, 19 Nov 2024 08:25:30 +0100 Subject: [PATCH] Feat(web-react): Introduce Card component #1535 --- .../examples/CardComposition.stories.tsx | 403 +++++++++++++++ packages/web-react/scripts/entryPoints.js | 1 + .../web-react/src/components/Card/Card.tsx | 29 ++ .../src/components/Card/CardArtwork.tsx | 27 + .../src/components/Card/CardBody.tsx | 26 + .../src/components/Card/CardEyebrow.tsx | 21 + .../src/components/Card/CardFooter.tsx | 27 + .../src/components/Card/CardLink.tsx | 40 ++ .../src/components/Card/CardLogo.tsx | 21 + .../src/components/Card/CardMedia.tsx | 28 ++ .../src/components/Card/CardTitle.tsx | 27 + .../web-react/src/components/Card/README.md | 461 ++++++++++++++++++ .../components/Card/__tests__/Card.test.tsx | 51 ++ .../Card/__tests__/CardArtwork.test.tsx | 30 ++ .../Card/__tests__/CardBody.test.tsx | 33 ++ .../Card/__tests__/CardEyebrow.test.tsx | 27 + .../Card/__tests__/CardFooter.test.tsx | 30 ++ .../Card/__tests__/CardLink.test.tsx | 27 + .../Card/__tests__/CardLogo.test.tsx | 27 + .../Card/__tests__/CardMedia.test.tsx | 50 ++ .../Card/__tests__/CardTitle.test.tsx | 39 ++ .../CardStylePropsDataProvider.ts | 159 ++++++ .../Card/__tests__/useCardStyleProps.test.ts | 18 + .../Card/demo/CardContentOptions.tsx | 176 +++++++ .../src/components/Card/demo/CardCustom.tsx | 83 ++++ .../Card/demo/CardFooterAlignment.tsx | 75 +++ .../Card/demo/CardFooterContent.tsx | 99 ++++ .../Card/demo/CardGeneralOptions.tsx | 78 +++ .../Card/demo/CardHorizontalLayout.tsx | 117 +++++ .../src/components/Card/demo/CardLogo.tsx | 112 +++++ .../components/Card/demo/CardMediaOptions.tsx | 155 ++++++ .../components/Card/demo/CardMediaSizes.tsx | 200 ++++++++ .../demo/CardReversedHorizontalLayout.tsx | 117 +++++ .../src/components/Card/demo/CardText.tsx | 64 +++ .../components/Card/demo/CardTitleOptions.tsx | 50 ++ .../src/components/Card/demo/constants.tsx | 72 +++ .../src/components/Card/demo/index.tsx | 64 +++ .../web-react/src/components/Card/index.html | 1 + .../web-react/src/components/Card/index.ts | 12 + .../components/Card/stories/Card.stories.tsx | 90 ++++ .../Card/stories/CardArtwork.stories.tsx | 73 +++ .../Card/stories/CardBody.stories.tsx | 83 ++++ .../Card/stories/CardEyebrow.stories.tsx | 77 +++ .../Card/stories/CardFooter.stories.tsx | 89 ++++ .../Card/stories/CardLink.stories.tsx | 93 ++++ .../Card/stories/CardLogo.stories.tsx | 80 +++ .../Card/stories/CardMedia.stories.tsx | 122 +++++ .../Card/stories/CardTitle.stories.tsx | 93 ++++ .../src/components/Card/useCardStyleProps.ts | 99 ++++ packages/web-react/src/components/index.ts | 1 + packages/web-react/src/types/card.ts | 113 +++++ packages/web-react/src/types/index.ts | 1 + .../providerTests/dictionaryPropsTest.tsx | 70 ++- 53 files changed, 4153 insertions(+), 8 deletions(-) create mode 100644 packages/web-react/docs/stories/examples/CardComposition.stories.tsx create mode 100644 packages/web-react/src/components/Card/Card.tsx create mode 100644 packages/web-react/src/components/Card/CardArtwork.tsx create mode 100644 packages/web-react/src/components/Card/CardBody.tsx create mode 100644 packages/web-react/src/components/Card/CardEyebrow.tsx create mode 100644 packages/web-react/src/components/Card/CardFooter.tsx create mode 100644 packages/web-react/src/components/Card/CardLink.tsx create mode 100644 packages/web-react/src/components/Card/CardLogo.tsx create mode 100644 packages/web-react/src/components/Card/CardMedia.tsx create mode 100644 packages/web-react/src/components/Card/CardTitle.tsx create mode 100644 packages/web-react/src/components/Card/README.md create mode 100644 packages/web-react/src/components/Card/__tests__/Card.test.tsx create mode 100644 packages/web-react/src/components/Card/__tests__/CardArtwork.test.tsx create mode 100644 packages/web-react/src/components/Card/__tests__/CardBody.test.tsx create mode 100644 packages/web-react/src/components/Card/__tests__/CardEyebrow.test.tsx create mode 100644 packages/web-react/src/components/Card/__tests__/CardFooter.test.tsx create mode 100644 packages/web-react/src/components/Card/__tests__/CardLink.test.tsx create mode 100644 packages/web-react/src/components/Card/__tests__/CardLogo.test.tsx create mode 100644 packages/web-react/src/components/Card/__tests__/CardMedia.test.tsx create mode 100644 packages/web-react/src/components/Card/__tests__/CardTitle.test.tsx create mode 100644 packages/web-react/src/components/Card/__tests__/__fixtures__/CardStylePropsDataProvider.ts create mode 100644 packages/web-react/src/components/Card/__tests__/useCardStyleProps.test.ts create mode 100644 packages/web-react/src/components/Card/demo/CardContentOptions.tsx create mode 100644 packages/web-react/src/components/Card/demo/CardCustom.tsx create mode 100644 packages/web-react/src/components/Card/demo/CardFooterAlignment.tsx create mode 100644 packages/web-react/src/components/Card/demo/CardFooterContent.tsx create mode 100644 packages/web-react/src/components/Card/demo/CardGeneralOptions.tsx create mode 100644 packages/web-react/src/components/Card/demo/CardHorizontalLayout.tsx create mode 100644 packages/web-react/src/components/Card/demo/CardLogo.tsx create mode 100644 packages/web-react/src/components/Card/demo/CardMediaOptions.tsx create mode 100644 packages/web-react/src/components/Card/demo/CardMediaSizes.tsx create mode 100644 packages/web-react/src/components/Card/demo/CardReversedHorizontalLayout.tsx create mode 100644 packages/web-react/src/components/Card/demo/CardText.tsx create mode 100644 packages/web-react/src/components/Card/demo/CardTitleOptions.tsx create mode 100644 packages/web-react/src/components/Card/demo/constants.tsx create mode 100644 packages/web-react/src/components/Card/demo/index.tsx create mode 100644 packages/web-react/src/components/Card/index.html create mode 100644 packages/web-react/src/components/Card/index.ts create mode 100644 packages/web-react/src/components/Card/stories/Card.stories.tsx create mode 100644 packages/web-react/src/components/Card/stories/CardArtwork.stories.tsx create mode 100644 packages/web-react/src/components/Card/stories/CardBody.stories.tsx create mode 100644 packages/web-react/src/components/Card/stories/CardEyebrow.stories.tsx create mode 100644 packages/web-react/src/components/Card/stories/CardFooter.stories.tsx create mode 100644 packages/web-react/src/components/Card/stories/CardLink.stories.tsx create mode 100644 packages/web-react/src/components/Card/stories/CardLogo.stories.tsx create mode 100644 packages/web-react/src/components/Card/stories/CardMedia.stories.tsx create mode 100644 packages/web-react/src/components/Card/stories/CardTitle.stories.tsx create mode 100644 packages/web-react/src/components/Card/useCardStyleProps.ts create mode 100644 packages/web-react/src/types/card.ts diff --git a/packages/web-react/docs/stories/examples/CardComposition.stories.tsx b/packages/web-react/docs/stories/examples/CardComposition.stories.tsx new file mode 100644 index 0000000000..5b9f238518 --- /dev/null +++ b/packages/web-react/docs/stories/examples/CardComposition.stories.tsx @@ -0,0 +1,403 @@ +import React, { ElementType } from 'react'; +import { + ButtonLink, + Card, + CardArtwork, + CardBody, + CardEyebrow, + CardFooter, + CardLink, + CardLogo, + CardMedia, + CardTitle, + Container, + Grid, + Icon, + PartnerLogo, + UseCardStyleProps, +} from '../../../src/components'; +import { LOGO, MEDIA_IMAGE } from '../../../src/components/Card/demo/constants'; +import { AlignmentX, Sizes } from '../../../src/constants'; +import { CardDirection, CardSizes, GridColumns, SizesDictionaryType } from '../../../src/types'; + +type CardCompositionType = { + cardElementType: ElementType; + cardLogoHasSafeArea: boolean; + cardLogoSize: SizesDictionaryType; + contentText: string; + eyebrowText: string; + gridCols: GridColumns; + image: string; + numCards: number; + showArtwork: boolean; + showContent: boolean; + showEyebrow: boolean; + showFooter: boolean; + showLogo: boolean; + showMedia: boolean; + showTitle: boolean; + titleElementType: ElementType; + titleText: string; + titleWithLink: boolean; + wrapInContainer: boolean; +} & UseCardStyleProps; + +export default { + title: 'Examples/Compositions', + argTypes: { + artworkAlignmentX: { + control: 'select', + description: 'Alignment inside CardArtwork component.', + options: [...Object.values(AlignmentX)], + name: 'alignmentX', + table: { + category: 'CardArtwork', + defaultValue: { summary: AlignmentX.LEFT }, + }, + }, + cardElementType: { + control: 'text', + name: 'elementType', + description: 'Element type for the card.', + table: { + category: 'Card', + defaultValue: { summary: 'article' }, + }, + }, + cardLogoHasSafeArea: { + control: 'boolean', + description: 'If true, the logo will have a safe area.', + name: 'logo safe area', + table: { + category: 'CardLogo', + subcategory: 'Demo settings', + }, + }, + cardLogoSize: { + control: 'select', + description: 'Size of the logo.', + options: [...Object.values(Sizes)], + name: 'logo size', + table: { + category: 'CardLogo', + subcategory: 'Demo settings', + }, + }, + contentText: { + control: 'text', + description: 'Text for the user content.', + name: 'children', + table: { + category: 'CardBody', + defaultValue: { + summary: '', + }, + }, + }, + direction: { + control: 'select', + description: 'Direction of the card.', + options: [...Object.values(CardDirection)], + table: { + category: 'Card', + defaultValue: { summary: CardDirection.VERTICAL }, + }, + }, + eyebrowText: { + control: 'text', + description: 'Text for the CardEyebrow component.', + name: 'children', + table: { + category: 'CardEyebrow', + defaultValue: { summary: '' }, + }, + }, + footerAlignmentX: { + control: 'select', + description: 'Alignment inside CardFooter component.', + options: [...Object.values(AlignmentX)], + name: 'alignmentX', + table: { + category: 'CardFooter', + defaultValue: { summary: AlignmentX.LEFT }, + }, + }, + gridCols: { + control: 'select', + name: 'grid columns', + description: 'Number of columns in the grid.', + options: [1, 2, 3, 4, 5, 6, 12], + }, + hasFilledHeight: { + control: 'boolean', + description: 'Fill the height of the media.', + table: { + category: 'CardMedia', + defaultValue: { summary: false }, + }, + }, + image: { + control: 'text', + description: 'Image source for the CardMedia image.', + name: 'image url', + table: { + category: 'CardMedia', + subcategory: 'Demo settings', + }, + }, + isBoxed: { + control: 'boolean', + description: 'Border around the card.', + table: { + category: 'Card', + defaultValue: { summary: false }, + }, + }, + isExpanded: { + control: 'boolean', + description: 'Expand the media to fill the card. Only works when isBoxed is true.', + table: { + category: 'CardMedia', + defaultValue: { summary: false }, + }, + }, + isHeading: { + control: 'boolean', + description: 'If true, the CardTitle will render as a heading.', + table: { + category: 'CardTitle', + defaultValue: { summary: true }, + }, + }, + isSelectable: { + control: 'boolean', + description: 'Whether the CardBody is selectable.', + table: { + category: 'CardBody', + defaultValue: { summary: false }, + }, + }, + numCards: { + control: 'select', + name: 'number of cards', + description: 'Number of cards to display.', + options: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], + }, + showArtwork: { + control: 'boolean', + description: 'Show the CardArtwork component.', + name: 'show artwork', + table: { + category: 'CardArtwork', + subcategory: 'Demo settings', + }, + }, + showFooter: { + control: 'boolean', + description: 'Show the CardFooter component.', + name: 'show footer', + table: { + category: 'CardFooter', + subcategory: 'Demo settings', + }, + }, + showLogo: { + control: 'boolean', + description: 'Show the CardLogo component.', + name: 'show logo', + table: { + category: 'CardLogo', + subcategory: 'Demo settings', + }, + }, + showContent: { + control: 'boolean', + description: 'Show the user content component.', + name: 'show card content', + table: { + category: 'CardBody', + subcategory: 'Demo settings', + }, + }, + showEyebrow: { + control: 'boolean', + description: 'Show the CardEyebrow component.', + name: 'show eyebrow', + table: { + category: 'CardEyebrow', + subcategory: 'Demo settings', + }, + }, + showMedia: { + control: 'boolean', + description: 'Show the CardMedia component.', + name: 'show media', + table: { + category: 'CardMedia', + subcategory: 'Demo settings', + }, + }, + showTitle: { + control: 'boolean', + description: 'Show the CardTitle component.', + name: 'show title', + table: { + category: 'CardTitle', + subcategory: 'Demo settings', + }, + }, + size: { + control: 'select', + description: 'Size of the media.', + options: [...Object.values(CardSizes)], + table: { + category: 'CardMedia', + defaultValue: { summary: CardSizes.MEDIUM }, + }, + }, + titleElementType: { + control: 'text', + name: 'elementType', + description: 'Element type for the title.', + table: { + category: 'CardTitle', + defaultValue: { summary: 'h4' }, + }, + }, + titleText: { + control: 'text', + description: 'Text for the CardTitle component.', + name: 'children', + table: { + category: 'CardTitle', + defaultValue: { summary: '' }, + }, + }, + titleWithLink: { + control: 'boolean', + description: 'Add a link to the CardTitle component.', + name: 'title as link', + table: { + category: 'CardTitle', + subcategory: 'Demo settings', + }, + }, + wrapInContainer: { + control: 'boolean', + description: 'Wrap the card in a container.', + name: 'wrap cards in container', + }, + }, + args: { + artworkAlignmentX: AlignmentX.LEFT, + cardElementType: 'article', + cardLogoHasSafeArea: true, + cardLogoSize: Sizes.MEDIUM, + contentText: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla accumsan, metus ultrices eleifend gravida, nulla nunc varius lectus, nec rutrum justo nibh eu lectus. Ut vulputate semper dui. Fusce erat. Morbi fringilla convallis sapien. Sed ac felis. Aliquam erat volutpat. Aliquam euismod. Aenean vel lectus. Nunc imperdiet justo nec dolor.', + direction: CardDirection.VERTICAL, + eyebrowText: 'Eyebrow title', + footerAlignmentX: AlignmentX.LEFT, + gridCols: 3, + hasFilledHeight: false, + image: MEDIA_IMAGE, + isBoxed: false, + isExpanded: false, + isHeading: true, + isSelectable: false, + numCards: 3, + showArtwork: false, + showFooter: true, + showLogo: true, + showContent: true, + showEyebrow: true, + showMedia: true, + showTitle: true, + size: CardSizes.MEDIUM, + titleElementType: 'h4', + titleText: 'Card Title', + titleWithLink: false, + wrapInContainer: true, + }, +}; + +export const CardComposition = (args: CardCompositionType) => { + const { + artworkAlignmentX, + cardElementType, + cardLogoHasSafeArea, + cardLogoSize, + contentText, + direction, + eyebrowText, + footerAlignmentX, + gridCols, + hasFilledHeight, + image, + isBoxed, + isExpanded, + isHeading, + isSelectable, + numCards, + showArtwork, + showContent, + showEyebrow, + showFooter, + showLogo, + showMedia, + showTitle, + size, + titleElementType, + titleText, + titleWithLink, + wrapInContainer, + ...restProps + } = args; + + const renderTitle = () => ( + + {titleWithLink ? {titleText} : titleText} + + ); + + const renderCard = () => ( + + {Array.from({ length: numCards }, (_, index) => ( + + {showMedia && ( + + + + )} + {showArtwork && ( + + + + )} + {showLogo && ( + + + {LOGO} + + + )} + {(showEyebrow || showTitle || showContent) && ( + + {showEyebrow && {eyebrowText}} + {showTitle && renderTitle()} + {showContent &&

{contentText}

} +
+ )} + {showFooter && ( + + Primary + Secondary + + )} +
+ ))} +
+ ); + + return wrapInContainer ? {renderCard()} : renderCard(); +}; diff --git a/packages/web-react/scripts/entryPoints.js b/packages/web-react/scripts/entryPoints.js index a66a19e95a..266716c8d5 100644 --- a/packages/web-react/scripts/entryPoints.js +++ b/packages/web-react/scripts/entryPoints.js @@ -14,6 +14,7 @@ const entryPoints = [ { dirs: ['components', 'Alert'] }, { dirs: ['components', 'Breadcrumbs'] }, { dirs: ['components', 'Button'] }, + { dirs: ['components', 'Card'] }, { dirs: ['components', 'Checkbox'] }, { dirs: ['components', 'Collapse'] }, { dirs: ['components', 'Container'] }, diff --git a/packages/web-react/src/components/Card/Card.tsx b/packages/web-react/src/components/Card/Card.tsx new file mode 100644 index 0000000000..d45e3018ff --- /dev/null +++ b/packages/web-react/src/components/Card/Card.tsx @@ -0,0 +1,29 @@ +'use client'; + +import classNames from 'classnames'; +import React, { ElementType } from 'react'; +import { Direction } from '../../constants'; +import { useStyleProps } from '../../hooks'; +import { SpiritCardProps } from '../../types'; +import { useCardStyleProps } from './useCardStyleProps'; + +const defaultProps: Partial = { + direction: Direction.VERTICAL, + elementType: 'article', + isBoxed: false, +}; + +const Card = (props: SpiritCardProps) => { + const propsWithDefaults = { ...defaultProps, ...props }; + const { elementType: ElementTag = 'article', direction, isBoxed, children, ...restProps } = propsWithDefaults; + const { classProps } = useCardStyleProps({ direction, isBoxed }); + const { styleProps, props: otherProps } = useStyleProps(restProps); + + return ( + + {children} + + ); +}; + +export default Card; diff --git a/packages/web-react/src/components/Card/CardArtwork.tsx b/packages/web-react/src/components/Card/CardArtwork.tsx new file mode 100644 index 0000000000..a1e5ecc94e --- /dev/null +++ b/packages/web-react/src/components/Card/CardArtwork.tsx @@ -0,0 +1,27 @@ +'use client'; + +import classNames from 'classnames'; +import React from 'react'; +import { AlignmentX } from '../../constants'; +import { useStyleProps } from '../../hooks'; +import { SpiritCardArtworkProps } from '../../types'; +import { useCardStyleProps } from './useCardStyleProps'; + +const defaultProps: Partial = { + alignmentX: AlignmentX.LEFT, +}; + +const CardArtwork = (props: SpiritCardArtworkProps) => { + const propsWithDefaults = { ...defaultProps, ...props }; + const { children, alignmentX, ...restProps } = propsWithDefaults; + const { classProps } = useCardStyleProps({ artworkAlignmentX: alignmentX }); + const { styleProps, props: otherProps } = useStyleProps(restProps); + + return ( +
+ {children} +
+ ); +}; + +export default CardArtwork; diff --git a/packages/web-react/src/components/Card/CardBody.tsx b/packages/web-react/src/components/Card/CardBody.tsx new file mode 100644 index 0000000000..9ccc7bc47e --- /dev/null +++ b/packages/web-react/src/components/Card/CardBody.tsx @@ -0,0 +1,26 @@ +'use client'; + +import classNames from 'classnames'; +import React from 'react'; +import { useStyleProps } from '../../hooks'; +import { SpiritCardBodyProps } from '../../types'; +import { useCardStyleProps } from './useCardStyleProps'; + +const defaultProps: Partial = { + isSelectable: false, +}; + +const CardBody = (props: SpiritCardBodyProps) => { + const propsWithDefaults = { ...defaultProps, ...props }; + const { children, isSelectable, ...restProps } = propsWithDefaults; + const { classProps } = useCardStyleProps({ isSelectable }); + const { styleProps, props: otherProps } = useStyleProps(restProps); + + return ( +
+ {children} +
+ ); +}; + +export default CardBody; diff --git a/packages/web-react/src/components/Card/CardEyebrow.tsx b/packages/web-react/src/components/Card/CardEyebrow.tsx new file mode 100644 index 0000000000..011ff04d08 --- /dev/null +++ b/packages/web-react/src/components/Card/CardEyebrow.tsx @@ -0,0 +1,21 @@ +'use client'; + +import classNames from 'classnames'; +import React from 'react'; +import { useStyleProps } from '../../hooks'; +import { SpiritCardEyebrowProps } from '../../types'; +import { useCardStyleProps } from './useCardStyleProps'; + +const CardEyebrow = (props: SpiritCardEyebrowProps) => { + const { children, ...restProps } = props; + const { classProps } = useCardStyleProps(); + const { styleProps, props: otherProps } = useStyleProps(restProps); + + return ( +
+ {children} +
+ ); +}; + +export default CardEyebrow; diff --git a/packages/web-react/src/components/Card/CardFooter.tsx b/packages/web-react/src/components/Card/CardFooter.tsx new file mode 100644 index 0000000000..6a906e06d6 --- /dev/null +++ b/packages/web-react/src/components/Card/CardFooter.tsx @@ -0,0 +1,27 @@ +'use client'; + +import classNames from 'classnames'; +import React from 'react'; +import { AlignmentX } from '../../constants'; +import { useStyleProps } from '../../hooks'; +import { SpiritCardFooterProps } from '../../types'; +import { useCardStyleProps } from './useCardStyleProps'; + +const defaultProps: Partial = { + alignmentX: AlignmentX.LEFT, +}; + +const CardFooter = (props: SpiritCardFooterProps) => { + const propsWithDefaults = { ...defaultProps, ...props }; + const { children, alignmentX, ...restProps } = propsWithDefaults; + const { classProps } = useCardStyleProps({ footerAlignmentX: alignmentX }); + const { styleProps, props: otherProps } = useStyleProps(restProps); + + return ( +
+ {children} +
+ ); +}; + +export default CardFooter; diff --git a/packages/web-react/src/components/Card/CardLink.tsx b/packages/web-react/src/components/Card/CardLink.tsx new file mode 100644 index 0000000000..d3c7af2a21 --- /dev/null +++ b/packages/web-react/src/components/Card/CardLink.tsx @@ -0,0 +1,40 @@ +'use client'; + +import classNames from 'classnames'; +import React, { ElementType, forwardRef } from 'react'; +import { useStyleProps } from '../../hooks'; +import { PolymorphicRef, SpiritCardLinkProps, SpiritLinkProps } from '../../types'; +import { useCardStyleProps } from './useCardStyleProps'; + +const defaultProps: Partial = { + elementType: 'a', +}; + +/* We need an exception for components exported with forwardRef */ +/* eslint no-underscore-dangle: ['error', { allow: ['_CardLink'] }] */ +const _CardLink = (props: SpiritCardLinkProps, ref: PolymorphicRef): JSX.Element => { + const propsWithDefaults = { ...defaultProps, ...props }; + const { + elementType: ElementTag = defaultProps.elementType as ElementType, + children, + ...restProps + } = propsWithDefaults; + const { classProps } = useCardStyleProps(); + const { styleProps, props: otherProps } = useStyleProps(restProps); + + return ( + + {children} + + ); +}; + +const CardLink = forwardRef>(_CardLink); + +export default CardLink; diff --git a/packages/web-react/src/components/Card/CardLogo.tsx b/packages/web-react/src/components/Card/CardLogo.tsx new file mode 100644 index 0000000000..4d7d517c85 --- /dev/null +++ b/packages/web-react/src/components/Card/CardLogo.tsx @@ -0,0 +1,21 @@ +'use client'; + +import classNames from 'classnames'; +import React from 'react'; +import { useStyleProps } from '../../hooks'; +import { SpiritCardLogoProps } from '../../types'; +import { useCardStyleProps } from './useCardStyleProps'; + +const CardLogo = (props: SpiritCardLogoProps) => { + const { children, ...restProps } = props; + const { classProps } = useCardStyleProps(); + const { styleProps, props: otherProps } = useStyleProps(restProps); + + return ( +
+ {children} +
+ ); +}; + +export default CardLogo; diff --git a/packages/web-react/src/components/Card/CardMedia.tsx b/packages/web-react/src/components/Card/CardMedia.tsx new file mode 100644 index 0000000000..0f026b1f93 --- /dev/null +++ b/packages/web-react/src/components/Card/CardMedia.tsx @@ -0,0 +1,28 @@ +'use client'; + +import classNames from 'classnames'; +import React from 'react'; +import { useStyleProps } from '../../hooks'; +import { CardSizes, SpiritCardMediaProps } from '../../types'; +import { useCardStyleProps } from './useCardStyleProps'; + +const defaultProps: Partial = { + hasFilledHeight: false, + isExpanded: false, + size: CardSizes.AUTO, +}; + +const CardMedia = (props: SpiritCardMediaProps) => { + const propsWithDefaults = { ...defaultProps, ...props }; + const { children, size, isExpanded, hasFilledHeight, ...restProps } = propsWithDefaults; + const { classProps } = useCardStyleProps({ size, isExpanded, hasFilledHeight }); + const { styleProps, props: otherProps } = useStyleProps(restProps); + + return ( +
+
{children}
+
+ ); +}; + +export default CardMedia; diff --git a/packages/web-react/src/components/Card/CardTitle.tsx b/packages/web-react/src/components/Card/CardTitle.tsx new file mode 100644 index 0000000000..af5b05abb3 --- /dev/null +++ b/packages/web-react/src/components/Card/CardTitle.tsx @@ -0,0 +1,27 @@ +'use client'; + +import classNames from 'classnames'; +import React, { ElementType } from 'react'; +import { useStyleProps } from '../../hooks'; +import { SpiritCardTitleProps } from '../../types'; +import { useCardStyleProps } from './useCardStyleProps'; + +const defaultProps: Partial = { + elementType: 'h4', + isHeading: true, +}; + +const CardTitle = (props: SpiritCardTitleProps) => { + const propsWithDefaults = { ...defaultProps, ...props }; + const { elementType: ElementTag = 'h4', children, isHeading, ...restProps } = propsWithDefaults; + const { classProps } = useCardStyleProps({ isHeading }); + const { styleProps, props: otherProps } = useStyleProps(restProps); + + return ( + + {children} + + ); +}; + +export default CardTitle; diff --git a/packages/web-react/src/components/Card/README.md b/packages/web-react/src/components/Card/README.md new file mode 100644 index 0000000000..fbff0fac4a --- /dev/null +++ b/packages/web-react/src/components/Card/README.md @@ -0,0 +1,461 @@ +# Card + +Card is a compact container for organizing and displaying content about a single topic. + +Card is a versatile composition of a few subcomponents: + +- [Card](#card-1) + - [CardArtwork](#cardartwork) + - [CardMedia](#cardmedia) + - [CardLogo](#cardlogo) + - [CardBody](#cardbody) + - [CardTitle](#cardtitle) + - [CardEyebrow](#cardeyebrow) + - [CardFooter](#cardfooter) + +Additionally, Card can be used with [CardLink](#making-the-whole-card-clickable) to create a clickable card. + +## Card + +Card is the main container of the composition. + +```jsx + + {/* CardArtwork or CardMedia */} + {/* CardBody */} + {/* CardFooter */} + +``` + +Regardless of the [layout](#card-layout), the Card subcomponents must be arranged in the following order: + +1. [CardArtwork](#cardartwork) (optional) or CardMedia (optional) — first +2. [CardLogo](#cardlogo) (optional) +3. [CardBody](#cardbody) +4. [CardFooter](#cardfooter) (optional) – last + +ℹ️ Every `
` counts, especially on large pages. During the development of the Card component, we did our best to +balance between flexibility and simplicity. To provide the best performance, we decided to use the CSS grid layout with +predefined grid areas. This way, we can avoid unnecessary elements and keep the Card structure as flat as possible. + +ℹ️ Vertical spacing between subcomponents is implemented using the `margin-bottom` property and the Card relies on +the specified order of its subcomponents. Since the Card uses the CSS grid layout with predefined grid areas, using the +`gap` property would lead to redundant spacing when dropping in just some of the subcomponents. + +⚠️ **Arranging the subcomponents in a different order will break the spacing of the subcomponents and may also have +negative impact on accessibility of the Card.** + +### Card Layout + +Card can be displayed in a vertical, horizontal, or reversed horizontal layout. + +```jsx +// Vertical card + + {/* … */} + + +// Horizontal card --> + + {/* … */} + + +// Reversed horizontal card --> + + {/* … */} + +``` + +👉 Keep in mind that, no matter the layout, the Card subcomponents must be arranged in the order +[specified above](#card-1). + +### Boxed Cards + +Card can be displayed with a border and a box shadow on hover. + +```jsx +{/* … */} +``` + +### API + +| Name | Type | Default | Required | Description | +| ------------- | --------------------------------------------------------------------- | ---------- | -------- | ---------------------------------------------- | +| `direction` | [[Direction dictionary][dictionary-direction], `horizontal-reversed`] | `vertical` | ✕ | Direction of the content inside Card component | +| `elementType` | `ElementType` | `article` | ✕ | Type of element | +| `isBoxed` | `bool` | `false` | ✕ | Whether the Card have border | + +On top of the API options, the components accept [additional attributes][readme-additional-attributes]. +If you need more control over the styling of a component, you can use [style props][readme-style-props] +and [escape hatches][readme-escape-hatches]. + +## CardArtwork + +CardArtwork is an optional subcomponent that displays a small image or icon. + +```jsx + + + +``` + +### Artwork Alignment (Horizontal Layouts Only) + +In the vertical Card layout, the artwork can be horizontally aligned to the start, center, or end of the Card. +Available alignment options are derived from the [AlignmentX][dictionary-alignment] dictionary. +To align the artwork, use `alignmentX` prop: + +- `left` (default) +- `center` +- `right` + +```jsx + + + +``` + +### API + +| Name | Type | Default | Required | Description | +| ------------ | --------------------------------------------- | ------- | -------- | ---------------------------- | +| `alignmentX` | [AlignmentX dictionary][dictionary-alignment] | `left` | ✕ | Alignment of artwork content | + +On top of the API options, the components accept [additional attributes][readme-additional-attributes]. +If you need more control over the styling of a component, you can use [style props][readme-style-props] +and [escape hatches][readme-escape-hatches]. + +## CardMedia + +To display larger images or videos, use the CardMedia subcomponent. + +```jsx + + + +``` + +👉 Please note the empty `alt` attribute which means the image is purely decorative and does not convey any information. + +👉 Please note that there is no link around or inside the CardMedia subcomponent. See the +[Linking the Media](#linking-the-media) section for more. + +Or, for a video: + +```jsx + + +``` + +### Media Sizes + +CardMedia can be displayed in different sizes. The available sizes are based on the [Size][dictionary-size] dictionary. +By default, the media uses the `auto` size option which means the media will be displayed in its original aspect ratio. +Other options set the media to a specific height (in the vertical Card layout) or width (in the horizontal Card layout). + +In the vertical Card layout, the media is always expanded to the full width of the CardBody content. For boxed Cards, +the media can be even expanded [to the edges](#expanding-the-media) of the Card. + +- `auto` (default) +- `small` +- `medium` +- `large` + +For example: + +```jsx +{/* … */} +``` + +ℹ️ The Card automatically prevents the media from overflowing the Card container or even pushing the subsequent +CardBody content out of the Card. In such cases, the media will be cropped to fit the Card container. + +### Expanding the Media + +To expand the media to the full width or height of a boxed Card, use the `isExpanded` prop. This option is +available for both vertical and horizontal (including reversed horizontal) Card layouts. + +```jsx + + {/* … */} + {/* … */} + +``` + +Additionally, there is a `filledHeight` prop that expands the media to match the height of the CardBody +content. This option works with both boxed and non-boxed Card, but is only available in the horizontal Card layout. + +```jsx + + {/* … */} + {/* … */} + +``` + +ℹ️ Both options work with all media sizes. + +🎉 Fun fact: The `isExpanded` and `hasFilledHeight` props produce the same result for non-boxed +horizontal (and reversed horizontal) Cards. But in all other contexts, the two props behave differently. + +### API + +| Name | Type | Default | Required | Description | +| ---------------------- | -------------------------------------------- | ------- | -------- | ------------------------------------------- | +| `hasFilledHeightClass` | `bool` | `false` | ✕ | Whether the image fill the height of a Card | +| `isExpanded` | `bool` | `false` | ✕ | Whether the media has space around | +| `size` | [[Size dictionary][dictionary-size], `auto`] | `auto` | ✕ | Size of the image media | + +On top of the API options, the components accept [additional attributes][readme-additional-attributes]. +If you need more control over the styling of a component, you can use [style props][readme-style-props] +and [escape hatches][readme-escape-hatches]. + +## CardLogo + +CardLogo is an optional subcomponent that displays a logo. To achieve the best visual result, use the PartnerLogo +subcomponent. + +```jsx + + + Product Name + + +``` + +## CardBody + +CardBody is the main content area of the Card. + +```jsx +{/* … */} +``` + +### API + +| Name | Type | Default | Required | Description | +| -------------- | ------ | ------- | -------- | ------------------------------ | +| `isSelectable` | `bool` | `false` | ✕ | Whether the text is selectable | + +On top of the API options, the components accept [additional attributes][readme-additional-attributes]. +If you need more control over the styling of a component, you can use [style props][readme-style-props] +and [escape hatches][readme-escape-hatches]. + +### CardTitle + +CardTitle displays the main title of the Card. It uses the `

` heading element by default, but you can use any other +heading level that fits your document outline. + +```jsx + + Card Title + +``` + +The CardTitle is emphasized by default. To deemphasize it, simply set the `isHeading` prop to false: + +```jsx + + Card Title + +``` + +👉 See below how to extend the link in CardTitle to [make the whole card clickable](#making-the-whole-card-clickable). + +### API + +| Name | Type | Default | Required | Description | +| ------------- | ------------- | ------- | -------- | ------------------------------------------ | +| `elementType` | `ElementType` | `h4` | ✕ | Type of element | +| `isHeading` | `bool` | `true` | ✕ | Whether the title is rendered as a heading | + +On top of the API options, the components accept [additional attributes][readme-additional-attributes]. +If you need more control over the styling of a component, you can use [style props][readme-style-props] +and [escape hatches][readme-escape-hatches]. + +### CardEyebrow + +CardEyebrow is an optional subcomponent that accompanies the CardTitle. + +```jsx +Content options +Card Title +``` + +## CardFooter + +Use CardFooter for actions or any other content at the bottom of the Card. When using Cards with CardFooter in a Grid, +the CardFooters will automatically line up. + +```jsx +{/* … */} +``` + +### Footer Alignment + +The footer can be horizontally aligned to the start, center, or end of the Card. To align the footer, use one of the +following `alignmentX` prop values: + +- `left` (default) +- `center` +- `right` + +### API + +| Name | Type | Default | Required | Description | +| ------------ | --------------------------------------------- | ------- | -------- | --------------------------- | +| `alignmentX` | [AlignmentX dictionary][dictionary-alignment] | `left` | ✕ | Alignment of footer content | + +On top of the API options, the components accept [additional attributes][readme-additional-attributes]. +If you need more control over the styling of a component, you can use [style props][readme-style-props] +and [escape hatches][readme-escape-hatches]. + +## Card Grid + +In a typical use case, you will display multiple Cards in a [Grid][grid]. + +```jsx + + {/* … */} + {/* … */} + {/* … */} + +``` + +Depending on your situation, you may want to use the list semantics. And it will work! + +```jsx + + {/* … */} + {/* … */} + {/* … */} + +``` + +## Best Practices + +### Making the Whole Card Clickable + +To make the whole Card clickable, use the provided CardLink subcomponent. For best accessibility, you would typically +wrap your CardTitle text in the CardLink component: + +```jsx + + Card title + +``` + +This establishes a [clickable overlay][hugo-giraudel-card] over the whole Card, making it easier for users to interact +with the Card. + +ℹ️ Don't worry, any interactive elements inside the Card (like links or buttons) will still work as expected. + +If you need the text content of your CardBody remains [selectable and copyable][heydon-pickering-card], you can use the `isSelectable` prop on CardBody component: + +```jsx + + + Card title + + +``` + +### API + +| Name | Type | Default | Required | Description | +| ------------- | ------------- | ------- | -------- | --------------- | +| `elementType` | `ElementType` | `a` | ✕ | Type of element | + +On top of the API options, the components accept [additional attributes][readme-additional-attributes]. +If you need more control over the styling of a component, you can use [style props][readme-style-props] +and [escape hatches][readme-escape-hatches]. + +### Linking the Media + +In most cases, using just a single link in the CardTitle and +[making the whole card clickable](#making-the-whole-card-clickable) is the best approach in terms of accessibility. +The Card will have a single accessible link which will be announced by screen readers. + +However, if you cannot use the CardLink subcomponent, and you still need to make the media clickable, you can wrap the +CardMedia image in a link: + +```jsx + + + + + +``` + +👉 Please note that the `aria-hidden="true"` attribute is used to hide the link from screen readers so the user is not +confused by too many links in the Card. + +### The “Read More” Use Case + +For article previews or similar use cases, you may want to display a limited amount of text content with a “Read More” +link. For optimum accessibility, you should only provide this in the form of a text node, not a button or a link: + +```jsx + + + Card title + +

{/* … */}

+ {/* DON'T DO THIS */} + Read more + {/* This is correct */} + +
+``` + +This way, the Card will only have a single accessible link which will be announced by screen readers. + +## Full Example + +When you put it all together: + +```jsx + + + + + + + Logo + + + + Content options + + Card Title + +

Card content

+
+ + Primary + Secondary + +
+``` + +ℹ️ A big shout-out to [Ondřej Pohl][ondrej-pohl] for sharing many of these best practices! + +[dictionary-alignment]: https://github.com/lmc-eu/spirit-design-system/blob/main/docs/DICTIONARIES.md#alignment +[dictionary-size]: https://github.com/lmc-eu/spirit-design-system/blob/main/docs/DICTIONARIES.md#size +[grid]: https://github.com/lmc-eu/spirit-design-system/blob/main/packages/web-react/src/components/Grid/README.md +[heydon-pickering-card]: https://inclusive-components.design/cards/ +[hugo-giraudel-card]: https://kittygiraudel.com/2022/04/02/accessible-cards/ +[ondrej-pohl]: https://linkedin.com/in/ondrejpohl +[readme-additional-attributes]: https://github.com/lmc-eu/spirit-design-system/blob/main/packages/web-react/README.md#additional-attributes +[readme-escape-hatches]: https://github.com/lmc-eu/spirit-design-system/blob/main/packages/web-react/README.md#escape-hatches +[readme-style-props]: https://github.com/lmc-eu/spirit-design-system/blob/main/packages/web-react/README.md#style-props diff --git a/packages/web-react/src/components/Card/__tests__/Card.test.tsx b/packages/web-react/src/components/Card/__tests__/Card.test.tsx new file mode 100644 index 0000000000..354dbc584e --- /dev/null +++ b/packages/web-react/src/components/Card/__tests__/Card.test.tsx @@ -0,0 +1,51 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { classNamePrefixProviderTest } from '../../../../tests/providerTests/classNamePrefixProviderTest'; +import { restPropsTest } from '../../../../tests/providerTests/restPropsTest'; +import { stylePropsTest } from '../../../../tests/providerTests/stylePropsTest'; +import Card from '../Card'; + +describe('Card', () => { + classNamePrefixProviderTest(Card, 'Card'); + + stylePropsTest(Card); + + restPropsTest(Card, 'article'); + + it('should render card component and have default class names', () => { + render(); + + expect(screen.getByRole('article')).toHaveClass('Card Card--vertical'); + }); + + it('should render custom element', () => { + render(); + + expect(screen.getByTestId('test')).toContainHTML('section'); + }); + + it('should render boxed card', () => { + render(); + + expect(screen.getByRole('article')).toHaveClass('Card--boxed'); + }); + + it('should render horizontal card', () => { + render(); + + expect(screen.getByRole('article')).toHaveClass('Card--horizontal'); + }); + + it('should render horizontal reversed card', () => { + render(); + + expect(screen.getByRole('article')).toHaveClass('Card--horizontalReversed'); + }); + + it('should render text children', () => { + render(Hello World); + + expect(screen.getByRole('article')).toHaveTextContent('Hello World'); + }); +}); diff --git a/packages/web-react/src/components/Card/__tests__/CardArtwork.test.tsx b/packages/web-react/src/components/Card/__tests__/CardArtwork.test.tsx new file mode 100644 index 0000000000..c7917c638b --- /dev/null +++ b/packages/web-react/src/components/Card/__tests__/CardArtwork.test.tsx @@ -0,0 +1,30 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { classNamePrefixProviderTest } from '../../../../tests/providerTests/classNamePrefixProviderTest'; +import { alignmentXPropsTest } from '../../../../tests/providerTests/dictionaryPropsTest'; +import { restPropsTest } from '../../../../tests/providerTests/restPropsTest'; +import { stylePropsTest } from '../../../../tests/providerTests/stylePropsTest'; +import CardArtwork from '../CardArtwork'; + +describe('CardArtwork', () => { + classNamePrefixProviderTest(CardArtwork, 'CardArtwork'); + + stylePropsTest(CardArtwork); + + restPropsTest(CardArtwork, '.CardArtwork'); + + alignmentXPropsTest(CardArtwork, 'CardArtwork'); + + it('should render artwork card component and have default class name', () => { + render(); + + expect(screen.getByTestId('test')).toHaveClass('CardArtwork'); + }); + + it('should render text children', () => { + render(Hello World); + + expect(screen.getByTestId('test')).toHaveTextContent('Hello World'); + }); +}); diff --git a/packages/web-react/src/components/Card/__tests__/CardBody.test.tsx b/packages/web-react/src/components/Card/__tests__/CardBody.test.tsx new file mode 100644 index 0000000000..90b512df23 --- /dev/null +++ b/packages/web-react/src/components/Card/__tests__/CardBody.test.tsx @@ -0,0 +1,33 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { classNamePrefixProviderTest } from '../../../../tests/providerTests/classNamePrefixProviderTest'; +import { restPropsTest } from '../../../../tests/providerTests/restPropsTest'; +import { stylePropsTest } from '../../../../tests/providerTests/stylePropsTest'; +import CardBody from '../CardBody'; + +describe('CardBody', () => { + classNamePrefixProviderTest(CardBody, 'CardBody'); + + stylePropsTest(CardBody); + + restPropsTest(CardBody, '.CardBody'); + + it('should render body card component and have default class name', () => { + render(); + + expect(screen.getByTestId('test')).toHaveClass('CardBody'); + }); + + it('should have selectable class', () => { + render(); + + expect(screen.getByTestId('test')).toHaveClass('CardBody--selectable'); + }); + + it('should render text children', () => { + render(Hello World); + + expect(screen.getByTestId('test')).toHaveTextContent('Hello World'); + }); +}); diff --git a/packages/web-react/src/components/Card/__tests__/CardEyebrow.test.tsx b/packages/web-react/src/components/Card/__tests__/CardEyebrow.test.tsx new file mode 100644 index 0000000000..1fdf9d32a1 --- /dev/null +++ b/packages/web-react/src/components/Card/__tests__/CardEyebrow.test.tsx @@ -0,0 +1,27 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { classNamePrefixProviderTest } from '../../../../tests/providerTests/classNamePrefixProviderTest'; +import { restPropsTest } from '../../../../tests/providerTests/restPropsTest'; +import { stylePropsTest } from '../../../../tests/providerTests/stylePropsTest'; +import CardEyebrow from '../CardEyebrow'; + +describe('CardEyebrow', () => { + classNamePrefixProviderTest(CardEyebrow, 'CardEyebrow'); + + stylePropsTest(CardEyebrow); + + restPropsTest(CardEyebrow, '.CardEyebrow'); + + it('should render eyebrow card component and have default class name', () => { + render(); + + expect(screen.getByTestId('test')).toHaveClass('CardEyebrow'); + }); + + it('should render text children', () => { + render(Hello World); + + expect(screen.getByTestId('test')).toHaveTextContent('Hello World'); + }); +}); diff --git a/packages/web-react/src/components/Card/__tests__/CardFooter.test.tsx b/packages/web-react/src/components/Card/__tests__/CardFooter.test.tsx new file mode 100644 index 0000000000..9833c193e0 --- /dev/null +++ b/packages/web-react/src/components/Card/__tests__/CardFooter.test.tsx @@ -0,0 +1,30 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { classNamePrefixProviderTest } from '../../../../tests/providerTests/classNamePrefixProviderTest'; +import { alignmentXPropsTest } from '../../../../tests/providerTests/dictionaryPropsTest'; +import { restPropsTest } from '../../../../tests/providerTests/restPropsTest'; +import { stylePropsTest } from '../../../../tests/providerTests/stylePropsTest'; +import CardFooter from '../CardFooter'; + +describe('CardFooter', () => { + classNamePrefixProviderTest(CardFooter, 'CardFooter'); + + stylePropsTest(CardFooter); + + restPropsTest(CardFooter, '.CardFooter'); + + alignmentXPropsTest(CardFooter, 'CardFooter'); + + it('should render footer card component and have default class names', () => { + render(); + + expect(screen.getByRole('contentinfo')).toHaveClass('CardFooter CardFooter--alignmentXLeft'); + }); + + it('should render text children', () => { + render(Hello World); + + expect(screen.getByRole('contentinfo')).toHaveTextContent('Hello World'); + }); +}); diff --git a/packages/web-react/src/components/Card/__tests__/CardLink.test.tsx b/packages/web-react/src/components/Card/__tests__/CardLink.test.tsx new file mode 100644 index 0000000000..e9afb8ac78 --- /dev/null +++ b/packages/web-react/src/components/Card/__tests__/CardLink.test.tsx @@ -0,0 +1,27 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { classNamePrefixProviderTest } from '../../../../tests/providerTests/classNamePrefixProviderTest'; +import { restPropsTest } from '../../../../tests/providerTests/restPropsTest'; +import { stylePropsTest } from '../../../../tests/providerTests/stylePropsTest'; +import CardLink from '../CardLink'; + +describe('CardLink', () => { + classNamePrefixProviderTest(CardLink, 'CardLink'); + + stylePropsTest(CardLink); + + restPropsTest(CardLink, '.CardLink'); + + it('should render link card component and have default class name', () => { + render(); + + expect(screen.getByRole('link')).toHaveClass('CardLink'); + }); + + it('should render text children', () => { + render(Hello World); + + expect(screen.getByRole('link')).toHaveTextContent('Hello World'); + }); +}); diff --git a/packages/web-react/src/components/Card/__tests__/CardLogo.test.tsx b/packages/web-react/src/components/Card/__tests__/CardLogo.test.tsx new file mode 100644 index 0000000000..aa66f375d8 --- /dev/null +++ b/packages/web-react/src/components/Card/__tests__/CardLogo.test.tsx @@ -0,0 +1,27 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { classNamePrefixProviderTest } from '../../../../tests/providerTests/classNamePrefixProviderTest'; +import { restPropsTest } from '../../../../tests/providerTests/restPropsTest'; +import { stylePropsTest } from '../../../../tests/providerTests/stylePropsTest'; +import CardLogo from '../CardLogo'; + +describe('CardLogo', () => { + classNamePrefixProviderTest(CardLogo, 'CardLogo'); + + stylePropsTest(CardLogo); + + restPropsTest(CardLogo, '.CardLogo'); + + it('should render logo card component and have default class name', () => { + render(); + + expect(screen.getByTestId('test')).toHaveClass('CardLogo'); + }); + + it('should render text children', () => { + render(Hello World); + + expect(screen.getByTestId('test')).toHaveTextContent('Hello World'); + }); +}); diff --git a/packages/web-react/src/components/Card/__tests__/CardMedia.test.tsx b/packages/web-react/src/components/Card/__tests__/CardMedia.test.tsx new file mode 100644 index 0000000000..a19c1189d6 --- /dev/null +++ b/packages/web-react/src/components/Card/__tests__/CardMedia.test.tsx @@ -0,0 +1,50 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { classNamePrefixProviderTest } from '../../../../tests/providerTests/classNamePrefixProviderTest'; +import { sizePropsTest } from '../../../../tests/providerTests/dictionaryPropsTest'; +import { restPropsTest } from '../../../../tests/providerTests/restPropsTest'; +import { stylePropsTest } from '../../../../tests/providerTests/stylePropsTest'; +import CardMedia from '../CardMedia'; + +describe('CardMedia', () => { + classNamePrefixProviderTest(CardMedia, 'CardMedia'); + + stylePropsTest(CardMedia); + + restPropsTest(CardMedia, '.CardMedia'); + + sizePropsTest(CardMedia); + + it('should render media card media component and have default class names', () => { + render(); + + expect(screen.getByTestId('test')).toHaveClass('CardMedia CardMedia--auto'); + }); + + it('should render auto size', () => { + render(); + + expect(screen.getByTestId('test')).toHaveClass('CardMedia--auto'); + }); + + it('should fill the height', () => { + render(); + + expect(screen.getByTestId('test')).toHaveClass('CardMedia--filledHeight'); + }); + + it('should render image', () => { + render( + + description + , + ); + + const image = screen.getByRole('img'); + + expect(image).toHaveAttribute('src', 'image.png'); + expect(image).toHaveAttribute('alt', 'description'); + expect(image.parentElement).toHaveClass('CardMedia__canvas'); + }); +}); diff --git a/packages/web-react/src/components/Card/__tests__/CardTitle.test.tsx b/packages/web-react/src/components/Card/__tests__/CardTitle.test.tsx new file mode 100644 index 0000000000..8c2d84d575 --- /dev/null +++ b/packages/web-react/src/components/Card/__tests__/CardTitle.test.tsx @@ -0,0 +1,39 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { classNamePrefixProviderTest } from '../../../../tests/providerTests/classNamePrefixProviderTest'; +import { restPropsTest } from '../../../../tests/providerTests/restPropsTest'; +import { stylePropsTest } from '../../../../tests/providerTests/stylePropsTest'; +import CardTitle from '../CardTitle'; + +describe('CardTitle', () => { + classNamePrefixProviderTest(CardTitle, 'CardTitle'); + + stylePropsTest(CardTitle); + + restPropsTest(CardTitle, '.CardTitle'); + + it('should render title card component and have default class names', () => { + render(); + + expect(screen.getByTestId('test')).toHaveClass('CardTitle CardTitle--heading'); + }); + + it('should render custom element', () => { + render(); + + expect(screen.getByTestId('test')).toContainHTML('h1'); + }); + + it('should render as heading', () => { + render(); + + expect(screen.getByTestId('test')).toHaveClass('CardTitle--heading'); + }); + + it('should render text children', () => { + render(Hello World); + + expect(screen.getByTestId('test')).toHaveTextContent('Hello World'); + }); +}); diff --git a/packages/web-react/src/components/Card/__tests__/__fixtures__/CardStylePropsDataProvider.ts b/packages/web-react/src/components/Card/__tests__/__fixtures__/CardStylePropsDataProvider.ts new file mode 100644 index 0000000000..9472040248 --- /dev/null +++ b/packages/web-react/src/components/Card/__tests__/__fixtures__/CardStylePropsDataProvider.ts @@ -0,0 +1,159 @@ +import { AlignmentX } from '../../../../constants'; +import { AlignmentXDictionaryType, CardDirection, CardSizes } from '../../../../types'; +import { toPascalCase } from '../../../../utils'; +import { UseCardStyleProps, UseCardStylePropsReturn } from '../../useCardStyleProps'; + +type TextPropsDataProviderType = { + props: UseCardStyleProps; + description: string; + expected: UseCardStylePropsReturn; +}; + +export const defaultExpectedClasses = { + artwork: 'CardArtwork', + body: 'CardBody', + eyebrow: 'CardEyebrow', + footer: 'CardFooter', + link: 'CardLink', + logo: 'CardLogo', + media: 'CardMedia', + mediaCanvas: 'CardMedia__canvas', + root: 'Card', + title: 'CardTitle', +}; + +// Helper function to generate classes +const generateExpectedClassProps = (overrides: Partial) => ({ + ...defaultExpectedClasses, + ...overrides, +}); + +// Helper for artworkAlignmentX, footerAlignmentX, and sizes +const alignmentDataProvider = (type: 'artwork' | 'footer', values: { alignment: AlignmentXDictionaryType }[]) => + values.map(({ alignment }) => ({ + props: { [`${type}AlignmentX`]: alignment }, + description: `return correct classProps for ${type}AlignmentX ${alignment!.toLowerCase()}`, + expected: { + classProps: generateExpectedClassProps({ + [type]: `${defaultExpectedClasses[type]} ${defaultExpectedClasses[type]}--alignmentX${toPascalCase(alignment!)}`, + }), + }, + })); + +const sizeDataProvider = Object.values(CardSizes).map((size) => ({ + props: { size }, + description: `return correct classProps for media ${size.toLowerCase()}`, + expected: { + classProps: generateExpectedClassProps({ + media: `${defaultExpectedClasses.media} ${defaultExpectedClasses.media}--${size.toLowerCase()}`, + }), + }, +})); + +export const textPropsDataProvider: TextPropsDataProviderType[] = [ + // Direction-specific classes + { + props: { direction: CardDirection.VERTICAL }, + description: 'return correct classProps for direction vertical', + expected: { classProps: generateExpectedClassProps({ root: 'Card Card--vertical' }) }, + }, + { + props: { direction: CardDirection.HORIZONTAL }, + description: 'return correct classProps for direction horizontal', + expected: { classProps: generateExpectedClassProps({ root: 'Card Card--horizontal' }) }, + }, + { + props: { direction: CardDirection.HORIZONTAL_REVERSED }, + description: 'return correct classProps for direction horizontal reversed', + expected: { classProps: generateExpectedClassProps({ root: 'Card Card--horizontalReversed' }) }, + }, + + // Artwork alignment + ...alignmentDataProvider('artwork', [ + { alignment: AlignmentX.LEFT }, + { alignment: AlignmentX.RIGHT }, + { alignment: AlignmentX.CENTER }, + ]), + + // Footer alignment + ...alignmentDataProvider('footer', [ + { alignment: AlignmentX.LEFT }, + { alignment: AlignmentX.RIGHT }, + { alignment: AlignmentX.CENTER }, + ]), + + // Boxed card + { + props: { isBoxed: true }, + description: 'return correct classProps for boxed card', + expected: { classProps: generateExpectedClassProps({ root: 'Card Card--boxed' }) }, + }, + + // Body-specific properties + { + props: { isSelectable: true }, + description: 'return correct classProps for body selectable', + expected: { + classProps: generateExpectedClassProps({ + body: `${defaultExpectedClasses.body} ${defaultExpectedClasses.body}--selectable`, + }), + }, + }, + + // Media-specific properties + { + props: { hasFilledHeight: true }, + description: 'return correct classProps for media with filled height', + expected: { + classProps: generateExpectedClassProps({ + media: `${defaultExpectedClasses.media} ${defaultExpectedClasses.media}--filledHeight`, + }), + }, + }, + { + props: { isExpanded: true }, + description: 'return correct classProps for media expanded', + expected: { + classProps: generateExpectedClassProps({ + media: `${defaultExpectedClasses.media} ${defaultExpectedClasses.media}--expanded`, + }), + }, + }, + ...sizeDataProvider, + + // Title-specific properties + { + props: { isHeading: true }, + description: 'return correct classProps for title heading', + expected: { + classProps: generateExpectedClassProps({ + title: `${defaultExpectedClasses.title} ${defaultExpectedClasses.title}--heading`, + }), + }, + }, + + // Complex scenario + { + props: { + artworkAlignmentX: AlignmentX.LEFT, + direction: CardDirection.HORIZONTAL, + footerAlignmentX: AlignmentX.RIGHT, + hasFilledHeight: true, + isBoxed: true, + isExpanded: true, + isHeading: false, + isSelectable: true, + size: CardSizes.SMALL, + }, + description: 'return correct classProps for a horizontal, boxed, expanded card with small size', + expected: { + classProps: generateExpectedClassProps({ + artwork: 'CardArtwork CardArtwork--alignmentXLeft', + body: 'CardBody CardBody--selectable', + footer: 'CardFooter CardFooter--alignmentXRight', + media: 'CardMedia CardMedia--small CardMedia--expanded CardMedia--filledHeight', + root: 'Card Card--horizontal Card--boxed', + }), + }, + }, +]; diff --git a/packages/web-react/src/components/Card/__tests__/useCardStyleProps.test.ts b/packages/web-react/src/components/Card/__tests__/useCardStyleProps.test.ts new file mode 100644 index 0000000000..ae2e00378e --- /dev/null +++ b/packages/web-react/src/components/Card/__tests__/useCardStyleProps.test.ts @@ -0,0 +1,18 @@ +import { renderHook } from '@testing-library/react'; +import { UseCardStyleProps, useCardStyleProps } from '../useCardStyleProps'; +import { defaultExpectedClasses, textPropsDataProvider } from './__fixtures__/CardStylePropsDataProvider'; + +describe('useCardStyleProps', () => { + it('should return defaults', () => { + const props: UseCardStyleProps = {}; + const { result } = renderHook(() => useCardStyleProps(props)); + + expect(result.current.classProps).toEqual(defaultExpectedClasses); + }); + + it.each(textPropsDataProvider)('should %s', ({ props, expected }) => { + const { result } = renderHook(() => useCardStyleProps(props)); + + expect(result.current.classProps).toEqual(expected.classProps); + }); +}); diff --git a/packages/web-react/src/components/Card/demo/CardContentOptions.tsx b/packages/web-react/src/components/Card/demo/CardContentOptions.tsx new file mode 100644 index 0000000000..0c10ab2f50 --- /dev/null +++ b/packages/web-react/src/components/Card/demo/CardContentOptions.tsx @@ -0,0 +1,176 @@ +import React from 'react'; +import { ButtonLink } from '../../ButtonLink'; +import { Grid } from '../../Grid'; +import { Icon } from '../../Icon'; +import { PartnerLogo } from '../../PartnerLogo'; +import Card from '../Card'; +import CardArtwork from '../CardArtwork'; +import CardBody from '../CardBody'; +import CardEyebrow from '../CardEyebrow'; +import CardFooter from '../CardFooter'; +import CardLink from '../CardLink'; +import CardLogo from '../CardLogo'; +import CardMedia from '../CardMedia'; +import CardTitle from '../CardTitle'; +import { LOGO, MEDIA_IMAGE } from './constants'; + +const CardContentOptions = () => ( + + + + + + + Content options + + Image and text + + {/* User content */} +

+ Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean fermentum risus id tortor. Integer lacinia. + Sed vel lectus. +

+ {/* End user content */} +
+ + + Primary + + + Secondary + + +
+ + + + + + + + {LOGO} + + + + Content options + + Image, logo and selectable text + + {/* User content */} +

+ Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean fermentum risus id tortor. Integer lacinia. + Sed vel lectus. +

+ {/* End user content */} +
+ + + Primary + + + Secondary + + +
+ + + + + + Content options + + Video and text + + {/* User content */} +

+ Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean fermentum risus id tortor. Integer lacinia. + Sed vel lectus. +

+ {/* End user content */} +
+ + + Primary + + + Secondary + + +
+ + + + + + + Content options + + Artwork and text + + {/* User content */} +

+ Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean fermentum risus id tortor. Integer lacinia. + Sed vel lectus. +

+ {/* End user content */} +
+ + + Primary + + + Secondary + + +
+ + + + Content options + + Text + + {/* User content */} +

Lorem ipsum dolor sit amet, consectetuer adipiscing elit.

+
    +
  • Lorem ipsum dolor sit amet
  • +
  • Consectetuer adipiscing elit
  • +
  • Aenean fermentum risus id tortor
  • +
+ {/* End user content */} +
+ + + Primary + + + Secondary + + +
+ + + + Content options + Text only + {/* User content */} +

Lorem ipsum dolor sit amet, consectetuer adipiscing elit.

+
    +
  • Lorem ipsum dolor sit amet
  • +
  • Consectetuer adipiscing elit
  • +
  • Aenean fermentum risus id tortor
  • +
+ {/* End user content */} +
+
+
+); + +export default CardContentOptions; diff --git a/packages/web-react/src/components/Card/demo/CardCustom.tsx b/packages/web-react/src/components/Card/demo/CardCustom.tsx new file mode 100644 index 0000000000..3f7141ffd0 --- /dev/null +++ b/packages/web-react/src/components/Card/demo/CardCustom.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import { Grid } from '../../Grid'; +import { PartnerLogo } from '../../PartnerLogo'; +import { Tag } from '../../Tag'; +import Card from '../Card'; +import CardArtwork from '../CardArtwork'; +import CardBody from '../CardBody'; +import CardLink from '../CardLink'; +import { LOGO } from './constants'; + +const CardCustom = () => ( + + + + + + {LOGO} + + + + + {/* User content */} + + Tag + + {/* End user content */} + + + + + + + + {LOGO} + + + + + {/* User content */} + + Tag + + {/* End user content */} + + + + + + + + {LOGO} + + + + + {/* User content */} + + Tag + + {/* End user content */} + + + + + + + + {LOGO} + + + + + {/* User content */} + + Tag + + {/* End user content */} + + + +); + +export default CardCustom; diff --git a/packages/web-react/src/components/Card/demo/CardFooterAlignment.tsx b/packages/web-react/src/components/Card/demo/CardFooterAlignment.tsx new file mode 100644 index 0000000000..0f633002cb --- /dev/null +++ b/packages/web-react/src/components/Card/demo/CardFooterAlignment.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { ButtonLink } from '../../ButtonLink'; +import { Grid } from '../../Grid'; +import Card from '../Card'; +import CardBody from '../CardBody'; +import CardEyebrow from '../CardEyebrow'; +import CardFooter from '../CardFooter'; +import CardLink from '../CardLink'; +import CardTitle from '../CardTitle'; + +const CardFooterAlignment = () => { + return ( + + + + Footer alignment + + Left + + {/* User content */} +

Lorem ipsum dolor sit amet.

+ {/* End user content */} +
+ + + Primary + + + Secondary + + +
+ + + Footer alignment + + Center + + {/* User content */} +

Lorem ipsum dolor sit amet.

+ {/* End user content */} +
+ + + Primary + + + Secondary + + +
+ + + Footer alignment + + Right + + {/* User content */} +

Lorem ipsum dolor sit amet.

+ {/* End user content */} +
+ + + Primary + + + Secondary + + +
+
+ ); +}; + +export default CardFooterAlignment; diff --git a/packages/web-react/src/components/Card/demo/CardFooterContent.tsx b/packages/web-react/src/components/Card/demo/CardFooterContent.tsx new file mode 100644 index 0000000000..b95f98d660 --- /dev/null +++ b/packages/web-react/src/components/Card/demo/CardFooterContent.tsx @@ -0,0 +1,99 @@ +import React from 'react'; +import { ButtonLink } from '../../ButtonLink'; +import { Flex } from '../../Flex'; +import { Grid } from '../../Grid'; +import { Icon } from '../../Icon'; +import { Link } from '../../Link'; +import { Text } from '../../Text'; +import { UNSTABLE_Avatar } from '../../UNSTABLE_Avatar'; +import { VisuallyHidden } from '../../VisuallyHidden'; +import Card from '../Card'; +import CardBody from '../CardBody'; +import CardEyebrow from '../CardEyebrow'; +import CardFooter from '../CardFooter'; +import CardLink from '../CardLink'; +import CardTitle from '../CardTitle'; + +const CardFooterContent = () => { + return ( + + + + Footer content + + Links + + {/* User content */} +

Lorem ipsum dolor sit amet.

+ {/* End user content */} +
+ + + Primary + + + Secondary + + +
+ + + + Footer content + + Icon buttons + + {/* User content */} +

Lorem ipsum dolor sit amet.

+ {/* End user content */} +
+ + + Add + + + + Edit + + + + Help + + + +
+ + + + Footer content + + Custom footer + + {/* User content */} +

Lorem ipsum dolor sit amet.

+ {/* End user content */} +
+ + + + + +
+ + Jiří Bárta + + + Senior UI Designer + +
+
+
+
+
+ ); +}; + +export default CardFooterContent; diff --git a/packages/web-react/src/components/Card/demo/CardGeneralOptions.tsx b/packages/web-react/src/components/Card/demo/CardGeneralOptions.tsx new file mode 100644 index 0000000000..d0f1e11508 --- /dev/null +++ b/packages/web-react/src/components/Card/demo/CardGeneralOptions.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { Button, ButtonLink } from '../../Button'; +import { Grid } from '../../Grid'; +import Card from '../Card'; +import CardBody from '../CardBody'; +import CardEyebrow from '../CardEyebrow'; +import CardFooter from '../CardFooter'; +import CardLink from '../CardLink'; +import CardMedia from '../CardMedia'; +import CardTitle from '../CardTitle'; +import { MEDIA_IMAGE } from './constants'; + +const CardGeneralOptions = () => ( + + + + + + + Eyebrow title + + Basic card + + {/* User content */} +

+ Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean fermentum risus id tortor. Integer lacinia. + Sed vel lectus. +

+
Read more
+ {/* End user content */} +
+
+ + + + + + + Eyebrow title + + Boxed card + + {/* User content */} +

Lorem ipsum dolor sit amet.

+ {/* End user content */} +
+ + + Primary + + + Secondary + + +
+ + + + + + + Eyebrow title + + Boxed card, expanded media + + {/* User content */} +

Lorem ipsum dolor sit amet.

+ {/* End user content */} +
+ + + + +
+
+); + +export default CardGeneralOptions; diff --git a/packages/web-react/src/components/Card/demo/CardHorizontalLayout.tsx b/packages/web-react/src/components/Card/demo/CardHorizontalLayout.tsx new file mode 100644 index 0000000000..989f8bc4c0 --- /dev/null +++ b/packages/web-react/src/components/Card/demo/CardHorizontalLayout.tsx @@ -0,0 +1,117 @@ +import React from 'react'; +import { ButtonLink } from '../../ButtonLink'; +import { Grid } from '../../Grid'; +import { Icon } from '../../Icon'; +import { PartnerLogo } from '../../PartnerLogo'; +import Card from '../Card'; +import CardArtwork from '../CardArtwork'; +import CardBody from '../CardBody'; +import CardEyebrow from '../CardEyebrow'; +import CardFooter from '../CardFooter'; +import CardLink from '../CardLink'; +import CardLogo from '../CardLogo'; +import CardMedia from '../CardMedia'; +import CardTitle from '../CardTitle'; +import { LOGO, MEDIA_IMAGE } from './constants'; + +const CardHorizontalLayout = () => ( + + + + + + + Horizontal card layout + + Media and text + + {/* User content */} +

+ Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean fermentum risus id tortor. Integer lacinia. + Sed vel lectus. +

+ {/* End user content */} +
+ + + Primary + + + Secondary + + +
+ + + + + + + + {LOGO} + + + + Horizontal card layout + + Media, logo and text + + {/* User content */} +

Lorem ipsum dolor sit amet.

+ {/* End user content */} +
+ + + Primary + + + Secondary + + +
+ + + + + + + Horizontal card layout + Artwork and text + {/* User content */} +

Lorem ipsum dolor sit amet, consectetuer adipiscing elit.

+ {/* End user content */} +
+ + + Primary + + + Secondary + + +
+ + + + + + + Horizontal card layout + Artwork and text + {/* User content */} +

Lorem ipsum dolor sit amet, consectetuer adipiscing elit.

+ {/* End user content */} +
+ + + Primary + + + Secondary + + +
+
+); + +export default CardHorizontalLayout; diff --git a/packages/web-react/src/components/Card/demo/CardLogo.tsx b/packages/web-react/src/components/Card/demo/CardLogo.tsx new file mode 100644 index 0000000000..54930b3619 --- /dev/null +++ b/packages/web-react/src/components/Card/demo/CardLogo.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { ButtonLink } from '../../ButtonLink'; +import { Grid } from '../../Grid'; +import { PartnerLogo } from '../../PartnerLogo'; +import Card from '../Card'; +import CardBody from '../CardBody'; +import CardEyebrow from '../CardEyebrow'; +import CardFooter from '../CardFooter'; +import CardLink from '../CardLink'; +import CardLogo from '../CardLogo'; +import CardMedia from '../CardMedia'; +import CardTitle from '../CardTitle'; +import { LOGO, MEDIA_IMAGE } from './constants'; + +const CardLogoDemo = () => ( + + + + + + + + {LOGO} + + + + Logo + + Basic card with media and logo + + {/* User content */} +

+ Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean fermentum risus id tortor. Integer lacinia. + Sed vel lectus. +

+ {/* End user content */} +
+ + + Primary + + + Secondary + + +
+ + + + + + + + {LOGO} + + + + Logo + + Boxed card with media and logo + + {/* User content */} +

+ Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean fermentum risus id tortor. Integer lacinia. + Sed vel lectus. +

+ {/* End user content */} +
+ + + Primary + + + Secondary + + +
+ + + + + + + + {LOGO} + + + + Logo + + Boxed card with expanded media and logo + + {/* User content */} +

+ Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean fermentum risus id tortor. Integer lacinia. + Sed vel lectus. +

+ {/* End user content */} +
+ + + Primary + + + Secondary + + +
+
+); + +export default CardLogoDemo; diff --git a/packages/web-react/src/components/Card/demo/CardMediaOptions.tsx b/packages/web-react/src/components/Card/demo/CardMediaOptions.tsx new file mode 100644 index 0000000000..1f85f5d77e --- /dev/null +++ b/packages/web-react/src/components/Card/demo/CardMediaOptions.tsx @@ -0,0 +1,155 @@ +import React from 'react'; +import { ButtonLink } from '../../ButtonLink'; +import { Grid, GridItem } from '../../Grid'; +import Card from '../Card'; +import CardBody from '../CardBody'; +import CardEyebrow from '../CardEyebrow'; +import CardFooter from '../CardFooter'; +import CardLink from '../CardLink'; +import CardMedia from '../CardMedia'; +import CardTitle from '../CardTitle'; +import { MEDIA_IMAGE } from './constants'; + +export const CardMediaOptions = () => ( + + + + + + + + Media options + + Auto size + + {/* User content */} +

+ Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean fermentum risus id tortor. Integer lacinia. + Sed vel lectus. +

+ {/* End user content */} +
+ + + Primary + + + Secondary + + +
+
+ + + + + + + + Media options + + Auto size, expanded + + {/* User content */} +

+ Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean fermentum risus id tortor. Integer lacinia. + Sed vel lectus. +

+ {/* End user content */} +
+ + + Primary + + + Secondary + + +
+
+ + + + + + + + Media options + + Medium size + + {/* User content */} +

+ Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean fermentum risus id tortor. Integer lacinia. + Sed vel lectus. +

+ {/* End user content */} +
+ + + Primary + + + Secondary + + +
+
+ + + + + + + + Media options + + Medium size, filled height + + {/* User content */} +

+ Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean fermentum risus id tortor. Integer lacinia. + Sed vel lectus. +

+ {/* End user content */} +
+ + + Primary + + + Secondary + + +
+
+ + + + + + + + Media options + + Medium size, expanded + + {/* User content */} +

+ Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean fermentum risus id tortor. Integer lacinia. + Sed vel lectus. +

+ {/* End user content */} +
+ + + Primary + + + Secondary + + +
+
+
+); diff --git a/packages/web-react/src/components/Card/demo/CardMediaSizes.tsx b/packages/web-react/src/components/Card/demo/CardMediaSizes.tsx new file mode 100644 index 0000000000..16baa7270c --- /dev/null +++ b/packages/web-react/src/components/Card/demo/CardMediaSizes.tsx @@ -0,0 +1,200 @@ +import React from 'react'; +import { ButtonLink } from '../../ButtonLink'; +import { Grid } from '../../Grid'; +import Card from '../Card'; +import CardBody from '../CardBody'; +import CardEyebrow from '../CardEyebrow'; +import CardFooter from '../CardFooter'; +import CardLink from '../CardLink'; +import CardMedia from '../CardMedia'; +import CardTitle from '../CardTitle'; +import { MEDIA_IMAGE } from './constants'; + +const CardMediaSizes = () => ( + <> + + + + + + + Media size + + Small + +

+ Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean fermentum risus id tortor. Integer lacinia. + Sed vel lectus. +

+
+ + + Primary + + + Secondary + + +
+ + + + + + + Media size + + Medium + +

Lorem ipsum dolor sit amet.

+
+ + + Primary + + + Secondary + + +
+ + + + + + + Media size + + Large + +

Lorem ipsum dolor sit amet.

+
+ + + Primary + + + Secondary + + +
+ + + + + + + Media size + + Auto + +

Lorem ipsum dolor sit amet.

+
+ + + Primary + + + Secondary + + +
+
+ + + + + + + + Media size + + Small + + {/* User content */} +

Lorem ipsum dolor sit amet.

+ {/* End user content */} +
+ + + Primary + + + Secondary + + +
+ + + + + + + Media size + + Medium + + {/* User content */} +

Lorem ipsum dolor sit amet.

+ {/* End user content */} +
+ + + Primary + + + Secondary + + +
+ + + + + + + Media size + + Large + + {/* User content */} +

Lorem ipsum dolor sit amet.

+ {/* End user content */} +
+ + + Primary + + + Secondary + + +
+ + + + + + + Media size + + Auto + + {/* User content */} +

Lorem ipsum dolor sit amet.

+ {/* End user content */} +
+ + + Primary + + + Secondary + + +
+
+ +); + +export default CardMediaSizes; diff --git a/packages/web-react/src/components/Card/demo/CardReversedHorizontalLayout.tsx b/packages/web-react/src/components/Card/demo/CardReversedHorizontalLayout.tsx new file mode 100644 index 0000000000..9db231d8f6 --- /dev/null +++ b/packages/web-react/src/components/Card/demo/CardReversedHorizontalLayout.tsx @@ -0,0 +1,117 @@ +import React from 'react'; +import { ButtonLink } from '../../ButtonLink'; +import { Grid } from '../../Grid'; +import { Icon } from '../../Icon'; +import { PartnerLogo } from '../../PartnerLogo'; +import Card from '../Card'; +import CardArtwork from '../CardArtwork'; +import CardBody from '../CardBody'; +import CardEyebrow from '../CardEyebrow'; +import CardFooter from '../CardFooter'; +import CardLink from '../CardLink'; +import CardLogo from '../CardLogo'; +import CardMedia from '../CardMedia'; +import CardTitle from '../CardTitle'; +import { LOGO, MEDIA_IMAGE } from './constants'; + +const CardReversedHorizontalLayout = () => ( + + + + + + + Horizontal reversed card layout + + Media and text + + {/* User content */} +

+ Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean fermentum risus id tortor. Integer lacinia. + Sed vel lectus. +

+ {/* End user content */} +
+ + + Primary + + + Secondary + + +
+ + + + + + + + {LOGO} + + + + Horizontal reversed card layout + + Media, logo and text + + {/* User content */} +

Lorem ipsum dolor sit amet.

+ {/* End user content */} +
+ + + Primary + + + Secondary + + +
+ + + + + + + Horizontal reversed card layout + Artwork and text + {/* User content */} +

Lorem ipsum dolor sit amet, consectetuer adipiscing elit.

+ {/* End user content */} +
+ + + Primary + + + Secondary + + +
+ + + + + + + Horizontal reversed card layout + Artwork and text + {/* User content */} +

Lorem ipsum dolor sit amet, consectetuer adipiscing elit.

+ {/* End user content */} +
+ + + Primary + + + Secondary + + +
+
+); + +export default CardReversedHorizontalLayout; diff --git a/packages/web-react/src/components/Card/demo/CardText.tsx b/packages/web-react/src/components/Card/demo/CardText.tsx new file mode 100644 index 0000000000..4c1b848fa1 --- /dev/null +++ b/packages/web-react/src/components/Card/demo/CardText.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { Grid } from '../../Grid'; +import Card from '../Card'; +import CardBody from '../CardBody'; +import CardEyebrow from '../CardEyebrow'; +import CardTitle from '../CardTitle'; + +const CardText = () => ( + + + + Eyebrow title + Text card heading + {/* User content */} +

+ Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean fermentum risus id tortor. Integer lacinia. + Sed vel lectus. +

+ {/* End user content */} +
+
+ + + + Eyebrow title + Text card heading + {/* User content */} +

+ Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean fermentum risus id tortor. Integer lacinia. + Sed vel lectus. +

+ {/* End user content */} +
+
+ + + + Eyebrow title + Text card heading + {/* User content */} +

+ Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean fermentum risus id tortor. Integer lacinia. + Sed vel lectus. +

+ {/* End user content */} +
+
+ + + + Eyebrow title + Text card heading + {/* User content */} +

+ Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean fermentum risus id tortor. Integer lacinia. + Sed vel lectus. +

+ {/* End user content */} +
+
+
+); + +export default CardText; diff --git a/packages/web-react/src/components/Card/demo/CardTitleOptions.tsx b/packages/web-react/src/components/Card/demo/CardTitleOptions.tsx new file mode 100644 index 0000000000..5e510acc9d --- /dev/null +++ b/packages/web-react/src/components/Card/demo/CardTitleOptions.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { Grid } from '../../Grid'; +import Card from '../Card'; +import CardBody from '../CardBody'; +import CardMedia from '../CardMedia'; +import CardTitle from '../CardTitle'; +import { MEDIA_IMAGE } from './constants'; + +const CardTitleOptions = () => ( + + + + + + + + + Body-style primary link + + + + + + + + + + + + + Body-style secondary link + + + + + + + + + + + + Heading-style primary link + + + + +); + +export default CardTitleOptions; diff --git a/packages/web-react/src/components/Card/demo/constants.tsx b/packages/web-react/src/components/Card/demo/constants.tsx new file mode 100644 index 0000000000..1aa18377dd --- /dev/null +++ b/packages/web-react/src/components/Card/demo/constants.tsx @@ -0,0 +1,72 @@ +import React from 'react'; + +export const MEDIA_IMAGE = + 'https://images.unsplash.com/photo-1506260408121-e353d10b87c7?q=80&w=2728&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D'; + +export const LOGO = ( + + JobBoard logo + + + + + + + + + + + + + + + + + + + + + + + + +); diff --git a/packages/web-react/src/components/Card/demo/index.tsx b/packages/web-react/src/components/Card/demo/index.tsx new file mode 100644 index 0000000000..d6bfb2867a --- /dev/null +++ b/packages/web-react/src/components/Card/demo/index.tsx @@ -0,0 +1,64 @@ +// Because there is no `dist` directory during the CI run +/* eslint-disable import/no-extraneous-dependencies, import/extensions, import/no-unresolved */ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: No declaration file -- @see https://jira.almacareer.tech/browse/DS-561 +import icons from '@lmc-eu/spirit-icons/icons'; +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import DocsSection from '../../../../docs/DocsSections'; +import { IconsProvider } from '../../../context'; +import CardContentOptions from './CardContentOptions'; +import CardCustom from './CardCustom'; +import CardFooterAlignment from './CardFooterAlignment'; +import CardFooterContent from './CardFooterContent'; +import CardGeneralOptions from './CardGeneralOptions'; +import CardHorizontalLayout from './CardHorizontalLayout'; +import CardLogoDemo from './CardLogo'; +import { CardMediaOptions } from './CardMediaOptions'; +import CardMediaSizes from './CardMediaSizes'; +import CardReversedHorizontalLayout from './CardReversedHorizontalLayout'; +import CardText from './CardText'; +import CardTitleOptions from './CardTitleOptions'; + +ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + , +); diff --git a/packages/web-react/src/components/Card/index.html b/packages/web-react/src/components/Card/index.html new file mode 100644 index 0000000000..d5795e0a0a --- /dev/null +++ b/packages/web-react/src/components/Card/index.html @@ -0,0 +1 @@ +{{> web-react/demo title="Card" parentPageName="Components" }} diff --git a/packages/web-react/src/components/Card/index.ts b/packages/web-react/src/components/Card/index.ts new file mode 100644 index 0000000000..b7281788aa --- /dev/null +++ b/packages/web-react/src/components/Card/index.ts @@ -0,0 +1,12 @@ +'use client'; + +export { default as Card } from './Card'; +export { default as CardArtwork } from './CardArtwork'; +export { default as CardBody } from './CardBody'; +export { default as CardEyebrow } from './CardEyebrow'; +export { default as CardFooter } from './CardFooter'; +export { default as CardLink } from './CardLink'; +export { default as CardLogo } from './CardLogo'; +export { default as CardMedia } from './CardMedia'; +export { default as CardTitle } from './CardTitle'; +export * from './useCardStyleProps'; diff --git a/packages/web-react/src/components/Card/stories/Card.stories.tsx b/packages/web-react/src/components/Card/stories/Card.stories.tsx new file mode 100644 index 0000000000..15cee08912 --- /dev/null +++ b/packages/web-react/src/components/Card/stories/Card.stories.tsx @@ -0,0 +1,90 @@ +import { Markdown } from '@storybook/blocks'; +import type { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; +import { CardDirection } from '../../../types'; +import { ButtonLink } from '../../ButtonLink'; +import { Container } from '../../Container'; +import { PartnerLogo } from '../../PartnerLogo'; +import Card from '../Card'; +import CardBody from '../CardBody'; +import CardEyebrow from '../CardEyebrow'; +import CardFooter from '../CardFooter'; +import CardLogo from '../CardLogo'; +import CardMedia from '../CardMedia'; +import CardTitle from '../CardTitle'; +import { LOGO, MEDIA_IMAGE } from '../demo/constants'; +import ReadMe from '../README.md'; + +const meta: Meta = { + title: 'Components/Card', + component: Card, + parameters: { + docs: { + page: () => {ReadMe}, + }, + }, + argTypes: { + direction: { + control: 'select', + description: 'Direction of the card.', + options: [...Object.values(CardDirection)], + table: { + defaultValue: { summary: CardDirection.VERTICAL }, + }, + }, + elementType: { + control: 'text', + }, + isBoxed: { + control: 'boolean', + description: 'Border around the card.', + table: { + defaultValue: { summary: 'false' }, + }, + }, + }, + args: { + direction: CardDirection.VERTICAL, + elementType: 'article', + isBoxed: false, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Playground: Story = { + name: 'Card', + render: (args) => ( + + + + + + + + {LOGO} + + + + Card eyebrow + Card Title +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla accumsan, metus ultrices eleifend gravida, + nulla nunc varius lectus, nec rutrum justo nibh eu lectus. Ut vulputate semper dui. Fusce erat. Morbi + fringilla convallis sapien. Sed ac felis. Aliquam erat volutpat. Aliquam euismod. Aenean vel lectus. Nunc + imperdiet justo nec dolor. +

+
+ + + Primary + + + Secondary + + +
+
+ ), +}; diff --git a/packages/web-react/src/components/Card/stories/CardArtwork.stories.tsx b/packages/web-react/src/components/Card/stories/CardArtwork.stories.tsx new file mode 100644 index 0000000000..0f72b07b4d --- /dev/null +++ b/packages/web-react/src/components/Card/stories/CardArtwork.stories.tsx @@ -0,0 +1,73 @@ +import { Markdown } from '@storybook/blocks'; +import type { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; +import { AlignmentX } from '../../../constants'; +import { ButtonLink } from '../../ButtonLink'; +import { Container } from '../../Container'; +import { Icon } from '../../Icon'; +import Card from '../Card'; +import CardArtwork from '../CardArtwork'; +import CardBody from '../CardBody'; +import CardEyebrow from '../CardEyebrow'; +import CardFooter from '../CardFooter'; +import CardTitle from '../CardTitle'; +import ReadMe from '../README.md'; + +const meta: Meta = { + title: 'Components/Card', + component: CardArtwork, + parameters: { + docs: { + page: () => {ReadMe}, + }, + }, + argTypes: { + alignmentX: { + control: 'select', + description: 'Alignment inside CardArtwork component.', + options: [...Object.values(AlignmentX)], + table: { + defaultValue: { summary: AlignmentX.LEFT }, + }, + }, + }, + args: { + alignmentX: AlignmentX.LEFT, + }, +}; + +export default meta; +type Story = StoryObj; + +export const CardArtworkPlayground: Story = { + name: 'CardArtwork', + render: (args) => { + return ( + + + + + + + Card eyebrow + Card Title +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla accumsan, metus ultrices eleifend gravida, + nulla nunc varius lectus, nec rutrum justo nibh eu lectus. Ut vulputate semper dui. Fusce erat. Morbi + fringilla convallis sapien. Sed ac felis. Aliquam erat volutpat. Aliquam euismod. Aenean vel lectus. Nunc + imperdiet justo nec dolor. +

+
+ + + Primary + + + Secondary + + +
+
+ ); + }, +}; diff --git a/packages/web-react/src/components/Card/stories/CardBody.stories.tsx b/packages/web-react/src/components/Card/stories/CardBody.stories.tsx new file mode 100644 index 0000000000..3c8ddb819f --- /dev/null +++ b/packages/web-react/src/components/Card/stories/CardBody.stories.tsx @@ -0,0 +1,83 @@ +import { Markdown } from '@storybook/blocks'; +import type { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; +import { ButtonLink } from '../../ButtonLink'; +import { Container } from '../../Container'; +import { PartnerLogo } from '../../PartnerLogo'; +import Card from '../Card'; +import CardBody from '../CardBody'; +import CardEyebrow from '../CardEyebrow'; +import CardFooter from '../CardFooter'; +import CardLink from '../CardLink'; +import CardLogo from '../CardLogo'; +import CardMedia from '../CardMedia'; +import CardTitle from '../CardTitle'; +import { LOGO, MEDIA_IMAGE } from '../demo/constants'; +import ReadMe from '../README.md'; + +const meta: Meta = { + title: 'Components/Card', + component: CardBody, + parameters: { + docs: { + page: () => {ReadMe}, + }, + }, + argTypes: { + children: { + control: 'text', + description: 'Text to display in the CardBody.', + }, + isSelectable: { + control: 'boolean', + description: 'Whether the CardBody is selectable. CardTitle must be as link.', + table: { + defaultValue: { summary: 'false' }, + }, + }, + }, + args: { + children: 'CardBody Text', + isSelectable: false, + }, +}; + +export default meta; +type Story = StoryObj; + +export const CardBodyPlayground: Story = { + name: 'CardBody', + render: (args) => { + const { children } = args; + + return ( + + + + + + + + {LOGO} + + + + Card eyebrow + + Card Title + +

{children}

+
+ + + Primary + + + Secondary + + +
+
+ ); + }, +}; diff --git a/packages/web-react/src/components/Card/stories/CardEyebrow.stories.tsx b/packages/web-react/src/components/Card/stories/CardEyebrow.stories.tsx new file mode 100644 index 0000000000..1b32121fd2 --- /dev/null +++ b/packages/web-react/src/components/Card/stories/CardEyebrow.stories.tsx @@ -0,0 +1,77 @@ +import { Markdown } from '@storybook/blocks'; +import type { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; +import { ButtonLink } from '../../ButtonLink'; +import { Container } from '../../Container'; +import { PartnerLogo } from '../../PartnerLogo'; +import Card from '../Card'; +import CardBody from '../CardBody'; +import CardEyebrow from '../CardEyebrow'; +import CardFooter from '../CardFooter'; +import CardLogo from '../CardLogo'; +import CardMedia from '../CardMedia'; +import CardTitle from '../CardTitle'; +import { LOGO, MEDIA_IMAGE } from '../demo/constants'; +import ReadMe from '../README.md'; + +const meta: Meta = { + title: 'Components/Card', + component: CardEyebrow, + parameters: { + docs: { + page: () => {ReadMe}, + }, + }, + argTypes: { + children: { + control: 'text', + description: 'Text to display in the CardEyebrow.', + }, + }, + args: { + children: 'Card Eyebrow', + }, +}; + +export default meta; +type Story = StoryObj; + +export const CardEyebrowPlayground: Story = { + name: 'CardEyebrow', + render: (args) => { + const { children } = args; + + return ( + + + + + + + + {LOGO} + + + + {children} + Card Title +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla accumsan, metus ultrices eleifend gravida, + nulla nunc varius lectus, nec rutrum justo nibh eu lectus. Ut vulputate semper dui. Fusce erat. Morbi + fringilla convallis sapien. Sed ac felis. Aliquam erat volutpat. Aliquam euismod. Aenean vel lectus. Nunc + imperdiet justo nec dolor. +

+
+ + + Primary + + + Secondary + + +
+
+ ); + }, +}; diff --git a/packages/web-react/src/components/Card/stories/CardFooter.stories.tsx b/packages/web-react/src/components/Card/stories/CardFooter.stories.tsx new file mode 100644 index 0000000000..131b3282db --- /dev/null +++ b/packages/web-react/src/components/Card/stories/CardFooter.stories.tsx @@ -0,0 +1,89 @@ +import { Markdown } from '@storybook/blocks'; +import type { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; +import { AlignmentX } from '../../../constants'; +import { ButtonLink } from '../../ButtonLink'; +import { Container } from '../../Container'; +import { PartnerLogo } from '../../PartnerLogo'; +import Card from '../Card'; +import CardBody from '../CardBody'; +import CardEyebrow from '../CardEyebrow'; +import CardFooter from '../CardFooter'; +import CardLogo from '../CardLogo'; +import CardMedia from '../CardMedia'; +import CardTitle from '../CardTitle'; +import { LOGO, MEDIA_IMAGE } from '../demo/constants'; +import ReadMe from '../README.md'; + +const meta: Meta = { + title: 'Components/Card', + component: CardFooter, + parameters: { + docs: { + page: () => {ReadMe}, + }, + }, + argTypes: { + alignmentX: { + control: 'select', + description: 'Alignment inside CardFooter component.', + options: [...Object.values(AlignmentX)], + table: { + defaultValue: { summary: AlignmentX.LEFT }, + }, + }, + children: { + control: 'object', + description: 'Content to display in the CardFooter.', + }, + }, + args: { + alignmentX: AlignmentX.LEFT, + children: ( + <> + + Primary + + + Secondary + + + ), + }, +}; + +export default meta; +type Story = StoryObj; + +export const CardFooterPlayground: Story = { + name: 'CardFooter', + render: (args) => { + const { children } = args; + + return ( + + + + + + + + {LOGO} + + + + Card Title + Card Title +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla accumsan, metus ultrices eleifend gravida, + nulla nunc varius lectus, nec rutrum justo nibh eu lectus. Ut vulputate semper dui. Fusce erat. Morbi + fringilla convallis sapien. Sed ac felis. Aliquam erat volutpat. Aliquam euismod. Aenean vel lectus. Nunc + imperdiet justo nec dolor. +

+
+ {children} +
+
+ ); + }, +}; diff --git a/packages/web-react/src/components/Card/stories/CardLink.stories.tsx b/packages/web-react/src/components/Card/stories/CardLink.stories.tsx new file mode 100644 index 0000000000..3e361d5e13 --- /dev/null +++ b/packages/web-react/src/components/Card/stories/CardLink.stories.tsx @@ -0,0 +1,93 @@ +import { Markdown } from '@storybook/blocks'; +import type { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; +import { ButtonLink } from '../../ButtonLink'; +import { Container } from '../../Container'; +import { PartnerLogo } from '../../PartnerLogo'; +import Card from '../Card'; +import CardBody from '../CardBody'; +import CardEyebrow from '../CardEyebrow'; +import CardFooter from '../CardFooter'; +import CardLink from '../CardLink'; +import CardLogo from '../CardLogo'; +import CardMedia from '../CardMedia'; +import CardTitle from '../CardTitle'; +import { LOGO, MEDIA_IMAGE } from '../demo/constants'; +import ReadMe from '../README.md'; + +const meta: Meta = { + title: 'Components/Card', + component: CardLink, + parameters: { + docs: { + page: () => {ReadMe}, + }, + }, + argTypes: { + children: { + control: 'text', + description: 'Text to display in the CardLink.', + }, + elementType: { + control: 'text', + description: 'Element type for the CardLink href="#" component.', + table: { + defaultValue: { summary: 'a' }, + }, + }, + href: { + control: 'text', + description: 'URL to link to.', + }, + }, + args: { + children: 'Card Link Title', + elementType: 'a', + href: '#', + }, +}; + +export default meta; +type Story = StoryObj; + +export const CardLinkPlayground: Story = { + name: 'CardLink', + render: (args) => { + const { children } = args; + + return ( + + + + + + + + {LOGO} + + + + Card eyebrow + + {children} + +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla accumsan, metus ultrices eleifend gravida, + nulla nunc varius lectus, nec rutrum justo nibh eu lectus. Ut vulputate semper dui. Fusce erat. Morbi + fringilla convallis sapien. Sed ac felis. Aliquam erat volutpat. Aliquam euismod. Aenean vel lectus. Nunc + imperdiet justo nec dolor. +

+
+ + + Primary + + + Secondary + + +
+
+ ); + }, +}; diff --git a/packages/web-react/src/components/Card/stories/CardLogo.stories.tsx b/packages/web-react/src/components/Card/stories/CardLogo.stories.tsx new file mode 100644 index 0000000000..2acb89e910 --- /dev/null +++ b/packages/web-react/src/components/Card/stories/CardLogo.stories.tsx @@ -0,0 +1,80 @@ +import { Markdown } from '@storybook/blocks'; +import type { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; +import { ButtonLink } from '../../ButtonLink'; +import { Container } from '../../Container'; +import { PartnerLogo } from '../../PartnerLogo'; +import Card from '../Card'; +import CardBody from '../CardBody'; +import CardEyebrow from '../CardEyebrow'; +import CardFooter from '../CardFooter'; +import CardLogo from '../CardLogo'; +import CardMedia from '../CardMedia'; +import CardTitle from '../CardTitle'; +import { LOGO, MEDIA_IMAGE } from '../demo/constants'; +import ReadMe from '../README.md'; + +const meta: Meta = { + title: 'Components/Card', + component: CardLogo, + parameters: { + docs: { + page: () => {ReadMe}, + }, + }, + argTypes: { + children: { + control: 'object', + description: 'CardLogo content', + table: { + category: 'Content', + }, + }, + }, + args: { + children: ( + + {LOGO} + + ), + }, +}; + +export default meta; +type Story = StoryObj; + +export const CardLogoPlayground: Story = { + name: 'CardLogo', + render: (args) => { + const { children } = args; + + return ( + + + + + + {children} + + Card eyebrow + Card Title +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla accumsan, metus ultrices eleifend gravida, + nulla nunc varius lectus, nec rutrum justo nibh eu lectus. Ut vulputate semper dui. Fusce erat. Morbi + fringilla convallis sapien. Sed ac felis. Aliquam erat volutpat. Aliquam euismod. Aenean vel lectus. Nunc + imperdiet justo nec dolor. +

+
+ + + Primary + + + Secondary + + +
+
+ ); + }, +}; diff --git a/packages/web-react/src/components/Card/stories/CardMedia.stories.tsx b/packages/web-react/src/components/Card/stories/CardMedia.stories.tsx new file mode 100644 index 0000000000..8a832494db --- /dev/null +++ b/packages/web-react/src/components/Card/stories/CardMedia.stories.tsx @@ -0,0 +1,122 @@ +import { Markdown } from '@storybook/blocks'; +import type { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; +import { CardSizes } from '../../../types'; +import { ButtonLink } from '../../ButtonLink'; +import { Container } from '../../Container'; +import { PartnerLogo } from '../../PartnerLogo'; +import Card from '../Card'; +import CardBody from '../CardBody'; +import CardEyebrow from '../CardEyebrow'; +import CardFooter from '../CardFooter'; +import CardLogo from '../CardLogo'; +import CardMedia from '../CardMedia'; +import CardTitle from '../CardTitle'; +import { LOGO, MEDIA_IMAGE } from '../demo/constants'; +import ReadMe from '../README.md'; + +const meta: Meta = { + title: 'Components/Card', + component: CardMedia, + parameters: { + docs: { + page: () => {ReadMe}, + }, + }, + argTypes: { + hasFilledHeight: { + control: 'boolean', + description: 'Fill the height of the media. Only works when the card direction is not vertical.', + table: { + defaultValue: { summary: 'false' }, + }, + }, + isExpanded: { + control: 'boolean', + description: 'Expand the media to fill the card. Only works when isBoxed is true.', + table: { + defaultValue: { summary: 'false' }, + }, + }, + size: { + control: 'select', + description: 'Size of the media.', + options: [...Object.values(CardSizes)], + table: { + defaultValue: { summary: 'auto' }, + }, + }, + }, + args: { + hasFilledHeight: false, + isExpanded: false, + size: CardSizes.AUTO, + }, +}; + +export default meta; +type Story = StoryObj; + +export const CardMediaPlayground: Story = { + name: 'CardMedia', + render: (args) => ( + + + + + + + + {LOGO} + + + + Card eyebrow + Card Title +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla accumsan, metus ultrices eleifend gravida, + nulla nunc varius lectus, nec rutrum justo nibh eu lectus. Ut vulputate semper dui. Fusce erat. Morbi + fringilla convallis sapien. Sed ac felis. Aliquam erat volutpat. Aliquam euismod. Aenean vel lectus. Nunc + imperdiet justo nec dolor. +

+
+ + + Primary + + + Secondary + + +
+ + + + + + + {LOGO} + + + + Card Title + Card Title +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla accumsan, metus ultrices eleifend gravida, + nulla nunc varius lectus, nec rutrum justo nibh eu lectus. Ut vulputate semper dui. Fusce erat. Morbi + fringilla convallis sapien. Sed ac felis. Aliquam erat volutpat. Aliquam euismod. Aenean vel lectus. Nunc + imperdiet justo nec dolor. +

+
+ + + Primary + + + Secondary + + +
+
+ ), +}; diff --git a/packages/web-react/src/components/Card/stories/CardTitle.stories.tsx b/packages/web-react/src/components/Card/stories/CardTitle.stories.tsx new file mode 100644 index 0000000000..e121879863 --- /dev/null +++ b/packages/web-react/src/components/Card/stories/CardTitle.stories.tsx @@ -0,0 +1,93 @@ +import { Markdown } from '@storybook/blocks'; +import type { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; +import { ButtonLink } from '../../ButtonLink'; +import { Container } from '../../Container'; +import { PartnerLogo } from '../../PartnerLogo'; +import Card from '../Card'; +import CardBody from '../CardBody'; +import CardEyebrow from '../CardEyebrow'; +import CardFooter from '../CardFooter'; +import CardLogo from '../CardLogo'; +import CardMedia from '../CardMedia'; +import CardTitle from '../CardTitle'; +import { LOGO, MEDIA_IMAGE } from '../demo/constants'; +import ReadMe from '../README.md'; + +const meta: Meta = { + title: 'Components/Card', + component: CardTitle, + parameters: { + docs: { + page: () => {ReadMe}, + }, + }, + argTypes: { + children: { + control: 'text', + description: 'Text to display in the CardTitle.', + }, + elementType: { + control: 'text', + description: 'Element type for the CardTitle component.', + table: { + defaultValue: { summary: 'h4' }, + }, + }, + isHeading: { + control: 'boolean', + description: 'If true, the CardTitle will render as a heading.', + table: { + defaultValue: { summary: 'true' }, + }, + }, + }, + args: { + children: 'Card Title', + elementType: 'h4', + isHeading: true, + }, +}; + +export default meta; +type Story = StoryObj; + +export const CardTitlePlayground: Story = { + name: 'CardTitle', + render: (args) => { + const { children } = args; + + return ( + + + + + + + + {LOGO} + + + + Card Eyebrow + {children} +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla accumsan, metus ultrices eleifend gravida, + nulla nunc varius lectus, nec rutrum justo nibh eu lectus. Ut vulputate semper dui. Fusce erat. Morbi + fringilla convallis sapien. Sed ac felis. Aliquam erat volutpat. Aliquam euismod. Aenean vel lectus. Nunc + imperdiet justo nec dolor. +

+
+ + + Primary + + + Secondary + + +
+
+ ); + }, +}; diff --git a/packages/web-react/src/components/Card/useCardStyleProps.ts b/packages/web-react/src/components/Card/useCardStyleProps.ts new file mode 100644 index 0000000000..953e9a1f1d --- /dev/null +++ b/packages/web-react/src/components/Card/useCardStyleProps.ts @@ -0,0 +1,99 @@ +import classNames from 'classnames'; +import { useAlignmentClass, useClassNamePrefix } from '../../hooks'; +import { CardAlignmentXType, CardDirectionDictionaryType, CardSizesDictionaryType } from '../../types'; +import { kebabCaseToCamelCase } from '../../utils'; + +export interface UseCardStyleProps { + artworkAlignmentX?: CardAlignmentXType; + direction?: CardDirectionDictionaryType; + footerAlignmentX?: CardAlignmentXType; + hasFilledHeight?: boolean; + isBoxed?: boolean; + isExpanded?: boolean; + isHeading?: boolean; + isSelectable?: boolean; + size?: CardSizesDictionaryType; +} + +export interface UseCardStylePropsReturn { + /** className props */ + classProps: { + artwork: string; + body: string; + eyebrow: string; + footer: string; + link: string; + logo: string; + media: string; + mediaCanvas: string; + root: string; + title: string; + }; +} + +export function useCardStyleProps(props?: UseCardStyleProps): UseCardStylePropsReturn { + const { + artworkAlignmentX, + direction, + footerAlignmentX, + hasFilledHeight, + isBoxed, + isExpanded, + isHeading, + isSelectable, + size, + } = props || {}; + const cardClass = useClassNamePrefix('Card'); + const artworkClass = `${cardClass}Artwork`; + const bodyClass = `${cardClass}Body`; + const eyebrowClass = `${cardClass}Eyebrow`; + const footerClass = `${cardClass}Footer`; + const linkClass = `${cardClass}Link`; + const logoClass = `${cardClass}Logo`; + const mediaClass = `${cardClass}Media`; + const titleClass = `${cardClass}Title`; + + const bodyIsSelectableClass = `${bodyClass}--selectable`; + const directionClass = direction ? `${cardClass}--${kebabCaseToCamelCase(direction)}` : ''; + const isBoxedClass = `${cardClass}--boxed`; + const mediaCanvasClass = `${mediaClass}__canvas`; + const mediaHasFilledHeightClass = `${mediaClass}--filledHeight`; + const mediaIsExpandedClass = `${mediaClass}--expanded`; + const mediaSizeClass = size ? `${mediaClass}--${size}` : ''; + const titleHeadingClass = `${titleClass}--heading`; + + const artworkClasses = classNames(artworkClass, { + [useAlignmentClass(artworkClass, artworkAlignmentX!, 'alignmentX')]: artworkAlignmentX, + }); + const bodyClasses = classNames(bodyClass, { + [bodyIsSelectableClass]: isSelectable, + }); + const footerClasses = classNames(footerClass, { + [useAlignmentClass(footerClass, footerAlignmentX!, 'alignmentX')]: footerAlignmentX, + }); + const mediaClasses = classNames(mediaClass, mediaSizeClass, { + [mediaIsExpandedClass]: isExpanded, + [mediaHasFilledHeightClass]: hasFilledHeight, + }); + const rootClasses = classNames(cardClass, directionClass, { + [isBoxedClass]: isBoxed, + }); + const titleClasses = classNames(titleClass, { + [titleHeadingClass]: isHeading, + }); + + return { + classProps: { + artwork: artworkClasses, + body: bodyClasses, + eyebrow: eyebrowClass, + footer: footerClasses, + link: linkClass, + logo: logoClass, + media: mediaClasses, + mediaCanvas: mediaCanvasClass, + root: rootClasses, + title: titleClasses, + }, + }; +} diff --git a/packages/web-react/src/components/index.ts b/packages/web-react/src/components/index.ts index 857be0d09b..c9d80396ac 100644 --- a/packages/web-react/src/components/index.ts +++ b/packages/web-react/src/components/index.ts @@ -4,6 +4,7 @@ export * from './Accordion'; export * from './Alert'; export * from './Breadcrumbs'; export * from './Button'; +export * from './Card'; export * from './Checkbox'; export * from './Collapse'; export * from './Container'; diff --git a/packages/web-react/src/types/card.ts b/packages/web-react/src/types/card.ts new file mode 100644 index 0000000000..75aa5fed0b --- /dev/null +++ b/packages/web-react/src/types/card.ts @@ -0,0 +1,113 @@ +import { ElementType, JSXElementConstructor } from 'react'; +import { Direction, Sizes } from '../constants'; +import { + AlignmentXDictionaryType, + ChildrenProps, + SpiritPolymorphicElementPropsWithRef, + StyleProps, + TransferProps, +} from './shared'; + +export const CardSizes = { + ...Sizes, + AUTO: 'auto', +} as const; + +export type CardSizesDictionaryKeys = keyof typeof CardSizes; +export type CardSizesDictionaryType = (typeof CardSizes)[CardSizesDictionaryKeys] | T; + +export const CardDirection = { + ...Direction, + HORIZONTAL_REVERSED: 'horizontal-reversed', +}; + +export type CardDirectionDictionaryKeys = keyof typeof CardDirection; +export type CardDirectionDictionaryType = (typeof CardDirection)[CardDirectionDictionaryKeys]; + +export type CardAlignmentXType = + | NonNullable + | { [key: string]: NonNullable }; + +export interface CardElementTypeProps { + /** + * The HTML element or React element used to render the Card, e.g. 'div'. + * + * @default 'article' + */ + elementType?: T | JSXElementConstructor; +} + +// Card types +// Extend direction props to include horizontal-reversed +export type HorizontalReversedType = 'horizontal-reversed'; + +export interface CardProps extends CardElementTypeProps { + direction?: CardDirectionDictionaryType; + isBoxed?: boolean; +} + +export interface SpiritCardProps + extends CardProps, + ChildrenProps, + StyleProps, + TransferProps {} + +// CardMedia types +export interface CardMediaProps { + isExpanded?: boolean; + size?: CardSizesDictionaryType; + hasFilledHeight?: boolean; +} + +export interface SpiritCardMediaProps extends CardMediaProps, ChildrenProps, StyleProps, TransferProps {} + +// CardLogo types +export interface SpiritCardLogoProps extends ChildrenProps, StyleProps, TransferProps {} + +// CardArtwork types +export interface CardArtworkProps { + alignmentX?: CardAlignmentXType; +} +export interface SpiritCardArtworkProps extends CardArtworkProps, ChildrenProps, StyleProps, TransferProps {} + +// CardBody types +export interface CardBodyProps { + isSelectable?: boolean; +} + +export interface SpiritCardBodyProps extends CardBodyProps, ChildrenProps, StyleProps, TransferProps {} + +// CardEyebrow types +export interface SpiritCardEyebrowProps extends ChildrenProps, StyleProps, TransferProps {} + +// CardTitle types +export interface CardTitleProps { + isHeading?: boolean; +} + +export interface SpiritCardTitleProps + extends CardTitleProps, + CardElementTypeProps, + ChildrenProps, + StyleProps, + TransferProps {} + +// CardFooter types +export interface CardFooterProps { + alignmentX?: CardAlignmentXType; +} + +export interface SpiritCardFooterProps extends CardFooterProps, ChildrenProps, StyleProps, TransferProps {} + +// CardLink types +export type CardLinkProps = { + /** + * The HTML element or React element used to render the Link, e.g. 'a'. + * + * @default 'a' + */ + elementType?: E; +}; + +export type SpiritCardLinkProps = CardLinkProps & + SpiritPolymorphicElementPropsWithRef>; diff --git a/packages/web-react/src/types/index.ts b/packages/web-react/src/types/index.ts index 45c359fc65..2b2d412fb3 100644 --- a/packages/web-react/src/types/index.ts +++ b/packages/web-react/src/types/index.ts @@ -3,6 +3,7 @@ export * from './alert'; export * from './avatar'; export * from './breadcrumbs'; export * from './button'; +export * from './card'; export * from './checkbox'; export * from './collapse'; export * from './container'; diff --git a/packages/web-react/tests/providerTests/dictionaryPropsTest.tsx b/packages/web-react/tests/providerTests/dictionaryPropsTest.tsx index b2c795714e..21aac5dec1 100644 --- a/packages/web-react/tests/providerTests/dictionaryPropsTest.tsx +++ b/packages/web-react/tests/providerTests/dictionaryPropsTest.tsx @@ -1,22 +1,28 @@ import { render, waitFor } from '@testing-library/react'; import React, { ComponentType } from 'react'; import { - ActionButtonColorsDictionaryType, ActionButtonColors, - ActionColorsDictionaryType, + ActionButtonColorsDictionaryType, ActionColors, - ActionLinkColorsDictionaryType, + ActionColorsDictionaryType, ActionLinkColors, - EmotionColorsDictionaryType, + ActionLinkColorsDictionaryType, + AlignmentX, + AlignmentXDictionaryType, + AlignmentXExtended, + AlignmentXExtendedDictionaryType, + AlignmentYDictionaryType, + AlignmentYExtendedDictionaryType, EmotionColors, + EmotionColorsDictionaryType, SizeExtendedDictionaryType, - SizesExtended, - SizesDictionaryType, Sizes, - TextColorsDictionaryType, + SizesDictionaryType, + SizesExtended, TextColors, - ValidationStatesDictionaryType, + TextColorsDictionaryType, ValidationStates, + ValidationStatesDictionaryType, } from '../../src'; import getElement from '../testUtils/getElement'; @@ -115,3 +121,51 @@ export const validationStatePropsTest = (Component: ComponentType, prefix: }); }); }; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const alignmentXPropsTest = (Component: ComponentType, prefix?: string, testId?: string) => { + it.each([Object.values(AlignmentX)])('should render alignmentX %s', async (alignment) => { + const dom = render(} />); + + await waitFor(() => { + const element = getElement(dom, testId); + expect(element).toHaveClass(`${prefix}--alignmentX${alignment.charAt(0).toUpperCase() + alignment.slice(1)}`); + }); + }); +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const alignmentXExtendedPropsTest = (Component: ComponentType, prefix?: string, testId?: string) => { + it.each([Object.values(AlignmentXExtended)])('should render extended alignmentX %s', async (alignment) => { + const dom = render(} />); + + await waitFor(() => { + const element = getElement(dom, testId); + expect(element).toHaveClass(`${prefix}--alignmentX${alignment.charAt(0).toUpperCase() + alignment.slice(1)}`); + }); + }); +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const alignmentYPropsTest = (Component: ComponentType, prefix?: string, testId?: string) => { + it.each([Object.values(AlignmentX)])('should render alignmentY %s', async (alignment) => { + const dom = render(} />); + + await waitFor(() => { + const element = getElement(dom, testId); + expect(element).toHaveClass(`${prefix}--alignmentY${alignment.charAt(0).toUpperCase() + alignment.slice(1)}`); + }); + }); +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const alignmentYExtendedPropsTest = (Component: ComponentType, prefix?: string, testId?: string) => { + it.each([Object.values(AlignmentXExtended)])('should render extended alignmentY %s', async (alignment) => { + const dom = render(} />); + + await waitFor(() => { + const element = getElement(dom, testId); + expect(element).toHaveClass(`${prefix}--alignmentY${alignment.charAt(0).toUpperCase() + alignment.slice(1)}`); + }); + }); +};