Skip to content

Commit

Permalink
Feat(web-react): Introduce responsive layouts of Card #DS-1559
Browse files Browse the repository at this point in the history
  • Loading branch information
pavelklibani authored and adamkudrna committed Dec 12, 2024
1 parent 361e478 commit 82f43fb
Show file tree
Hide file tree
Showing 8 changed files with 190 additions and 15 deletions.
7 changes: 7 additions & 0 deletions docs/DICTIONARIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ This project uses `dictionaries` to unify props between different components.
| Emotion Color | `success`, `informative`, `warning`, `danger` | EmotionColor |
| Text Color | `primary`, `secondary`, `primary-inverted`, `secondary-inverted` | TextColor |

### Direction

| Dictionary | Values | Code name |
| ------------- | ------------------------ | ------------- |
| Direction | `horizontal`, `vertical` | Direction |
| DirectionAxis | `x`, `y` | DirectionAxis |

### Emphasis

| Dictionary | Values | Code name |
Expand Down
21 changes: 16 additions & 5 deletions packages/web-react/src/components/Card/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,16 @@ Card can be displayed in a vertical, horizontal, or reversed horizontal layout.
👉 Keep in mind that, no matter the layout, the Card subcomponents must be arranged in the order
[specified above](#card-1).

### Responsive Card Layout

Pass an object to props to set different values for different breakpoints. The values will
be applied from mobile to desktop and if not set for a breakpoint, the value from the
previous breakpoint will be used.

```jsx
<Card direction={{ mobile: 'vertical', tablet: 'horizontal', desktop: 'horizontal-reversed' }}>{/**/}</Card>
```

### Boxed Cards

Card can be displayed with a border and a box shadow on hover.
Expand All @@ -79,11 +89,11 @@ Card can be displayed with a border and a box shadow on hover.

### 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 |
| Name | Type | Default | Required | Description |
| ------------- | --------------------------------------------------------------------------------- | ---------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `direction` | [[Direction dictionary][dictionary-direction], `horizontal-reversed` \| `object`] | `vertical` || Direction of the content inside Card component, use object to set responsive values, e.g. `{ mobile: 'horizontal', tablet: 'vertical', desktop: 'horizontal-reversed' }` |
| `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]
Expand Down Expand Up @@ -451,6 +461,7 @@ When you put it all together:
ℹ️ 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-direction]: https://github.com/lmc-eu/spirit-design-system/blob/main/docs/DICTIONARIES.md#direction
[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/
Expand Down
74 changes: 74 additions & 0 deletions packages/web-react/src/components/Card/demo/CardResponsiveCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import React from 'react';
import { ButtonLink } from '../../Button';
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 CardResponsiveCard = () => {
return (
<Grid cols={{ mobile: 1, tablet: 2 }}>
<Card direction={{ mobile: 'vertical', tablet: 'horizontal', desktop: 'horizontal-reversed' }} isBoxed>
<CardMedia size="medium">{MEDIA_IMAGE}</CardMedia>
<CardLogo>
<PartnerLogo size="small" hasSafeArea>
{LOGO}
</PartnerLogo>
</CardLogo>
<CardBody>
<CardEyebrow>Responsive card layout</CardEyebrow>
<CardTitle isHeading>
<CardLink href="#">Vertical → horizontal → reversed horizontal</CardLink>
</CardTitle>
{/* User content */}
<p>Lorem ipsum dolor sit amet, consectetuer adipiscing elit.</p>
{/* End user content */}
</CardBody>
<CardFooter>
<ButtonLink href="#" color="primary">
Primary
</ButtonLink>
<ButtonLink href="#" color="secondary">
Secondary
</ButtonLink>
</CardFooter>
</Card>
<Card direction={{ mobile: 'vertical', tablet: 'horizontal', desktop: 'horizontal-reversed' }} isBoxed>
<CardMedia size="medium" isExpanded>
{MEDIA_IMAGE}
</CardMedia>
<CardLogo>
<PartnerLogo size="small" hasSafeArea>
{LOGO}
</PartnerLogo>
</CardLogo>
<CardBody>
<CardEyebrow>Responsive card layout</CardEyebrow>
<CardTitle isHeading>
<CardLink href="#">Vertical → horizontal → reversed horizontal, expanded media</CardLink>
</CardTitle>
{/* User content */}
<p>Lorem ipsum dolor sit amet, consectetuer adipiscing elit.</p>
{/* End user content */}
</CardBody>
<CardFooter>
<ButtonLink href="#" color="primary">
Primary
</ButtonLink>
<ButtonLink href="#" color="secondary">
Secondary
</ButtonLink>
</CardFooter>
</Card>
</Grid>
);
};

export default CardResponsiveCard;
4 changes: 4 additions & 0 deletions packages/web-react/src/components/Card/demo/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import CardHorizontalLayout from './CardHorizontalLayout';
import CardLogoDemo from './CardLogo';
import { CardMediaOptions } from './CardMediaOptions';
import CardMediaSizes from './CardMediaSizes';
import CardResponsiveCard from './CardResponsiveCard';
import CardReversedHorizontalLayout from './CardReversedHorizontalLayout';
import CardText from './CardText';
import CardTitleOptions from './CardTitleOptions';
Expand All @@ -38,6 +39,9 @@ ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<DocsSection title="Reversed Horizontal Card Layout">
<CardReversedHorizontalLayout />
</DocsSection>
<DocsSection title="Responsive Card Layout">
<CardResponsiveCard />
</DocsSection>
<DocsSection title="Media Options">
<CardMediaOptions />
</DocsSection>
Expand Down
13 changes: 8 additions & 5 deletions packages/web-react/src/components/Card/useCardStyleProps.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import classNames from 'classnames';
import { useAlignmentClass, useClassNamePrefix } from '../../hooks';
import { CardAlignmentXType, CardDirectionDictionaryType, CardSizesDictionaryType } from '../../types';
import { kebabCaseToCamelCase } from '../../utils';
import { CardAlignmentXType, CardDirectionType, CardSizesDictionaryType } from '../../types';
import { generateStylePropsClassNames, stringOrObjectKebabCaseToCamelCase } from '../../utils';

export interface UseCardStyleProps {
artworkAlignmentX?: CardAlignmentXType;
direction?: CardDirectionDictionaryType;
direction?: CardDirectionType;
footerAlignmentX?: CardAlignmentXType;
hasFilledHeight?: boolean;
isBoxed?: boolean;
Expand Down Expand Up @@ -54,7 +54,9 @@ export function useCardStyleProps(props?: UseCardStyleProps): UseCardStylePropsR
const titleClass = `${cardClass}Title`;

const bodyIsSelectableClass = `${bodyClass}--selectable`;
const directionClass = direction ? `${cardClass}--${kebabCaseToCamelCase(direction)}` : '';

const directionClass = generateStylePropsClassNames(cardClass, stringOrObjectKebabCaseToCamelCase(direction!));

const isBoxedClass = `${cardClass}--boxed`;
const mediaCanvasClass = `${mediaClass}__canvas`;
const mediaHasFilledHeightClass = `${mediaClass}--filledHeight`;
Expand All @@ -75,7 +77,8 @@ export function useCardStyleProps(props?: UseCardStyleProps): UseCardStylePropsR
[mediaIsExpandedClass]: isExpanded,
[mediaHasFilledHeightClass]: hasFilledHeight,
});
const rootClasses = classNames(cardClass, directionClass, {
const rootClasses = classNames(cardClass, {
[directionClass]: direction,
[isBoxedClass]: isBoxed,
});
const titleClasses = classNames(titleClass, {
Expand Down
5 changes: 4 additions & 1 deletion packages/web-react/src/types/card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,12 @@ export interface CardElementTypeProps<T extends ElementType = 'article'> {
// Card types
// Extend direction props to include horizontal-reversed
export type HorizontalReversedType = 'horizontal-reversed';
export type CardDirectionType =
| NonNullable<CardDirectionDictionaryType>
| { [key: string]: NonNullable<CardDirectionDictionaryType> };

export interface CardProps<T extends ElementType = 'article'> extends CardElementTypeProps<T> {
direction?: CardDirectionDictionaryType;
direction?: CardDirectionType;
isBoxed?: boolean;
}

Expand Down
38 changes: 36 additions & 2 deletions packages/web-react/src/utils/__tests__/string.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { kebabCaseToCamelCase } from '../string';
import { kebabCaseToCamelCase, kebabCaseToCamelCaseValues, stringOrObjectKebabCaseToCamelCase } from '../string';

describe('string', () => {
describe('#kebabCaseToCamelCase', () => {
Expand All @@ -11,7 +11,41 @@ describe('string', () => {
['kebab-case-test', 'kebabCaseTest'],
])('should convert kebab-case string "%s" to camelCase string "%s"', (input, expected) => {
const result = kebabCaseToCamelCase(input);
expect(result).toBe(expected);
expect(result).toEqual(expected);
});
});

describe('#kebabCaseToCamelCaseValues', () => {
it.each([
[{ test: 'foo-bar' }, { test: 'fooBar' }],
[{ test: 'test-case' }, { test: 'testCase' }],
[{ test: 'some-words-here' }, { test: 'someWordsHere' }],
[{ test: 'single' }, { test: 'single' }],
[{ test: '' }, { test: '' }],
[{ test: 'kebab-case-test' }, { test: 'kebabCaseTest' }],
])('should convert kebab-case object "%s" to camelCase object "%s"', (input, expected) => {
const result = kebabCaseToCamelCaseValues(input);
expect(result).toEqual(expected);
});
});

describe('#stringOrObjectKebabCaseToCamelCase', () => {
it.each([
['foo-bar', 'fooBar'],
['test-case', 'testCase'],
['some-words-here', 'someWordsHere'],
['single', 'single'],
['', ''],
['kebab-case-test', 'kebabCaseTest'],
[{ test: 'foo-bar' }, { test: 'fooBar' }],
[{ test: 'test-case' }, { test: 'testCase' }],
[{ test: 'some-words-here' }, { test: 'someWordsHere' }],
[{ test: 'single' }, { test: 'single' }],
[{ test: '' }, { test: '' }],
[{ test: 'kebab-case-test' }, { test: 'kebabCaseTest' }],
])('should convert kebab-case object "%s" to camelCase object "%s"', (input, expected) => {
const result = stringOrObjectKebabCaseToCamelCase(input);
expect(result).toEqual(expected);
});
});
});
43 changes: 41 additions & 2 deletions packages/web-react/src/utils/string.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,45 @@
/**
* Converts a kebab-case string to camelCase
* Converts a kebab-case string to camelCase.
*
* @param str
* @param {string} str - The kebab-case string to be converted.
* @returns {string} The camelCase version of the input string.
*/
export const kebabCaseToCamelCase = (str: string): string => str.replace(/-([a-z])/g, (g) => g[1].toUpperCase());

/**
* Converts an object with kebab-case string values to camelCase.
*
* @param {Record<string, string>} input - The input to be converted.
* @returns {Record<string, string>} The converted input.
*/
export const kebabCaseToCamelCaseValues = (input: Record<string, string>): Record<string, string> => {
if (typeof input === 'object' && input !== null) {
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(input)) {
result[key] = typeof value === 'string' ? kebabCaseToCamelCase(value) : value;
}

return result;
}

return input;
};

/**
* Converts a kebab-case string or an object with kebab-case values to camelCase.
*
* @param {string | Record<string, string>} input - The input to be converted.
* @returns {string | Record<string, string>} The converted input.
*/
export const stringOrObjectKebabCaseToCamelCase = (
input: string | Record<string, string>,
): string | Record<string, string> => {
if (typeof input === 'string') {
return kebabCaseToCamelCase(input);
}
if (typeof input === 'object' && input !== null) {
return kebabCaseToCamelCaseValues(input);
}

return input;
};

0 comments on commit 82f43fb

Please sign in to comment.