Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(card): support custom renderImage functions for Card #730

Merged
merged 1 commit into from
Jul 12, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions app/docs/components/card/card.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,35 @@ Add an image to the card by using the `imgSrc` prop and set the `imgAlt` prop to
</Card>
</CodePreview>

## Card with custom image render function

Specify your own render function for the image component for the card by using the `renderImage` prop. This is especially useful when
using the component with NextJS or Gatsby.

<CodePreview
importFlowbiteReact="Card"
importExternal="import Image from 'next/image';"
title="Card with decorative image"
className="max-w-sm"
code={`<Card renderImage={() => {<Image width={500} height={500} src="/images/blog/image-1.jpg" />}}>
<h5 className="text-2xl font-bold tracking-tight text-gray-900 dark:text-white">
Noteworthy technology acquisitions 2021
</h5>
<p className="font-normal text-gray-700 dark:text-gray-400">
Here are the biggest enterprise technology acquisitions of 2021 so far, in reverse chronological order.
</p>
</Card>`}
>
<Card renderImage={() => <Image width={500} height={500} src="/images/blog/image-1.jpg" />}>
rluders marked this conversation as resolved.
Show resolved Hide resolved
<h5 className="text-2xl font-bold tracking-tight text-gray-900 dark:text-white">
Noteworthy technology acquisitions 2021
</h5>
<p className="font-normal text-gray-700 dark:text-gray-400">
Here are the biggest enterprise technology acquisitions of 2021 so far, in reverse chronological order.
</p>
</Card>
</CodePreview>

## Horizontal card

Use the `horizontal` prop to show the card in a horizontal layout.
Expand Down
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 '../../';
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 { twMerge } from 'tailwind-merge';
import type { DeepPartial, FlowbiteBoolean } from '../../';
import { useTheme } from '../../';
rluders marked this conversation as resolved.
Show resolved Hide resolved
import { mergeDeep } from '../../helpers/merge-deep';
import { omit } from '../../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={twMerge(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={twMerge(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;
};
Loading
Loading