diff --git a/.changeset/proud-years-grab.md b/.changeset/proud-years-grab.md new file mode 100644 index 0000000000..c870484482 --- /dev/null +++ b/.changeset/proud-years-grab.md @@ -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. diff --git a/packages/components/card/README.md b/packages/components/card/README.md index 4d9f13215e..b51c87b0ce 100644 --- a/packages/components/card/README.md +++ b/packages/components/card/README.md @@ -43,10 +43,14 @@ export default Example; ## Properties -| Props | Type | Required | Default | Description | -| ------------ | ------------------------------------------------------------------ | :------: | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `type` | `union`
Possible values:
`'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`
Possible values:
`'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`
Possible values:
`'light' , 'dark'` | | `'light'` | Determines the background color of the card. | -| `className` | `string` | | | Pass a custom CSS class, useful to override the styles.
NOTE: This is not recommended and should only be used for building new components that require special style adjustments. | -| `children` | `ReactNode` | | | | +| Props | Type | Required | Default | Description | +| ---------------- | ------------------------------------------------------------------ | :------: | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `type` | `union`
Possible values:
`'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`
Possible values:
`'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`
Possible values:
`'light' , 'dark'` | | `'light'` | Determines the background color of the card. | +| `className` | `string` | | | Pass a custom CSS class, useful to override the styles.
NOTE: This is not recommended and should only be used for building new components 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`
Possible values:
`string , LocationDescriptor` | | | The URL that the Card should point to. When provided, the Card will render as a react-router ``. 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 `` 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`. | diff --git a/packages/components/card/package.json b/packages/components/card/package.json index be341402be..f36a1d0ae4 100644 --- a/packages/components/card/package.json +++ b/packages/components/card/package.json @@ -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" } } diff --git a/packages/components/card/src/card.spec.tsx b/packages/components/card/src/card.spec.tsx index 03e91af1c7..8897e9e721 100644 --- a/packages/components/card/src/card.spec.tsx +++ b/packages/components/card/src/card.spec.tsx @@ -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 it('should render children', () => { render(Bread); @@ -10,3 +11,112 @@ it('should pass data attributes', () => { const { container } = render(Bread); expect(container.querySelector("[data-testid='hefe']")).toBeInTheDocument(); }); + +it('should call `onClick` when the card is clicked', () => { + const handleClick = jest.fn(); + render(Clickable Content); + + 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( + + Disabled Content + + ); + + 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( + + {content} + + ); + + 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( + + {content} + + ); + + 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(Disabled 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( + + Disabled 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( + {}}> + Content + + ); + + const card = screen.getByTestId('hefe'); + expect(card).toHaveAttribute('role', 'button'); +}); + +it('should render a `
` parent container when disabled', () => { + render( + + Content + + ); + + const card = screen.getByTestId('hefe'); + expect(card.tagName).toBe('DIV'); +}); + +it('should call `onClick` when "Enter" key is pressed', () => { + const handleClick = jest.fn(); + render(Accessible button); + + const card = screen.getByText('Accessible button'); + fireEvent.keyDown(card, { key: 'Enter', code: 'Enter' }); + + expect(handleClick).toHaveBeenCalledTimes(1); +}); diff --git a/packages/components/card/src/card.story.js b/packages/components/card/src/card.story.js index 271435b0c5..ebb57d0180 100644 --- a/packages/components/card/src/card.story.js +++ b/packages/components/card/src/card.story.js @@ -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) => ( -
- {themes.map((theme) => ( - - - theme: {theme} - - - {insetScaleValues.map((scale) => ( - - - insetScale: {scale} - - - {text('content', content)} - - - ))} - - - ))} -
-); +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 ( + +
+ {themes.map((theme) => ( + + + theme: {theme} + + + {insetScaleValues.map((scale) => ( + + + insetScale: {scale} + + + {text('content', content)} + + + ))} + + + ))} +
+
+ ); +}; storiesOf('Components|Cards', module) .addDecorator(withKnobs) diff --git a/packages/components/card/src/card.tsx b/packages/components/card/src/card.tsx index cc7234917a..3b0188bc1f 100644 --- a/packages/components/card/src/card.tsx +++ b/packages/components/card/src/card.tsx @@ -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 = { /** @@ -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) => ( -
{ + 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; @@ -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 `
` 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' ? (
{props.children}
) : ( {props.children} - )} -
-); + ); + + 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 ( +
+ {content} + + ); + } else { + return ( + + {content} + + ); + } + } + } + + return ( +
) => { + if (isClickable && props.onClick && event.key === 'Enter') { + props.onClick(); + } + }} + > + {content} +
+ ); +}; const defaultProps: Pick = { type: 'raised', diff --git a/packages/components/card/src/card.visualroute.jsx b/packages/components/card/src/card.visualroute.jsx index 6a37be219f..2bd08a697c 100644 --- a/packages/components/card/src/card.visualroute.jsx +++ b/packages/components/card/src/card.visualroute.jsx @@ -100,6 +100,39 @@ export const component = () => ( + + + {text} + + + + + {text} + + + + + {text} + + + + + {text} + + +