diff --git a/src/components/Card/Card.spec.tsx b/src/components/Card/Card.spec.tsx index 58e4b502c0..40d792d6d8 100644 --- a/src/components/Card/Card.spec.tsx +++ b/src/components/Card/Card.spec.tsx @@ -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(); + 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 `` given an undefined `imgSrc`', () => { + render(); + expect(screen.queryAllByTestId('flowbite-card-image')).toHaveLength(0); + }); + + it('should render the image from the `renderImage` prop', () => { + render(
} />); + + 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 */ +
} + 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(() =>
); + render(); + expect(spy).toHaveBeenCalledWith(expect.any(Object), false); + }); + }); describe('A11y', () => { it('should allow `aria-label`', () => { render(); diff --git a/src/components/Card/Card.stories.tsx b/src/components/Card/Card.stories.tsx index 1dc097c133..d5a377bf6f 100644 --- a/src/components/Card/Card.stories.tsx +++ b/src/components/Card/Card.stories.tsx @@ -1,17 +1,12 @@ import type { Meta, Story } 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 => ( -
- -
- ), - ], + decorators: [(Story): JSX.Element =>
{Story()}
], } as Meta; const Template: Story = (args) => ( @@ -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: () => ( + Meaningful alt text for an image that is not purely decorative src} + width={1200} + height={800} + src={'https://flowbite.com/docs/images/blog/image-1.jpg'} + /> + ), +}; diff --git a/src/components/Card/Card.tsx b/src/components/Card/Card.tsx index 5270f19eb4..9332df9fad 100644 --- a/src/components/Card/Card.tsx +++ b/src/components/Card/Card.tsx @@ -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; @@ -21,26 +22,32 @@ export interface FlowbiteCardImageTheme { horizontal: FlowbiteBoolean; } -export interface CardProps extends PropsWithChildren> { +interface CommonCardProps extends PropsWithChildren> { horizontal?: boolean; href?: string; - imgAlt?: string; - imgSrc?: string; + /** Overwrites the theme. Will be merged with the context theme. + * @default {} + */ theme?: DeepPartial; } -export const Card: FC = ({ - 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, horizontal: boolean) => JSX.Element; + imgAlt?: never; + imgSrc?: never; + } + ) & + CommonCardProps; + +export const Card: FC = (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); @@ -56,16 +63,37 @@ export const Card: FC = ({ )} {...theirProps} > - {imgSrc && ( - {imgAlt - )} +
{children}
); }; -Card.displayName = 'Card'; +const Image: FC = ({ theme: customTheme = {}, ...props }) => { + const theme = mergeDeep(useTheme().theme.card, customTheme); + if (props.renderImage) { + return props.renderImage(theme, props.horizontal ?? false); + } + if (props.imgSrc) { + return ( + {props.imgAlt + ); + } + return null; +}; + +const removeCustomProps = omit([ + 'renderImage', + 'imgSrc', + 'imgAlt', + 'children', + 'className', + 'horizontal', + 'href', + 'theme', +]); diff --git a/src/helpers/omit.spec.ts b/src/helpers/omit.spec.ts new file mode 100644 index 0000000000..c31f3108ae --- /dev/null +++ b/src/helpers/omit.spec.ts @@ -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' }); + }); +}); diff --git a/src/helpers/omit.ts b/src/helpers/omit.ts new file mode 100644 index 0000000000..04b43f466d --- /dev/null +++ b/src/helpers/omit.ts @@ -0,0 +1,14 @@ +export const omit = + (keys: readonly K[]) => + (obj: T): Omit => { + const result = {} as Omit; + 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; + }; diff --git a/yarn.lock b/yarn.lock index 14b69a7fbf..474586bc19 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"