Skip to content

Commit

Permalink
chore(ui-kit): add clickable functionality to Card (#2706)
Browse files Browse the repository at this point in the history
* chore(ui-kit): first pass at card container changes

* chore(ui-kit): install react-router-dom as card component dependency for dependent link component

* chore(ui-kit): add button role for aria compatibility

* chore(ui-kit): add aria-disabled

* chore(ui-kit): fix incorrect test names

* chore(ui-kit): refine a11y strategy

* chore(ui-kit): correct incorrectly defined prop name

* chore(ui-kit): handle LocationDescriptor type

* chore(ui-kit): generate changeset

* chore(ui-kit): prefer warning over throwing error

* chore(ui-kit): remove unnecessary link role

* chore(ui-kit): remove brackets for prop name

* chore(ui-kit): fix storybook prop

* chore(ui-kit): fix disabled logic

* chore(ui-kit): assert parent container type

* chore(ui-kit): cast proptype

* chore(ui-kit): enable disabled state only if onClick or to provided

* chore(ui-kit): fix test note

* chore(ui-kit): enable disable in visualroute spec

* chore(ui-kit): improve variable naming

* chore(ui-kit): support button accessibility

* chore(ui-kit): remove prop from knobs
  • Loading branch information
jaikamat authored Jan 31, 2024
1 parent a0abf51 commit 7f42b3d
Show file tree
Hide file tree
Showing 8 changed files with 313 additions and 52 deletions.
5 changes: 5 additions & 0 deletions .changeset/proud-years-grab.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@commercetools-uikit/card': patch
---

We've extended the Card component with onClick and internal/external linking functionality, along with disabled functionality and styling.
18 changes: 11 additions & 7 deletions packages/components/card/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,14 @@ export default Example;

## Properties

| Props | Type | Required | Default | Description |
| ------------ | ------------------------------------------------------------------ | :------: | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type` | `union`<br/>Possible values:<br/>`'raised' , 'flat'` | | `'raised'` | Determines the visual effect of the card. A raised card has a box shadow while a flat card has just a border. |
| `insetScale` | `union`<br/>Possible values:<br/>`'none' , 's' , 'm' , 'l' , 'xl'` | | `'m'` | Determines the spacing (padding) that the content should have from the card borders. In case there is no space needed, you can pass `none`. |
| `theme` | `union`<br/>Possible values:<br/>`'light' , 'dark'` | | `'light'` | Determines the background color of the card. |
| `className` | `string` | | | Pass a custom CSS class, useful to override the styles.&#xA;<br>&#xA;NOTE: This is not recommended and should only be used for building new components&#xA;that require special style adjustments. |
| `children` | `ReactNode` | | | |
| Props | Type | Required | Default | Description |
| ---------------- | ------------------------------------------------------------------ | :------: | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type` | `union`<br/>Possible values:<br/>`'raised' , 'flat'` | | `'raised'` | Determines the visual effect of the card. A raised card has a box shadow while a flat card has just a border. |
| `insetScale` | `union`<br/>Possible values:<br/>`'none' , 's' , 'm' , 'l' , 'xl'` | | `'m'` | Determines the spacing (padding) that the content should have from the card borders. In case there is no space needed, you can pass `none`. |
| `theme` | `union`<br/>Possible values:<br/>`'light' , 'dark'` | | `'light'` | Determines the background color of the card. |
| `className` | `string` | | | Pass a custom CSS class, useful to override the styles.&#xA;<br>&#xA;NOTE: This is not recommended and should only be used for building new components&#xA;that require special style adjustments. |
| `children` | `ReactNode` | | | |
| `onClick` | `() => void` | | | The callback function to be executed when the Card component is clicked. Prefer this for managing side effects rather than navigation. |
| `to` | `union`<br/>Possible values:<br/>`string , LocationDescriptor` | | | The URL that the Card should point to. When provided, the Card will render as a react-router `<Link>`. Use with `isExternal` to determine if an internal or external link should be used. |
| `isExternalLink` | `boolean` | | | A flag to indicate if the Card points to an external source. When true, the Card will render as an `<a>` tag with appropriate attributes for external links. |
| `isDisabled` | `boolean` | | | Indicates that a clickable Card should not allow clicks. This is useful for temporarily disabling a clickable Card. Use in conjunction with `to` and/or `onClick`. |
7 changes: 5 additions & 2 deletions packages/components/card/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,15 @@
"@commercetools-uikit/utils": "17.0.1",
"@emotion/react": "^11.10.5",
"@emotion/styled": "^11.10.5",
"@types/react-router-dom": "^5.3.3",
"prop-types": "15.8.1"
},
"devDependencies": {
"react": "17.0.2"
"react": "17.0.2",
"react-router-dom": "5.3.4"
},
"peerDependencies": {
"react": "17.x"
"react": "17.x",
"react-router-dom": "5.x"
}
}
112 changes: 111 additions & 1 deletion packages/components/card/src/card.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { screen, render } from '../../../../test/test-utils';
import { screen, render, fireEvent } from '../../../../test/test-utils';
import Card from './card';
import { BrowserRouter } from 'react-router-dom'; // Required for testing <Link>

it('should render children', () => {
render(<Card>Bread</Card>);
Expand All @@ -10,3 +11,112 @@ it('should pass data attributes', () => {
const { container } = render(<Card data-testid="hefe">Bread</Card>);
expect(container.querySelector("[data-testid='hefe']")).toBeInTheDocument();
});

it('should call `onClick` when the card is clicked', () => {
const handleClick = jest.fn();
render(<Card onClick={handleClick}>Clickable Content</Card>);

fireEvent.click(screen.getByText('Clickable Content'));
expect(handleClick).toHaveBeenCalledTimes(1);
});

it('should not call `onClick` when the card is disabled', () => {
const handleClick = jest.fn();
render(
<Card onClick={handleClick} isDisabled>
Disabled Content
</Card>
);

fireEvent.click(screen.getByText('Disabled Content'));
expect(handleClick).not.toHaveBeenCalled();
});

it('should render as a react-router `Link` when `to` prop is provided', () => {
const content = 'Internal Link';
render(
<BrowserRouter>
<Card to="/internal-link">{content}</Card>
</BrowserRouter>
);

const link = screen.getByText(content).closest('a');
expect(link).toHaveAttribute('href', '/internal-link');
expect(link).not.toHaveAttribute('target', '_blank');
expect(link).not.toHaveAttribute('rel', 'noopener noreferrer');
});

it('should render as an external link when `to` and `isExternalLink` props are provided', () => {
const content = 'External Link';
render(
<Card to="http://www.commercetools.com" isExternalLink>
{content}
</Card>
);

const link = screen.getByText(content).closest('a');
expect(link).toHaveAttribute('href', 'http://www.commercetools.com');
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
});

it('should not trigger disabled styling without `to` or `onClick` props', () => {
const { container } = render(<Card isDisabled>Disabled Card</Card>);

const card = container.firstChild;
// Content should not have opacity change
expect(card?.firstChild).not.toHaveStyle(`opacity: 0.5`);
// Cursor should be unaffected
expect(card).not.toHaveStyle(`cursor: not-allowed`);
});

it('should trigger disabled styling when `to` or `onClick` props are provided', () => {
const { container } = render(
<Card to="http://www.commercetools.com" isDisabled>
Disabled Card
</Card>
);

const card = container.firstChild;
// Content should have opacity change, not the card container
expect(card?.firstChild).toHaveStyle(`opacity: 0.5`);
// Cursor should be affected
expect(card).toHaveStyle(`cursor: not-allowed`);
});

it('should support accessibility as a button when the `onClick` prop is provided', () => {
render(
<Card data-testid="hefe" onClick={() => {}}>
Content
</Card>
);

const card = screen.getByTestId('hefe');
expect(card).toHaveAttribute('role', 'button');
});

it('should render a `<div>` parent container when disabled', () => {
render(
<Card
data-testid="hefe"
to="http://www.commercetools.com"
isExternalLink
isDisabled
>
Content
</Card>
);

const card = screen.getByTestId('hefe');
expect(card.tagName).toBe('DIV');
});

it('should call `onClick` when "Enter" key is pressed', () => {
const handleClick = jest.fn();
render(<Card onClick={handleClick}>Accessible button</Card>);

const card = screen.getByText('Accessible button');
fireEvent.keyDown(card, { key: 'Enter', code: 'Enter' });

expect(handleClick).toHaveBeenCalledTimes(1);
});
67 changes: 43 additions & 24 deletions packages/components/card/src/card.story.js
Original file line number Diff line number Diff line change
@@ -1,41 +1,60 @@
/* eslint-disable react/prop-types */

import { storiesOf } from '@storybook/react';
import { withKnobs, text } from '@storybook/addon-knobs/react';
import { withKnobs, text, boolean } from '@storybook/addon-knobs/react';
import Spacings from '@commercetools-uikit/spacings';
import Text from '@commercetools-uikit/text';
import Section from '../../../../docs/.storybook/decorators/section';
import Readme from '../README.md';
import Card from './card';
import { BrowserRouter as Router } from 'react-router-dom';

const themes = ['light', 'dark'];
const insetScaleValues = ['none', 's', 'm', 'l', 'xl'];
const content =
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Arcu dictum varius duis at consectetur lorem donec.';

const CardStoryTemplate = (props) => (
<Section>
{themes.map((theme) => (
<Spacings.Stack key={theme}>
<Text.Headline as="h2">
<code>theme: {theme}</code>
</Text.Headline>
<Spacings.Inline>
{insetScaleValues.map((scale) => (
<Spacings.Stack key={scale} scale="s">
<Text.Body fontWeight="bold">
<code>insetScale: {scale}</code>
</Text.Body>
<Card insetScale={scale} type={props.type} theme={theme}>
{text('content', content)}
</Card>
</Spacings.Stack>
))}
</Spacings.Inline>
</Spacings.Stack>
))}
</Section>
);
const CardStoryTemplate = (props) => {
const onClickEnabled = boolean('onClick enabled', false);
const isDisabled = boolean('isDisabled', false);
const to = text('to', '');

const handleClick = onClickEnabled ? () => alert('Card clicked') : undefined;

return (
<Router>
<Section>
{themes.map((theme) => (
<Spacings.Stack key={theme}>
<Text.Headline as="h2">
<code>theme: {theme}</code>
</Text.Headline>
<Spacings.Inline>
{insetScaleValues.map((scale) => (
<Spacings.Stack key={scale} scale="s">
<Text.Body fontWeight="bold">
<code>insetScale: {scale}</code>
</Text.Body>
<Card
insetScale={scale}
type={props.type}
theme={theme}
onClick={handleClick}
isDisabled={isDisabled}
to={to ? to : undefined}
isExternalLink={false}
>
{text('content', content)}
</Card>
</Spacings.Stack>
))}
</Spacings.Inline>
</Spacings.Stack>
))}
</Section>
</Router>
);
};

storiesOf('Components|Cards', module)
.addDecorator(withKnobs)
Expand Down
120 changes: 102 additions & 18 deletions packages/components/card/src/card.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { ReactNode } from 'react';
import { KeyboardEvent, ReactNode } from 'react';
import { css } from '@emotion/react';
import { designTokens } from '@commercetools-uikit/design-system';
import { filterDataAttributes } from '@commercetools-uikit/utils';
import { filterDataAttributes, warning } from '@commercetools-uikit/utils';
import Inset from '@commercetools-uikit/spacings-inset';
import { Link } from 'react-router-dom';
import type { LocationDescriptor } from 'history';

export type TCardProps = {
/**
Expand All @@ -25,12 +27,34 @@ export type TCardProps = {
*/
className?: string;
children?: ReactNode;
/**
* The callback function to be executed when the Card component is clicked. Prefer this for managing side effects rather than navigation.
*/
onClick?: () => void;
/**
* The URL that the Card should point to. If provided, the Card will be rendered as an anchor element.
*/
to?: string | LocationDescriptor;
/**
* A flag to indicate if the Card points to an external source.
*/
isExternalLink?: boolean;
/**
* Indicates that a clickable Card should not allow clicks. This allows consumers to temporarily disable a clickable Card.
*/
isDisabled?: boolean;
};

const Card = (props: TCardProps) => (
<div
{...filterDataAttributes(props)}
css={css`
const Card = (props: TCardProps) => {
const isClickable = Boolean(!props.isDisabled && (props.onClick || props.to));
// Only disable styling if the card is not clickable
const shouldBeDisabled = props.isDisabled && (props.onClick || props.to);

const commonProps = {
...filterDataAttributes(props),
onClick: isClickable ? props.onClick : undefined,
'aria-disabled': props.isDisabled ? true : undefined,
css: css`
box-sizing: border-box;
width: 100%;
font-size: 1rem;
Expand All @@ -44,23 +68,83 @@ const Card = (props: TCardProps) => (
background: ${props.theme === 'dark'
? designTokens.colorNeutral95
: designTokens.colorSurface};
`}
// Allow to override the styles by passing a `className` prop.
// Custom styles can also be passed using the `css` prop from emotion.
// https://emotion.sh/docs/css-prop#style-precedence
className={props.className}
>
{props.insetScale === 'none' ? (
// Use a `<div>` to ensure that there is always a wrapper container.
// This is mostly useful in case custom styles are targeting this element.
cursor: ${shouldBeDisabled
? 'not-allowed'
: isClickable
? 'pointer'
: 'default'};
:hover {
background: ${props.theme === 'dark'
? isClickable
? designTokens.colorNeutral90
: undefined
: isClickable
? designTokens.colorNeutral98
: undefined};
}
// Disables link text styling
color: inherit;
// Changes the opacity of the content, not the card itself
& > div {
opacity: ${shouldBeDisabled ? 0.5 : 1};
}
`,
className: props.className,
};

const content =
props.insetScale === 'none' ? (
<div>{props.children}</div>
) : (
<Inset scale={props.insetScale} height="expanded">
{props.children}
</Inset>
)}
</div>
);
);

if (isClickable) {
if (props.to) {
if (props.isExternalLink) {
warning(
typeof props.to === 'string',
'ui-kit/Card: "to" property must be a string when "isExternal" value is true'
);

return (
<a
{...commonProps}
href={props.to as string}
target="_blank"
rel="noopener noreferrer"
>
{content}
</a>
);
} else {
return (
<Link {...commonProps} to={props.to}>
{content}
</Link>
);
}
}
}

return (
<div
{...commonProps}
// Support accessibility as a button when the `onClick` prop is provided
role={isClickable ? 'button' : undefined}
tabIndex={isClickable ? 0 : undefined}
onKeyDown={(event: KeyboardEvent<HTMLDivElement>) => {
if (isClickable && props.onClick && event.key === 'Enter') {
props.onClick();
}
}}
>
{content}
</div>
);
};

const defaultProps: Pick<TCardProps, 'type' | 'theme' | 'insetScale'> = {
type: 'raised',
Expand Down
Loading

0 comments on commit 7f42b3d

Please sign in to comment.