Skip to content

Commit

Permalink
feat: support custom renderImage functions for Card
Browse files Browse the repository at this point in the history
Since in some cases, for example next.js apps, one needs to use a specific
and optimized `Image` component, the `Card` component now accepts a
`renderImage` property to render your own component. If `renderImage` is set,
`imgSrc` and `imgAlt` are ignored. TypeScript will error if the user attempts
to set them at the same time.

fix #706
  • Loading branch information
levino committed May 26, 2023
1 parent 6e2b091 commit 408aaf3
Show file tree
Hide file tree
Showing 6 changed files with 135 additions and 33 deletions.
38 changes: 37 additions & 1 deletion src/components/Card/Card.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,45 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { Flowbite } from '~/src';
import { Card } from './Card';

describe('Components / Card', () => {
describe('Functionality', () => {
it('should render an image when `imgSrc` is provided', () => {
render(<Card imgSrc="https://flowbite.com/docs/images/blog/image-1.jpg" />);
expect(screen.queryAllByTestId('flowbite-card-image')).toHaveLength(1);
expect(screen.queryByTestId('flowbite-card-image')).toHaveAttribute(
'src',
'https://flowbite.com/docs/images/blog/image-1.jpg',
);
});
it('should not render an `<img>` given an undefined `imgSrc`', () => {
render(<Card imgSrc={undefined} />);
expect(screen.queryAllByTestId('flowbite-card-image')).toHaveLength(0);
});

it('should render the image from the `renderImage` prop', () => {
render(<Card renderImage={() => <div data-testid="dummy-div" />} />);

expect(screen.queryAllByTestId('dummy-div')).toHaveLength(1);
});
it('should use the `renderImage` prop even if the user provides an `imgSrc`', () => {
render(
/* @ts-expect-error should be illegal to use `renderImage` and `imgSrc` at the same time */
<Card
renderImage={() => <div data-testid="dummy-div2" />}
imgSrc="https://flowbite.com/docs/images/blog/image-1.jpg"
/>,
);
expect(screen.queryAllByTestId('dummy-div2')).toHaveLength(1);
expect(screen.queryAllByTestId('flowbite-card-image')).toHaveLength(0);
});
it('should provide the theme and horizontal flag to the `renderImage` function', () => {
const spy = vi.fn(() => <div data-testid="dummy-div2" />);
render(<Card renderImage={spy} />);
expect(spy).toHaveBeenCalledWith(expect.any(Object), false);
});
});
describe('A11y', () => {
it('should allow `aria-label`', () => {
render(<Card aria-label="My card" />);
Expand Down
28 changes: 19 additions & 9 deletions src/components/Card/Card.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,15 @@
import type { Meta, Story } from '@storybook/react';
import type { Meta, StoryFn } from '@storybook/react';
import Image from 'next/image';
import type { CardProps } from './Card';
import { Card } from './Card';

export default {
title: 'Components/Card',
component: Card,
decorators: [
(Story): JSX.Element => (
<div className="h-1/2 w-1/2">
<Story />
</div>
),
],
decorators: [(Story): JSX.Element => <div className="h-1/2 w-1/2">{Story()}</div>],
} as Meta;

const Template: Story<CardProps> = (args) => (
const Template: StoryFn<CardProps> = (args: CardProps) => (
<Card {...args}>
<h5 className="text-2xl font-bold tracking-tight text-gray-900 dark:text-white">
Noteworthy technology acquisitions 2021
Expand Down Expand Up @@ -45,3 +40,18 @@ WithDecorativeImage.storyName = 'With decorative image';
WithDecorativeImage.args = {
imgSrc: 'https://flowbite.com/docs/images/blog/image-1.jpg',
};

export const WithNextImage = Template.bind({});
WithNextImage.storyName = 'With Next.js Image component';
WithNextImage.args = {
renderImage: () => (
<Image
alt="Meaningful alt text for an image that is not purely decorative"
// a loader is necessary to make `Image` render a React Node
loader={({ src }) => src}
width={1200}
height={800}
src={'https://flowbite.com/docs/images/blog/image-1.jpg'}
/>
),
};
73 changes: 51 additions & 22 deletions src/components/Card/Card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { ComponentProps, FC, PropsWithChildren } from 'react';
import type { DeepPartial, FlowbiteBoolean } from '~/src';
import { useTheme } from '~/src';
import { mergeDeep } from '~/src/helpers/merge-deep';
import { omit } from '~/src/helpers/omit';

export interface FlowbiteCardTheme {
root: FlowbiteCardRootTheme;
Expand All @@ -21,26 +22,32 @@ export interface FlowbiteCardImageTheme {
horizontal: FlowbiteBoolean;
}

export interface CardProps extends PropsWithChildren<ComponentProps<'div'>> {
interface CommonCardProps extends PropsWithChildren<ComponentProps<'div'>> {
horizontal?: boolean;
href?: string;
imgAlt?: string;
imgSrc?: string;
/** Overwrites the theme. Will be merged with the context theme.
* @default {}
*/
theme?: DeepPartial<FlowbiteCardTheme>;
}

export const Card: FC<CardProps> = ({
children,
className,
horizontal,
href,
imgAlt,
imgSrc,
theme: customTheme = {},
...props
}) => {
export type CardProps =
| (
| { imgAlt?: string; imgSrc?: string; renderImage?: never }
| {
/** Allows to provide a custom render function for the image component. Useful in Next.JS and Gatsby. **Setting this will disable `imgSrc` and `imgAlt`**.
*/
renderImage?: (theme: DeepPartial<FlowbiteCardTheme>, horizontal: boolean) => JSX.Element;
imgAlt?: never;
imgSrc?: never;
}
) &
CommonCardProps;

export const Card: FC<CardProps> = (props) => {
const { children, className, horizontal, href, theme: customTheme = {} } = props;
const Component = typeof href === 'undefined' ? 'div' : 'a';
const theirProps = props as object;
const theirProps = removeCustomProps(props);

const theme = mergeDeep(useTheme().theme.card, customTheme);

Expand All @@ -56,16 +63,38 @@ export const Card: FC<CardProps> = ({
)}
{...theirProps}
>
{imgSrc && (
<img
alt={imgAlt ?? ''}
src={imgSrc}
className={classNames(theme.img.base, theme.img.horizontal[horizontal ? 'on' : 'off'])}
/>
)}
{/* eslint-disable-next-line jsx-a11y/alt-text -- jsx-ally/alt-text gives a false positive here. Since we use our own Image component, we cannot provide an "alt" prop.*/}
<Image {...props} />
<div className={theme.root.children}>{children}</div>
</Component>
);
};

Card.displayName = 'Card';
const Image: FC<CardProps> = ({ theme: customTheme = {}, ...props }) => {
const theme = mergeDeep(useTheme().theme.card, customTheme);
if (props.renderImage) {
return props.renderImage(theme, props.horizontal ?? false);
}
if (props.imgSrc) {
return (
<img
data-testid="flowbite-card-image"
alt={props.imgAlt ?? ''}
src={props.imgSrc}
className={classNames(theme.img.base, theme.img.horizontal[props.horizontal ? 'on' : 'off'])}
/>
);
}
return null;
};

const removeCustomProps = omit([
'renderImage',
'imgSrc',
'imgAlt',
'children',
'className',
'horizontal',
'href',
'theme',
]);
8 changes: 8 additions & 0 deletions src/helpers/omit.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { describe, expect, it } from 'vitest';
import { omit } from './omit';

describe('omit', () => {
it('should omit keys from object', () => {
expect(omit(['a', 'b'])({ a: 'a', b: 'b', c: 'c' })).toEqual({ c: 'c' });
});
});
14 changes: 14 additions & 0 deletions src/helpers/omit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export const omit =
<T extends object, K extends string>(keys: readonly K[]) =>
(obj: T): Omit<T, K> => {
const result = {} as Omit<T, K>;
Object.keys(obj).forEach((key) => {
//@ts-expect-error - Somehow TS does not like this.
if (keys.includes(key)) {
return;
}
//@ts-expect-error - Somehow TS does not like this.
result[key] = obj[key];
});
return result;
};
7 changes: 6 additions & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -12493,11 +12493,16 @@ tslib@^1.8.1:
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==

tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.4.0, tslib@^2.5.0:
tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.5.0:
version "2.5.2"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.5.2.tgz#1b6f07185c881557b0ffa84b111a0106989e8338"
integrity sha512-5svOrSA2w3iGFDs1HibEVBGbDrAY82bFQ3HZ3ixB+88nsbsWQoKqDRb5UBYAUPEzbBn6dAp5gRNXglySbx1MlA==

tslib@^2.4.0:
version "2.5.0"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.5.0.tgz#42bfed86f5787aeb41d031866c8f402429e0fddf"
integrity sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==

tsutils@^3.21.0:
version "3.21.0"
resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623"
Expand Down

0 comments on commit 408aaf3

Please sign in to comment.