feat(image): initial structure

This commit is contained in:
Junior Garcia 2023-04-11 00:48:17 -03:00
parent cddb7b7fa7
commit e56ec07acc
13 changed files with 592 additions and 26 deletions

View File

@ -0,0 +1,24 @@
# @nextui-org/image
A Quick description of the component
> This is an internal utility, not intended for public usage.
## Installation
```sh
yarn add @nextui-org/image
# or
npm i @nextui-org/image
```
## Contribution
Yes please! See the
[contributing guidelines](https://github.com/nextui-org/nextui/blob/master/CONTRIBUTING.md)
for details.
## Licence
This project is licensed under the terms of the
[MIT license](https://github.com/nextui-org/nextui/blob/master/LICENSE).

View File

@ -0,0 +1,19 @@
import * as React from "react";
import {render} from "@testing-library/react";
import {Image} from "../src";
describe("Image", () => {
it("should render correctly", () => {
const wrapper = render(<Image />);
expect(() => wrapper.unmount()).not.toThrow();
});
it("ref should be forwarded", () => {
const ref = React.createRef<HTMLDivElement>();
render(<Image ref={ref} />);
expect(ref.current).not.toBeNull();
});
});

View File

@ -0,0 +1,59 @@
{
"name": "@nextui-org/image",
"version": "2.0.0-beta.1",
"description": "A simple image component",
"keywords": [
"image"
],
"author": "Junior Garcia <jrgarciadev@gmail.com>",
"homepage": "https://nextui.org",
"license": "MIT",
"main": "src/index.ts",
"sideEffects": false,
"files": [
"dist"
],
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/nextui-org/nextui.git",
"directory": "packages/components/image"
},
"bugs": {
"url": "https://github.com/nextui-org/nextui/issues"
},
"scripts": {
"build": "tsup src --dts",
"build:fast": "tsup src",
"dev": "yarn build:fast -- --watch",
"clean": "rimraf dist .turbo",
"typecheck": "tsc --noEmit",
"prepack": "clean-package",
"postpack": "clean-package restore"
},
"peerDependencies": {
"react": ">=18"
},
"dependencies": {
"@nextui-org/system": "workspace:*",
"@nextui-org/theme": "workspace:*",
"@nextui-org/shared-utils": "workspace:*",
"@nextui-org/dom-utils": "workspace:*",
"@nextui-org/use-image": "workspace:*"
},
"devDependencies": {
"clean-package": "2.2.0",
"react": "^18.0.0"
},
"clean-package": "../../../clean-package.config.json",
"tsup": {
"clean": true,
"target": "es2019",
"format": [
"cjs",
"esm"
]
}
}

View File

@ -0,0 +1,45 @@
import {forwardRef} from "@nextui-org/system";
import {cloneElement} from "react";
import {UseImageProps, useImage} from "./use-image";
export interface ImageProps extends Omit<UseImageProps, "ref" | "isLoading"> {}
const Image = forwardRef<ImageProps, "img">((props, ref) => {
const {
Component,
domRef,
isBlurred,
isZoomed,
showSkeleton,
getImgProps,
getWrapperProps,
getBlurredImgProps,
} = useImage({
ref,
...props,
});
const img = <Component ref={domRef} {...getImgProps()} />;
if (isBlurred) {
// clone element to add isBlurred prop to the cloned image
return (
<div {...getWrapperProps()}>
{img}
{cloneElement(img, getBlurredImgProps())}
</div>
);
}
// when zoomed or showSkeleton, we need to wrap the image
if (isZoomed || showSkeleton) {
return <div {...getWrapperProps()}>{img}</div>;
}
return img;
});
Image.displayName = "NextUI.Image";
export default Image;

View File

@ -0,0 +1,10 @@
import Image from "./image";
// export types
export type {ImageProps} from "./image";
// export hooks
export {useImage} from "./use-image";
// export component
export {Image};

View File

@ -0,0 +1,150 @@
import type {ImageVariantProps, SlotsToClasses, ImageSlots} from "@nextui-org/theme";
import {HTMLNextUIProps, mapPropsVariants, PropGetter} from "@nextui-org/system";
import {image} from "@nextui-org/theme";
import {useDOMRef} from "@nextui-org/dom-utils";
import {clsx, dataAttr, ReactRef} from "@nextui-org/shared-utils";
import {useImage as useImageBase} from "@nextui-org/use-image";
import {useMemo} from "react";
export interface Props extends HTMLNextUIProps<"img"> {
/**
* Ref to the DOM node.
*/
ref?: ReactRef<HTMLImageElement | null>;
/**
* Whether to add a blurred effect to the image.
* @default false
*/
isBlurred?: boolean;
/**
* If `true`, the fallback logic will be skipped.
* @default true
*/
ignoreFallback?: boolean;
/**
* A fallback image.
*/
fallbackSrc?: React.ReactNode;
/**
* Whether to disable the loading skeleton.
* @default false
*/
disableLoadingSkeleton?: boolean;
/**
* Function called when image failed to load
*/
onError?: () => void;
/**
* Classname or List of classes to change the styles of the element.
* if `className` is passed, it will be added to the base slot.
*
* @example
* ```ts
* <Image styles={{
* base:"base-classes", // wrapper
* img: "img-classes",
* blurredImg: "blurredImg-classes", // this is a cloned version of the img
* }} />
* ```
*/
styles?: SlotsToClasses<ImageSlots>;
}
export type UseImageProps = Props & ImageVariantProps;
export function useImage(originalProps: UseImageProps) {
const [props, variantProps] = mapPropsVariants(originalProps, image.variantKeys);
const {
ref,
as,
src: srcProp,
className,
styles,
isBlurred,
fallbackSrc,
ignoreFallback = true,
disableLoadingSkeleton = false,
onError,
...otherProps
} = props;
const imageStatus = useImageBase({src: srcProp, onError, ignoreFallback});
const isImgLoaded = imageStatus === "loaded";
const Component = as || "img";
const domRef = useDOMRef(ref);
/**
* Fallback image applies under 2 conditions:
* - If `src` was passed and the image has not loaded or failed to load
* - If `src` wasn't passed
*
* In this case, we'll show either the name avatar or default avatar
*/
const showFallback = (!srcProp || !isImgLoaded) && !ignoreFallback;
const showSkeleton = showFallback && !disableLoadingSkeleton;
const slots = useMemo(
() =>
image({
...variantProps,
isLoading: !isImgLoaded && showSkeleton,
}),
[...Object.values(variantProps), isImgLoaded, showSkeleton],
);
const src = useMemo(() => {
if (showFallback) {
return fallbackSrc;
}
return srcProp;
}, [srcProp, isImgLoaded, ignoreFallback]);
const baseStyles = clsx(className, styles?.base);
const getImgProps: PropGetter = () => {
return {
src,
ref: domRef,
"data-loaded": dataAttr(isImgLoaded),
className: slots.img({class: baseStyles}),
...otherProps,
};
};
const getWrapperProps: PropGetter = () => {
return {
className: slots.base({class: baseStyles}),
};
};
const getBlurredImgProps: PropGetter = () => {
return {
src,
"aria-hidden": dataAttr(true),
className: slots.blurredImg({class: styles?.blurredImg}),
};
};
return {
Component,
domRef,
slots,
styles,
isBlurred,
showSkeleton,
ignoreFallback,
disableLoadingSkeleton,
isZoomed: originalProps.isZoomed,
isLoading: originalProps.isLoading,
getImgProps,
getWrapperProps,
getBlurredImgProps,
};
}
export type UseImageReturn = ReturnType<typeof useImage>;

View File

@ -0,0 +1,84 @@
import React from "react";
import {ComponentStory, ComponentMeta} from "@storybook/react";
import {image} from "@nextui-org/theme";
import {Image, ImageProps} from "../src";
export default {
title: "Components/Image",
component: Image,
argTypes: {
radius: {
control: {
type: "select",
options: ["none", "base", "sm", "md", "lg", "xl", "2xl", "3xl", "full"],
},
},
isBlurred: {
control: {
type: "boolean",
},
},
isZoomed: {
control: {
type: "boolean",
},
},
},
decorators: [
(Story) => (
<div className="flex items-center justify-center w-screen h-screen">
<Story />
</div>
),
],
} as ComponentMeta<typeof Image>;
const defaultProps = {
...image.defaultVariants,
src: "https://nextui.org/images/hero-card.png",
alt: "NextUI hero image",
};
const Template: ComponentStory<typeof Image> = (args: ImageProps) => <Image {...args} />;
export const Default = Template.bind({});
Default.args = {
...defaultProps,
};
export const Blurred = Template.bind({});
Blurred.args = {
...defaultProps,
isBlurred: true,
};
export const Zoomed = Template.bind({});
Zoomed.args = {
...defaultProps,
width: 300,
isZoomed: true,
radius: "xl",
src: "https://nextui.org/images/card-example-2.jpeg",
};
export const Fallback = Template.bind({});
Fallback.args = {
...defaultProps,
width: 300,
radius: "xl",
ignoreFallback: false,
disableLoadingSkeleton: true,
src: "https://images.unsplash.com/photo-1539571696357-5a69c17a67c6",
fallbackSrc: "https://via.placeholder.com/300x450",
};
export const FallbackLoading = Template.bind({});
FallbackLoading.args = {
...defaultProps,
width: 300,
radius: "xl",
ignoreFallback: false,
src: "https://images.unsplash.com/broken",
fallbackSrc: "https://via.placeholder.com/300x450",
};

View File

@ -0,0 +1,10 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"tailwind-variants": ["../../../node_modules/tailwind-variants"]
}
},
"include": ["src", "index.ts"]
}

View File

@ -9,6 +9,11 @@ export const animations = {
"indeterminate-bar 1.5s cubic-bezier(0.65, 0.815, 0.735, 0.395) infinite normal none running",
},
keyframes: {
shimmer: {
"100%": {
transform: "translateX(100%)",
},
},
"spinner-spin": {
"0%": {
transform: "rotate(0deg)",

View File

@ -0,0 +1,134 @@
import type {VariantProps} from "tailwind-variants";
import {tv} from "tailwind-variants";
/**
* Image wrapper **Tailwind Variants** component
*
* const {base, img, blurredImg} = image({...})
*
* @example
* <div className={base()}>
* <img alt="image" className={img())} src="https://..." />
* // duplicate it for the blur effect
* <img alt="image" className={blurredImg())} src="https://..." />
* </div>
*/
const image = tv({
slots: {
base: "relative",
img: "",
blurredImg: [
"absolute",
"-z-10",
"inset-0",
"w-full",
"h-full",
"object-cover",
"filter",
"blur-lg",
"opacity-40",
"translate-x-1",
"translate-y-2",
],
},
variants: {
radius: {
none: {
base: "rounded-none",
img: "rounded-none",
blurredImg: "rounded-none",
},
base: {
base: "rounded",
img: "rounded",
blurredImg: "rounded",
},
sm: {
base: "rounded-sm",
img: "rounded-sm",
blurredImg: "rounded-sm",
},
md: {
base: "rounded-md",
img: "rounded-md",
blurredImg: "rounded-md",
},
lg: {
base: "rounded-lg",
img: "rounded-lg",
blurredImg: "rounded-lg",
},
xl: {
base: "rounded-xl",
img: "rounded-xl",
blurredImg: "rounded-xl",
},
"2xl": {
base: "rounded-2xl",
img: "rounded-2xl",
blurredImg: "rounded-2xl",
},
"3xl": {
base: "rounded-3xl",
img: "rounded-3xl",
blurredImg: "rounded-3xl",
},
full: {
base: "rounded-full",
img: "rounded-full",
blurredImg: "rounded-full",
},
},
isZoomed: {
true: {
base: "overflow-hidden",
img: [
"object-cover",
"transform",
"hover:scale-125",
"transition-transform",
"!duration-300",
"motion-reduce:transition-none",
],
},
},
isLoading: {
true: {
base: [
"space-y-5",
"overflow-hidden",
"bg-white/5",
"before:absolute",
"before:inset-0",
"before:-translate-x-full",
"before:animate-[shimmer_2s_infinite]",
"before:border-t",
"before:border-content4/70",
"before:bg-gradient-to-r",
"before:from-transparent",
"before:via-content4",
"dark:before:via-neutral-700/10",
"before:to-transparent",
//after
"after:absolute",
"after:inset-0",
"after:-z-10",
"after:bg-content3",
"dark:after:bg-content2",
],
img: "opacity-0",
},
},
},
defaultVariants: {
isZoomed: false,
isBlurred: false,
radius: "xl",
},
});
export type ImageVariantProps = VariantProps<typeof image>;
export type ImageSlots = keyof ReturnType<typeof image>;
export {image};

View File

@ -27,3 +27,4 @@ export * from "./dropdown";
export * from "./dropdown-item";
export * from "./dropdown-section";
export * from "./dropdown-menu";
export * from "./image";

View File

@ -90,7 +90,7 @@ export function useImage(props: UseImageProps = {}) {
img.onload = (event) => {
flush();
setStatus("loaded");
onLoad?.(event as unknown as ImageEvent);
onLoad?.((event as unknown) as ImageEvent);
};
img.onerror = (error) => {
flush();

75
pnpm-lock.yaml generated
View File

@ -893,6 +893,31 @@ importers:
specifier: ^18.2.0
version: 18.2.0
packages/components/image:
dependencies:
'@nextui-org/dom-utils':
specifier: workspace:*
version: link:../../utilities/dom-utils
'@nextui-org/shared-utils':
specifier: workspace:*
version: link:../../utilities/shared-utils
'@nextui-org/system':
specifier: workspace:*
version: link:../../core/system
'@nextui-org/theme':
specifier: workspace:*
version: link:../../core/theme
'@nextui-org/use-image':
specifier: workspace:*
version: link:../../hooks/use-image
devDependencies:
clean-package:
specifier: 2.2.0
version: 2.2.0
react:
specifier: ^18.2.0
version: 18.2.0
packages/components/input:
dependencies:
'@nextui-org/dom-utils':
@ -4599,7 +4624,7 @@ packages:
chalk: 4.1.2
convert-source-map: 1.9.0
fast-json-stable-stringify: 2.1.0
graceful-fs: 4.2.6
graceful-fs: 4.2.11
jest-haste-map: 26.6.2
jest-regex-util: 26.0.0
jest-util: 26.6.2
@ -10605,7 +10630,7 @@ packages:
chownr: 1.1.4
figgy-pudding: 3.5.2
glob: 7.2.3
graceful-fs: 4.2.6
graceful-fs: 4.2.11
infer-owner: 1.0.4
lru-cache: 5.1.1
mississippi: 3.0.0
@ -11515,7 +11540,7 @@ packages:
resolution: {integrity: sha512-0Cbj7gyvFVApzpK/uhCtQ/9kE9UnYpxMzaq5nQQC/Dh4iaj5fxp7iEFIullrYwzj8nf0qnsI1Qsx34hAeAebvw==}
engines: {node: '>=8'}
dependencies:
graceful-fs: 4.2.6
graceful-fs: 4.2.11
make-dir: 3.1.0
nested-error-stacks: 2.1.1
p-event: 4.2.0
@ -12035,7 +12060,7 @@ packages:
engines: {node: '>=10'}
dependencies:
globby: 11.1.0
graceful-fs: 4.2.6
graceful-fs: 4.2.11
is-glob: 4.0.3
is-path-cwd: 2.2.0
is-path-inside: 3.0.3
@ -12387,7 +12412,7 @@ packages:
resolution: {integrity: sha512-F/7vkyTtyc/llOIn8oWclcB25KdRaiPBpZYDgJHgh/UHtpgT2p2eldQgtQnLtUvfMKPKxbRaQM/hHkvLHt1Vng==}
engines: {node: '>=6.9.0'}
dependencies:
graceful-fs: 4.2.6
graceful-fs: 4.2.11
memory-fs: 0.4.1
tapable: 1.1.3
dev: true
@ -12396,7 +12421,7 @@ packages:
resolution: {integrity: sha512-Nv9m36S/vxpsI+Hc4/ZGRs0n9mXqSWGGq49zxb/cJfPAQMbUtttJAlNPS4AQzaBdw/pKskw5bMbekT/Y7W/Wlg==}
engines: {node: '>=6.9.0'}
dependencies:
graceful-fs: 4.2.6
graceful-fs: 4.2.11
memory-fs: 0.5.0
tapable: 1.1.3
dev: true
@ -12405,7 +12430,7 @@ packages:
resolution: {integrity: sha512-QHTXI/sZQmko1cbDoNAa3mJ5qhWUUNAq3vR0/YiD379fWQrcfuoX1+HW2S0MTt7XmoPLapdaDKUtelUSPic7hQ==}
engines: {node: '>=10.13.0'}
dependencies:
graceful-fs: 4.2.6
graceful-fs: 4.2.11
tapable: 2.2.1
dev: true
@ -14262,7 +14287,7 @@ packages:
resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==}
engines: {node: '>=12'}
dependencies:
graceful-fs: 4.2.6
graceful-fs: 4.2.11
jsonfile: 6.1.0
universalify: 2.0.0
dev: true
@ -14271,7 +14296,7 @@ packages:
resolution: {integrity: sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ==}
engines: {node: '>=14.14'}
dependencies:
graceful-fs: 4.2.6
graceful-fs: 4.2.11
jsonfile: 6.1.0
universalify: 2.0.0
dev: true
@ -14299,7 +14324,7 @@ packages:
engines: {node: '>=10'}
dependencies:
at-least-node: 1.0.0
graceful-fs: 4.2.6
graceful-fs: 4.2.11
jsonfile: 6.1.0
universalify: 2.0.0
dev: true
@ -14322,7 +14347,7 @@ packages:
/fs-write-stream-atomic@1.0.10:
resolution: {integrity: sha512-gehEzmPn2nAwr39eay+x3X34Ra+M2QlVUTLhkXPjWdeO8RF9kszk116avgBJM3ZyNHgHXBNx+VmPaFC36k0PzA==}
dependencies:
graceful-fs: 4.2.6
graceful-fs: 4.2.11
iferr: 0.1.5
imurmurhash: 0.1.4
readable-stream: 2.3.8
@ -16130,7 +16155,7 @@ packages:
'@types/node': 15.12.4
anymatch: 3.1.3
fb-watchman: 2.0.2
graceful-fs: 4.2.6
graceful-fs: 4.2.11
jest-regex-util: 26.0.0
jest-serializer: 26.6.2
jest-util: 26.6.2
@ -16333,7 +16358,7 @@ packages:
engines: {node: '>= 10.14.2'}
dependencies:
'@types/node': 15.12.4
graceful-fs: 4.2.6
graceful-fs: 4.2.11
dev: true
/jest-snapshot@28.1.3:
@ -16374,7 +16399,7 @@ packages:
'@jest/types': 26.6.2
'@types/node': 15.12.4
chalk: 4.1.2
graceful-fs: 4.2.6
graceful-fs: 4.2.11
is-ci: 2.0.0
micromatch: 4.0.5
dev: true
@ -16615,7 +16640,7 @@ packages:
/jsonfile@4.0.0:
resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==}
optionalDependencies:
graceful-fs: 4.2.6
graceful-fs: 4.2.11
dev: true
/jsonfile@6.1.0:
@ -16623,7 +16648,7 @@ packages:
dependencies:
universalify: 2.0.0
optionalDependencies:
graceful-fs: 4.2.6
graceful-fs: 4.2.11
dev: true
/jsonparse@1.3.1:
@ -16853,7 +16878,7 @@ packages:
resolution: {integrity: sha512-cy7ZdNRXdablkXYNI049pthVeXFurRyb9+hA/dZzerZ0pGTx42z+y+ssxBaVV2l70t1muq5IdKhn4UtcoGUY9A==}
engines: {node: '>=0.10.0'}
dependencies:
graceful-fs: 4.2.6
graceful-fs: 4.2.11
parse-json: 2.2.0
pify: 2.3.0
pinkie-promise: 2.0.1
@ -16865,7 +16890,7 @@ packages:
resolution: {integrity: sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==}
engines: {node: '>=4'}
dependencies:
graceful-fs: 4.2.6
graceful-fs: 4.2.11
parse-json: 4.0.0
pify: 3.0.0
strip-bom: 3.0.0
@ -16880,7 +16905,7 @@ packages:
resolution: {integrity: sha512-OfCBkGEw4nN6JLtgRidPX6QxjBQGQf72q3si2uvqyFEMbycSFFHwAZeXx6cJgFM9wmLrf9zBwCP3Ivqa+LLZPw==}
engines: {node: '>=6'}
dependencies:
graceful-fs: 4.2.6
graceful-fs: 4.2.11
js-yaml: 3.14.1
pify: 4.0.1
strip-bom: 3.0.0
@ -18730,7 +18755,7 @@ packages:
resolution: {integrity: sha512-S4eENJz1pkiQn9Znv33Q+deTOKmbl+jj1Fl+qiP/vYezj+S8x+J3Uo0ISrx/QoEvIlOaDWJhPaRd1flJ9HXZqg==}
engines: {node: '>=0.10.0'}
dependencies:
graceful-fs: 4.2.6
graceful-fs: 4.2.11
pify: 2.3.0
pinkie-promise: 2.0.1
dev: true
@ -20150,7 +20175,7 @@ packages:
resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==}
engines: {node: '>=6'}
dependencies:
graceful-fs: 4.2.6
graceful-fs: 4.2.11
js-yaml: 3.14.1
pify: 4.0.1
strip-bom: 3.0.0
@ -20181,7 +20206,7 @@ packages:
resolution: {integrity: sha512-1JU/8q+VgFZyxwrJ+SVIOsh+KywWGpds3NTqikiKpDMZWScmAYyKIgqkO+ARvNWJfXeXR1zxz7aHF4u4CyH6vQ==}
engines: {node: '>=0.10'}
dependencies:
graceful-fs: 4.2.6
graceful-fs: 4.2.11
micromatch: 3.1.10(supports-color@6.1.0)
readable-stream: 2.3.8
transitivePeerDependencies:
@ -23009,7 +23034,7 @@ packages:
/watchpack@1.7.5:
resolution: {integrity: sha512-9P3MWk6SrKjHsGkLT2KHXdQ/9SNkyoJbabxnKOoJepsvJjJG8uYTR3yTPxPQvNDI3w4Nz1xnE0TLHK4RIVe/MQ==}
dependencies:
graceful-fs: 4.2.6
graceful-fs: 4.2.11
neo-async: 2.6.2
optionalDependencies:
chokidar: 3.5.3
@ -23023,7 +23048,7 @@ packages:
engines: {node: '>=10.13.0'}
dependencies:
glob-to-regexp: 0.4.1
graceful-fs: 4.2.6
graceful-fs: 4.2.11
dev: true
/wcwidth@1.0.1:
@ -23243,7 +23268,7 @@ packages:
eslint-scope: 5.1.1
events: 3.3.0
glob-to-regexp: 0.4.1
graceful-fs: 4.2.6
graceful-fs: 4.2.11
json-parse-better-errors: 1.0.2
loader-runner: 4.3.0
mime-types: 2.1.35