├── .editorconfig ├── .gitignore ├── .prettierrc ├── .travis.yml ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── demo └── src │ └── index.js ├── nwb.config.js ├── package-lock.json ├── package.json ├── src ├── Image.js └── index.js └── tests ├── .eslintrc └── index.test.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_size = 2 8 | indent_style = tab 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | # editorconfig-tools is unable to ignore longs strings or urls 12 | max_line_length = off 13 | 14 | [*.md] 15 | max_line_length = 0 16 | trim_trailing_whitespace = false 17 | 18 | [COMMIT_EDITMSG] 19 | max_line_length = 0 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /coverage 2 | /demo/dist 3 | /es 4 | /lib 5 | /node_modules 6 | /umd 7 | npm-debug.log* 8 | .DS_Store 9 | .vscode 10 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/prettierrc", 3 | "printWidth": 80, 4 | "arrowParens": "always", 5 | "bracketSpacing": true, 6 | "jsxBracketSameLine": false, 7 | "jsxSingleQuote": false, 8 | "singleQuote": true, 9 | "semi": true, 10 | "trailingComma": "es5", 11 | "useEditorConfig": true 12 | } 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | 3 | language: node_js 4 | node_js: 5 | - 10 6 | 7 | before_install: 8 | - npm install codecov.io coveralls 9 | 10 | after_success: 11 | - cat ./coverage/lcov.info | ./node_modules/codecov.io/bin/codecov.io.js 12 | - cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js 13 | 14 | branches: 15 | only: 16 | - master 17 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Prerequisites 2 | 3 | [Node.js](http://nodejs.org/) >= 10 must be installed. 4 | 5 | ## Installation 6 | 7 | - Running `npm install` in the component's root directory will install everything you need for development. 8 | 9 | ## Demo Development Server 10 | 11 | - `npm start` will run a development server with the component's demo app at [http://localhost:3000](http://localhost:3000) with hot module reloading. 12 | 13 | ## Running Tests 14 | 15 | - `npm test` will run the tests once. 16 | 17 | - `npm run test:coverage` will run the tests and produce a coverage report in `coverage/`. 18 | 19 | - `npm run test:watch` will run the tests on every change. 20 | 21 | ## Building 22 | 23 | - `npm run build` will build the component for publishing to npm and also bundle the demo app. 24 | 25 | - `npm run clean` will delete built resources. 26 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2022 [benmneb](https://github.com/benmneb/) 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 10 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 11 | AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 12 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 13 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 14 | OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 15 | PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

🌅
mui-image

3 |
4 |

5 | The only Material UI image component to satisfy the Material Design guidelines for loading images. 6 |

7 |

8 | 9 | 10 |

11 |

12 | Demo Playground ↗️
13 |

14 | 15 | ### If you're already using [Material UI v5](https://material-ui.com), why not display your images according to the Material guidelines too? 16 | 17 | > Illustrations and photographs may load and transition in three phases at staggered durations, rather than relying on opacity changes alone. 18 | > 19 | > Visualize the image fading in, like a print during the photo development process. 20 | > 21 | > \- [Material guidelines](https://material.io/archive/guidelines/patterns/loading-images.html#loading-images-usage) 22 | 23 | ### 1. Install 24 | 25 | ``` 26 | npm i mui-image 27 | ``` 28 | 29 | or 30 | 31 | ``` 32 | yarn add mui-image 33 | ``` 34 | 35 | Using TypeScript? Also add [`@types/mui-image`](https://www.npmjs.com/package/@types/mui-image) 🥳 36 | 37 | ### 2. Use 38 | 39 | ``` 40 | import Image from 'mui-image' 41 | 42 | // or 43 | 44 | import { Image } from 'mui-image' 45 | 46 | // then 47 | 48 | 49 | ``` 50 | 51 | ### 3. Profit 💰 52 | 53 | _Note: Profits not guaranteed and Material UI v5 is a peer dependency. If you need to support legacy versions of Material UI, use [`material-ui-image`](https://github.com/TeamWertarbyte/material-ui-image) instead. See the [comparison chart](#comparison-with-similar-components) below for more._ 54 | 55 | ## Usage Examples 56 | 57 | You can use `mui-image` like a regular image. 58 | 59 | ``` 60 | 61 | ``` 62 | 63 | Except... it will fade and animate in as the Material guidelines recommend. 🤯 64 | 65 | Add a `height` and/or `width` to reserve space on the page for the image and avoid uncomforable content shifts as your picture loads. They both default to 100% of the parent you place them in and accept any valid CSS property. Numbers are converted to pixels. 66 | 67 | ``` 68 | 69 | 70 | ``` 71 | 72 | Apply the `showLoading` prop to add a progress indicator to let your fans know something amazing is coming. You can use the default Material UI indicator or bring your own. 😎 73 | 74 | ``` 75 | 76 | } /> 77 | ``` 78 | 79 | If you want the image to fail silently you can disable the `errorIcon`, or you can add your own to suit your brand. 80 | 81 | ``` 82 | 83 | } /> 84 | ``` 85 | 86 | If you want to _disobey Google_ 😵 then you can customise the animation and speed via the `duration` and `easing` props to any valid CSS property. Duration is always milliseconds. 87 | 88 | ``` 89 | 90 | 91 | ``` 92 | 93 | To add that extra bit of spice 🌶 you can do exactly what Google suggests and apply a small position `shift` to images as they appear. The direction, distance, and duration (in milliseconds) are up to you. 94 | 95 | ``` 96 | 97 | 98 | 99 | ``` 100 | 101 | And of course, you can style `mui-image` like you would a regular image... but with the addition of the Material UI v5 `sx` prop and [all the benefits](https://mui.com/system/the-sx-prop/) it brings. 😏 102 | 103 | ``` 104 | 105 | 106 | 107 | ``` 108 | 109 | If you want to get fancy 💃 you can also add inline styles and additional `className`'s to the root wrapper `div` and loading/error icon wrapper `div`, or just target their default `className`'s. This allows for complete customisation of every aspect of the component. 110 | 111 | Like and subscribe below for more. ⏬ 112 | 113 | ## Props 114 | 115 | | Name | Type | Default | Description | 116 | | -------------------- | ---------------- | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------- | 117 | | alt | string | "" | image `alt` tag value | 118 | | bgColor | string | "inherit" | the color the image transitions in from | 119 | | className | string | "mui-image-img" | CSS `class` for the image | 120 | | distance | string / number | 100 | any valid [CSS `length` value](https://developer.mozilla.org/en-US/docs/Web/CSS/length#units) (for the shift) | 121 | | duration | number | 3000 | sets the CSS [`transition-duration`](https://developer.mozilla.org/en-US/docs/Web/CSS/transition-duration) in milliseconds | 122 | | easing | string | cubic-bezier(0.7, 0, 0.6, 1) | sets the CSS [`transition-timing-function`](https://developer.mozilla.org/en-US/docs/Web/CSS/transition-timing-function) | 123 | | errorIcon | boolean / node | true | display default error icon, or your own | 124 | | fit | string | "contain" | any valid [CSS `object-fit` value](https://developer.mozilla.org/en-US/docs/Web/CSS/object-fit#syntax) | 125 | | height | number / string | "100%" | any valid [CSS `height` value](https://developer.mozilla.org/en-US/docs/Web/CSS/height) | 126 | | iconWrapperClassName | string | "mui-image-iconWrapper" | CSS `class` for the icon wrapper `div` | 127 | | iconWrapperStyle | object | | inline styles for the icon wrapper `div` | 128 | | position | string | "relative" | any valid [CSS `position` value](https://developer.mozilla.org/en-US/docs/Web/CSS/position) | 129 | | shift | boolean / string | false | either "left", "right", "top", "bottom", `null`, or `false` | 130 | | shiftDuration | number | duration \* 0.3 | duration of shift in milliseconds | 131 | | showLoading | boolean / node | false | display default loading spinner, or your own | 132 | | **_src_** \* | string | | image `src` tag... _required_ | 133 | | style | object | | inline styles for the image | 134 | | width | number / string | "100%" | any valid [CSS `width` value](https://developer.mozilla.org/en-US/docs/Web/CSS/width) | 135 | | wrapperClassName | string | "mui-image-wrapper" | CSS `class` for the root wrapper `div` | 136 | | wrapperStyle | object | | inline styles for the root wrapper `div` | 137 | 138 | \* required prop 139 | 140 | Any other props (eg. `sx`, `onLoad`) are passed directly to the native `img` element. 141 | 142 | ## Material guidelines for loading images 143 | 144 | > #### ✅ Fade-in 145 | > 146 | > Visualize the image fading in, like a print during the photo development process. 147 | 148 | > #### ✅ Opacity, exposure, and saturation recommendations 149 | > 150 | > Images should begin loading with low contrast levels and desaturated color. Once image opacity reaches 100%, display the image with full-color saturation. 151 | 152 | > #### ✅ Duration 153 | > 154 | > A longer duration is recommended for loading images, and a shorter duration is recommended for transitions. 155 | 156 | > #### ✅ Animation 157 | > 158 | > Add a small position shift to loading images. 159 | 160 | [(Source)](https://material.io/archive/guidelines/patterns/loading-images.html#loading-images-behavior) 161 | 162 | ## Comparison with similar components 163 | 164 | | Feature | `mui-image` | `material-ui-image` | 165 | | ----------------------------- | :------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------: | 166 | | Size (minzipped) | ![npm bundle size](https://img.shields.io/bundlephobia/minzip/mui-image?color=%2343a047&label=%20&style=flat-square) | ![npm bundle size](https://img.shields.io/bundlephobia/minzip/material-ui-image?color=%23b71c1c&label=%20&style=flat-square) | 167 | | Supports MUI v5 | ✅ | ❌ | 168 | | Fade-in | ✅ | ✅ | 169 | | Progressive level adjustments | ✅ | ❌ | 170 | | Suggested duration | ✅ | ✅ | 171 | | Optional shift animation | ✅ | ❌ | 172 | | Supports legacy MUI versions | ❌ | ✅ | 173 | 174 | ## License 175 | 176 | © [benmneb](https://github.com/benmneb) 177 | 178 | [ISC License](https://choosealicense.com/licenses/isc/) 179 | 180 | Permission to use, copy, modify, and/or distribute this software for any 181 | purpose with or without fee is hereby granted, provided that the above 182 | copyright notice and this permission notice appear in all copies. 183 | 184 | 191 | -------------------------------------------------------------------------------- /demo/src/index.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { render } from 'react-dom'; 4 | 5 | import TypeIt from 'typeit-react'; 6 | 7 | import Image from '../../src'; 8 | 9 | import { 10 | styled, 11 | AppBar, 12 | Box, 13 | Button, 14 | CssBaseline, 15 | Drawer, 16 | FormControlLabel, 17 | IconButton, 18 | MenuItem, 19 | Select, 20 | Stack, 21 | Switch, 22 | TextField, 23 | Tooltip, 24 | Typography, 25 | Toolbar, 26 | useMediaQuery, 27 | } from '@mui/material'; 28 | import { createTheme, ThemeProvider } from '@mui/material/styles'; 29 | import { createSvgIcon } from '@mui/material/utils'; 30 | 31 | import '@fontsource/fira-code'; 32 | 33 | const theme = createTheme({ 34 | typography: { 35 | fontFamily: ['Fira Code', 'monospace'].join(','), 36 | }, 37 | palette: { 38 | primary: { main: '#2979ff' }, 39 | }, 40 | }); 41 | 42 | const YarnIcon = createSvgIcon( 43 | , 44 | 'YarnIcon' 45 | ); 46 | 47 | const NpmIcon = createSvgIcon( 48 | , 49 | 'NpmIcon' 50 | ); 51 | 52 | const GitHubIcon = createSvgIcon( 53 | , 54 | 'GitHubIcon' 55 | ); 56 | 57 | const CodeIcon = createSvgIcon( 58 | , 59 | 'CodeIcon' 60 | ); 61 | 62 | const CodeOffIcon = createSvgIcon( 63 | , 64 | 'CodeOffIcon' 65 | ); 66 | 67 | const Line = styled(Box)({ 68 | display: 'flex', 69 | alignItems: 'center', 70 | '& .MuiTextField-root': { 71 | margin: '0 8px', 72 | }, 73 | }); 74 | 75 | const ImageOutput = styled('article')({ 76 | display: 'flex', 77 | justifyContent: 'center', 78 | alignItems: 'center', 79 | height: '100%', 80 | overflow: 'hidden', 81 | }); 82 | 83 | const DRAWER_WIDTH = 325; 84 | const DEFAULT_IMAGE = 674; 85 | 86 | const SHOW_LOADING = false; 87 | const ERROR_ICON = true; 88 | const HEIGHT = '100%'; 89 | const WIDTH = '100%'; 90 | const SHIFT = false; 91 | const DISTANCE = '100px'; 92 | const SHIFT_DURATION = null; 93 | const DURATION = 3000; 94 | const EASING = 'cubic-bezier(0.7, 0, 0.6, 1)'; 95 | const FIT = 'cover'; 96 | const BG_COLOR = 'inherit'; 97 | 98 | export default function Demo() { 99 | const [currentPhoto, setCurrentPhoto] = React.useState(DEFAULT_IMAGE); 100 | const [showPhoto, setShowPhoto] = React.useState(true); 101 | 102 | const [showLoading, setShowLoading] = React.useState(SHOW_LOADING); 103 | const [errorIcon, setErrorIcon] = React.useState(ERROR_ICON); 104 | const [height, setHeight] = React.useState(HEIGHT); 105 | const [width, setWidth] = React.useState(WIDTH); 106 | const [shift, setShift] = React.useState(SHIFT); 107 | const [distance, setDistance] = React.useState(DISTANCE); 108 | const [shiftDuration, setShiftDuration] = React.useState(SHIFT_DURATION); 109 | const [duration, setDuration] = React.useState(DURATION); 110 | const [easing, setEasing] = React.useState(EASING); 111 | const [fit, setFit] = React.useState(FIT); 112 | const [bgColor, setBgColor] = React.useState(BG_COLOR); 113 | 114 | function getNewPhoto() { 115 | if (mobileOpen) setMobileOpen(false); 116 | const newPhoto = Math.floor(Math.random() * 1051); 117 | setShowPhoto(false); 118 | setCurrentPhoto(newPhoto); 119 | setTimeout(() => { 120 | setShowPhoto(true); 121 | }, 100); 122 | } 123 | 124 | function refreshPhoto() { 125 | if (mobileOpen) setMobileOpen(false); 126 | setShowPhoto(false); 127 | setTimeout(() => { 128 | setShowPhoto(true); 129 | }, 100); 130 | } 131 | 132 | function resetDefaults() { 133 | setShowLoading(SHOW_LOADING); 134 | setErrorIcon(ERROR_ICON); 135 | setHeight(HEIGHT); 136 | setWidth(WIDTH); 137 | setShift(SHIFT); 138 | setDistance(DISTANCE); 139 | setShiftDuration(SHIFT_DURATION); 140 | setDuration(DURATION); 141 | setEasing(EASING); 142 | setFit(FIT); 143 | setBgColor(BG_COLOR); 144 | } 145 | 146 | const [mobileOpen, setMobileOpen] = React.useState(false); 147 | 148 | const mobile = useMediaQuery('@media (max-width: 900px)'); 149 | 150 | function handleDrawerToggle() { 151 | setMobileOpen(!mobileOpen); 152 | } 153 | 154 | return ( 155 | 156 | 157 | theme.zIndex.drawer + 1 }} 161 | > 162 | 163 | 170 | {mobileOpen ? : } 171 | 172 | 178 | { 180 | instance 181 | .pause(3500) 182 | .type('npm install mui-image') 183 | .pause(1500) 184 | .delete() 185 | .type("import Image from 'mui-image'"); 186 | 187 | return instance; 188 | }} 189 | options={{ speed: 120, cursor: false }} 190 | /> 191 | 192 | 198 | mui-image 199 | 200 | 201 | 203 | window.open('https://yarnpkg.com/package/mui-image') 204 | } 205 | color="inherit" 206 | > 207 | 212 | 213 | window.open('https://npmjs.com/package/mui-image')} 215 | color="inherit" 216 | > 217 | 218 | 219 | 221 | window.open('https://github.com/benmneb/mui-image') 222 | } 223 | color="inherit" 224 | > 225 | 226 | 227 | 228 | 229 | 230 | 248 | 249 | 255 | 256 | {' 258 | 259 | 260 | src="https://picsum.photos/id/{currentPhoto}/2000" 261 | 262 | 263 | 264 | height=" 265 | setHeight(e.target.value)} 269 | /> 270 | " 271 | 272 | 273 | 274 | 275 | width=" 276 | setWidth(e.target.value)} 280 | /> 281 | " 282 | 283 | 284 | 288 | 289 | fit= 290 | 302 | 303 | 304 | 308 | 309 | duration={'{'} 310 | setDuration(e.target.value)} 314 | /> 315 | {'}'} 316 | 317 | 318 | 322 | 323 | easing= 324 | 339 | 340 | 341 | 345 | 346 | showLoading= 347 | setShowLoading(e.target.checked)} 353 | /> 354 | } 355 | label={`{ ${showLoading} }`} 356 | labelPlacement="start" 357 | /> 358 | 359 | 360 | 364 | 365 | errorIcon= 366 | setErrorIcon(e.target.checked)} 372 | /> 373 | } 374 | label={`{ ${errorIcon} }`} 375 | labelPlacement="start" 376 | /> 377 | 378 | 379 | 383 | 384 | shift= 385 | 397 | 398 | 399 | 403 | 404 | distance=" 405 | setDistance(e.target.value)} 409 | /> 410 | " 411 | 412 | 413 | 417 | 418 | shiftDuration={'{'} 419 | setShiftDuration(e.target.value)} 423 | /> 424 | {'}'} 425 | 426 | 427 | 431 | 432 | bgColor=" 433 | setBgColor(e.target.value)} 437 | /> 438 | " 439 | 440 | 441 | 442 | 443 | {'/>'} 444 | 445 | 453 | 460 | 461 | 462 | 463 | 464 | 465 | 470 | {showPhoto && ( 471 | 485 | )} 486 | 487 | 488 | 489 | ); 490 | } 491 | 492 | render( 493 | 494 | 495 | 496 | , 497 | document.querySelector('#demo') 498 | ); 499 | -------------------------------------------------------------------------------- /nwb.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | type: 'react-component', 3 | npm: { 4 | esModules: true, 5 | umd: { 6 | global: 'MuiImage', 7 | externals: { 8 | react: 'React', 9 | }, 10 | }, 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mui-image", 3 | "version": "1.0.7", 4 | "description": "Display images as per the Material guidelines. For React apps using Material-UI v5.", 5 | "main": "lib/index.js", 6 | "module": "es/index.js", 7 | "files": [ 8 | "css", 9 | "es", 10 | "lib", 11 | "umd" 12 | ], 13 | "scripts": { 14 | "build": "nwb build-react-component", 15 | "clean": "nwb clean-module && nwb clean-demo", 16 | "prepublishOnly": "npm run build", 17 | "start": "nwb serve-react-demo", 18 | "test": "nwb test-react", 19 | "test:coverage": "nwb test-react --coverage", 20 | "test:watch": "nwb test-react --server", 21 | "size": "size-limit" 22 | }, 23 | "peerDependencies": { 24 | "@emotion/react": "^11.4.1", 25 | "@emotion/styled": "^11.3.0", 26 | "@mui/material": "^5.0.1", 27 | "prop-types": "^15.7.2", 28 | "react": "^17.0.2 || ^18.0.0" 29 | }, 30 | "devDependencies": { 31 | "@fontsource/fira-code": "^4.5.1", 32 | "@size-limit/preset-small-lib": "^7.0.8", 33 | "nwb": "0.25.2", 34 | "react": "^18.2.0", 35 | "react-dom": "^18.2.0", 36 | "size-limit": "^7.0.8", 37 | "typeit-react": "^2.0.3" 38 | }, 39 | "size-limit": [ 40 | { 41 | "path": "es/index.js" 42 | } 43 | ], 44 | "author": "benmneb", 45 | "homepage": "https://mui-image.surge.sh/", 46 | "license": "ISC", 47 | "repository": "https://github.com/benmneb/mui-image", 48 | "keywords": [ 49 | "material-ui", 50 | "material-ui image", 51 | "material-ui-image", 52 | "material design", 53 | "react", 54 | "loading image" 55 | ] 56 | } 57 | -------------------------------------------------------------------------------- /src/Image.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import PropTypes from 'prop-types'; 4 | 5 | import { styled } from '@mui/material/styles'; 6 | import { createSvgIcon } from '@mui/material/utils'; 7 | import CircularProgress from '@mui/material/CircularProgress'; 8 | 9 | const BrokenImageIcon = createSvgIcon( 10 | , 11 | 'BrokenImageIcon' 12 | ); 13 | 14 | const Img = styled('img')({ 15 | '@keyframes materialize': { 16 | '0%': { 17 | filter: 'saturate(20%) contrast(50%) brightness(120%)', 18 | }, 19 | '75%': { 20 | filter: 'saturate(60%) contrast(100%) brightness(100%)', 21 | }, 22 | '100%': { 23 | filter: 'saturate(100%) contrast(100%) brightness(100%)', 24 | }, 25 | }, 26 | }); 27 | 28 | export default function Image({ 29 | src, 30 | style, 31 | wrapperStyle, 32 | iconWrapperStyle, 33 | onLoad: onLoadProp, 34 | onError: onErrorProp, 35 | alt = '', 36 | height = '100%', 37 | width = '100%', 38 | position = 'relative', 39 | fit = 'cover', 40 | showLoading = false, 41 | errorIcon = true, 42 | shift = false, 43 | distance = 100, 44 | shiftDuration = null, 45 | bgColor = 'inherit', 46 | duration = 3000, 47 | easing = 'cubic-bezier(0.7, 0, 0.6, 1)', // "heavy move" from https://sprawledoctopus.com/easing/ 48 | className = '', 49 | wrapperClassName = '', 50 | iconWrapperClassName = '', 51 | ...rest 52 | }) { 53 | const [loaded, setLoaded] = React.useState(false); 54 | const [error, setError] = React.useState(false); 55 | 56 | function handleLoad() { 57 | setLoaded(true); 58 | setError(false); 59 | if (Boolean(onLoadProp)) onLoadProp(); 60 | } 61 | 62 | function handleError() { 63 | setError(true); 64 | setLoaded(false); 65 | if (Boolean(onErrorProp)) onErrorProp(); 66 | } 67 | 68 | const shiftStyles = { 69 | [shift]: loaded ? 0 : distance, 70 | }; 71 | 72 | const styles = { 73 | root: { 74 | width, 75 | height, 76 | display: 'flex', 77 | justifyContent: 'center', 78 | alignItems: 'center', 79 | backgroundColor: bgColor, 80 | ...wrapperStyle, 81 | }, 82 | image: { 83 | position, 84 | width: '100%', 85 | height: '100%', 86 | objectFit: fit, 87 | transitionProperty: `${Boolean(shift) ? `${shift}, ` : ''}opacity`, 88 | transitionDuration: `${ 89 | Boolean(shift) ? `${shiftDuration || duration * 0.3}ms, ` : '' 90 | }${duration / 2}ms`, 91 | transitionTimingFunction: easing, 92 | opacity: loaded ? 1 : 0, 93 | animation: loaded ? `materialize ${duration}ms 1 ${easing}` : '', 94 | ...(Boolean(shift) && shiftStyles), 95 | ...style, 96 | }, 97 | icons: { 98 | width: '100%', 99 | marginLeft: '-100%', 100 | display: 'flex', 101 | justifyContent: 'center', 102 | alignItems: 'center', 103 | opacity: loaded ? 0 : 1, 104 | ...iconWrapperStyle, 105 | }, 106 | }; 107 | 108 | const showErrorIcon = (typeof errorIcon !== 'boolean' && errorIcon) || ( 109 | // MUI grey[400] 110 | ); 111 | 112 | const loadingIndicator = (typeof showLoading !== 'boolean' && 113 | showLoading) || ; 114 | 115 | return ( 116 |
120 | {alt} 129 | {(Boolean(showLoading) || Boolean(errorIcon)) && ( 130 |
134 | {Boolean(errorIcon) && error && showErrorIcon} 135 | {Boolean(showLoading) && !error && !loaded && loadingIndicator} 136 |
137 | )} 138 |
139 | ); 140 | } 141 | 142 | Image.propTypes = { 143 | src: PropTypes.string.isRequired, 144 | alt: PropTypes.string, 145 | height: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), 146 | width: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), 147 | style: PropTypes.object, 148 | className: PropTypes.string, 149 | showLoading: PropTypes.oneOfType([PropTypes.bool, PropTypes.node]), 150 | errorIcon: PropTypes.oneOfType([PropTypes.bool, PropTypes.node]), 151 | shift: PropTypes.oneOf([false, null, 'top', 'bottom', 'left', 'right']), 152 | distance: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), 153 | shiftDuration: PropTypes.number, 154 | bgColor: PropTypes.string, 155 | wrapperStyle: PropTypes.object, 156 | iconWrapperStyle: PropTypes.object, 157 | wrapperClassName: PropTypes.string, 158 | iconWrapperClassName: PropTypes.string, 159 | duration: PropTypes.number, 160 | easing: PropTypes.string, 161 | onLoad: PropTypes.func, 162 | onError: PropTypes.func, 163 | position: PropTypes.oneOf([ 164 | 'static', 165 | 'relative', 166 | 'absolute', 167 | 'fixed', 168 | 'sticky', 169 | 'inherit', 170 | 'initial', 171 | 'revert', 172 | 'unset', 173 | ]), 174 | fit: PropTypes.oneOf([ 175 | 'contain', 176 | 'cover', 177 | 'fill', 178 | 'none', 179 | 'scale-down', 180 | 'inherit', 181 | 'initial', 182 | 'revert', 183 | 'unset', 184 | ]), 185 | }; 186 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import Image from './Image'; 2 | 3 | export default Image; 4 | 5 | export { default as Image } from './Image'; 6 | -------------------------------------------------------------------------------- /tests/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "mocha": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /tests/index.test.js: -------------------------------------------------------------------------------- 1 | import expect from 'expect'; 2 | import React from 'react'; 3 | import { render, unmountComponentAtNode } from 'react-dom'; 4 | 5 | import Component from 'src/'; 6 | 7 | describe('Component', () => { 8 | let node; 9 | 10 | beforeEach(() => { 11 | node = document.createElement('div'); 12 | }); 13 | 14 | afterEach(() => { 15 | unmountComponentAtNode(node); 16 | }); 17 | 18 | it('displays a welcome message', () => { 19 | render(, node, () => { 20 | expect(node.innerHTML).toContain('Welcome to React components'); 21 | }); 22 | }); 23 | }); 24 | --------------------------------------------------------------------------------