diff --git a/README.md b/README.md index 50bb789..d377ccd 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ - Full presentational control for the caller (render props). - Modern, performant implementation, using [IntersectionObserver](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) and providing [polyfill information](#polyfill-intersection-observer). - [Eager loading / Server-side rendering support](#eager-loading--server-side-rendering-ssr). +- [Debounce / Delay](#debounce--delay); can wait for an image to be in the viewport for a set time, before loading. - Works with horizontal scrolling, supports background images. - [Fallbacks for SEO / when Javascript is disabled](#fallback-without-javascript). - Easy to understand source code. You should be able to fork and do your thing if desired. @@ -292,6 +293,25 @@ Think about the cases where it is beneficial to do this, and apply it with inten Examples might be eager-loading hero images, preloading the first few elements in a list and so on. [Some of these use cases are provided as examples](#examples). +### Debounce / Delay + +In cases where you have a long list of images that the user might scroll through, then loading intermediate images can waste bandwidth and processing time. +This is undesired. +The way to handle it is with a **minimum duration** that the image has to stay within the viewport, before making the request. +This is specified using the `debounceDurationMs` prop: + +```jsx + ( + {imageProps.alt} + )} + actual={({ imageProps }) => } +/> +``` + ### Fallback without Javascript If Javascript is disabled altogether by the user, then they will be stuck with the placeholder (and any images loaded eagerly). @@ -410,19 +430,20 @@ The presentation can be derived from those plus, crucially, any specific needs y **``** accepts the following props: -| Name | Type | Default | Required | Description | -| ---------------------- | ------------------------------------------------------------------------- | ----------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------- | -| **src** | String | | true | The source of the image to load | -| **alt** | String | | false | The alt text description of the image you are loading | -| **srcSet** | String | | false | If your images use srcset, you can pass the `srcSet` prop to provide that information for preloading. | -| **sizes** | String | | false | If your images use srcset, the sizes attribute helps the browser decide which source to load. | -| **actual** | Function (render callback) of type ({imageProps}) => React.ReactNode | | true | Component to display once image has loaded | -| **placeholder** | Function (render callback) of type ({imageProps, ref}) => React.ReactNode | undefined | true | Component to display while no request for the actual image has been made | -| **loading** | Function (render callback) of type () => React.ReactNode | placeholder | false | Component to display while the image is loading | -| **error** | Function (render callback) of type () => React.ReactNode | actual (broken image) | false | Component to display if the image loading has failed (render prop) | -| **loadEagerly** | Boolean | false | false | Whether to skip checking for viewport and always show the 'actual' component | -| **observerProps** | {threshold: number, rootMargin: string} | {threshold: 0.01, rootMargin: "50px 0px"} | false | Subset of props for the IntersectionObserver | -| **experimentalDecode** | Boolean | false | false | Decode the image off-main-thread using the Image Decode API. Test before using! | +| Name | Type | Default | Required | Description | +| ---------------------- | ------------------------------------------------------------------------- | ----------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **src** | String | | true | The source of the image to load | +| **alt** | String | | false | The alt text description of the image you are loading | +| **srcSet** | String | | false | If your images use srcset, you can pass the `srcSet` prop to provide that information for preloading. | +| **sizes** | String | | false | If your images use srcset, the sizes attribute helps the browser decide which source to load. | +| **actual** | Function (render callback) of type ({imageProps}) => React.ReactNode | | true | Component to display once image has loaded | +| **placeholder** | Function (render callback) of type ({imageProps, ref}) => React.ReactNode | undefined | true | Component to display while no request for the actual image has been made | +| **loading** | Function (render callback) of type () => React.ReactNode | placeholder | false | Component to display while the image is loading | +| **error** | Function (render callback) of type () => React.ReactNode | actual (broken image) | false | Component to display if the image loading has failed (render prop) | +| **debounceDurationMs** | Number | N/A | false | The minimum duration that the image has to be in the viewport before starting to load, in ms. This can help avoid loading images while the user scrolls quickly past them. | +| **loadEagerly** | Boolean | false | false | Whether to skip checking for viewport and always show the 'actual' component | +| **observerProps** | {threshold: number, rootMargin: string} | {threshold: 0.01, rootMargin: "50px 0px"} | false | Subset of props for the IntersectionObserver | +| **experimentalDecode** | Boolean | false | false | Decode the image off-main-thread using the Image Decode API. Test before using! | **``** accepts the following props: @@ -432,6 +453,7 @@ The presentation can be derived from those plus, crucially, any specific needs y | **alt** | String | | false | The alt text description of the image you are loading | | **srcSet** | String | | false | If your images use srcset, you can pass the `srcSet` prop to provide that information for preloading. | | **sizes** | String | | false | If your images use srcset, the sizes attribute helps the browser decide which source to load. | +| **debounceDurationMs** | Number | N/A | false | The minimum duration that the image has to be in the viewport before starting to load, in ms. | | **loadEagerly** | Boolean | false | false | Whether to skip checking for viewport and always show the 'actual' component | | **observerProps** | {threshold: number, rootMargin: string} | {threshold: 0.01, rootMargin: "50px 0px"} | false | Subset of props for the IntersectionObserver | | **children** | Function of type ({imageProps, imageState, ref}) => React.ReactNode | | true | Function to call that renders based on the props and state provided to it by LazyImageFull | diff --git a/changes.md b/changes.md new file mode 100644 index 0000000..a1004fb --- /dev/null +++ b/changes.md @@ -0,0 +1,4 @@ +- "Buffering" state is exposed in LazyImageFull, but not in LazyImage. Does it make sense to even expose? + - Not exposing it would be non-breaking! +- Add "debounceDuration" or prop; decide on naming! + - Make optional, and 0 by default. diff --git a/package-lock.json b/package-lock.json index a4d727a..64b539a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "react-lazy-images", - "version": "0.9.1", + "version": "1.1.0-rc.3", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -14930,15 +14930,15 @@ "dev": true }, "typescript": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-2.9.2.tgz", - "integrity": "sha512-Gr4p6nFNaoufRIY4NMdpQRNmgxVIGMs4Fcu/ujdYk3nAZqk7supzBE9idmvfZIlH/Cuj//dvi+019qEue9lV0w==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.0.1.tgz", + "integrity": "sha512-zQIMOmC+372pC/CCVLqnQ0zSBiY7HHodU7mpQdjiZddek4GMj31I3dUJ7gAs9o65X7mnRma6OokOkc6f9jjfBg==", "dev": true }, "typescript-eslint-parser": { - "version": "16.0.1", - "resolved": "https://registry.npmjs.org/typescript-eslint-parser/-/typescript-eslint-parser-16.0.1.tgz", - "integrity": "sha512-IKawLTu4A2xN3aN/cPLxvZ0bhxZHILGDKTZWvWNJ3sLNhJ3PjfMEDQmR2VMpdRPrmWOadgWXRwjLBzSA8AGsaQ==", + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/typescript-eslint-parser/-/typescript-eslint-parser-17.0.1.tgz", + "integrity": "sha512-EPCOjxjnGqu9kQwH3CAwa1Jjty2Dn0FnehksVEmfZxXtkEK2Jerb2lnd/htsj1XooqEkKC5AzCaK+qOxHaBDBQ==", "dev": true, "requires": { "lodash.unescape": "4.0.1", @@ -15038,6 +15038,11 @@ } } }, + "unionize": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/unionize/-/unionize-2.1.2.tgz", + "integrity": "sha1-JROxSN5RW+yT8EXRaFvYjqtitgg=" + }, "uniq": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/uniq/-/uniq-1.0.1.tgz", diff --git a/package.json b/package.json index 017749a..4d3d959 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "react-lazy-images", "description": "React utilities for lazy image loading", - "version": "1.0.1", + "version": "1.1.0-rc.3", "source": "src/index.tsx", "module": "dist/react-lazy-images.es.js", "main": "dist/react-lazy-images.js", @@ -10,9 +10,11 @@ "scripts": { "dev": "npm-run-all bundle:prod storybook", "dev:ts": "tsc --watch --pretty", - "build": "npm-run-all bundle:prod size", + "clean": "rimraf dist/", + "build": "npm-run-all clean bundle:prod copy-defs size", "bundle:prod": "microbundle build", "bundle:watch": "microbundle watch", + "copy-defs": "tsc && cp -r ts-build/*.d.ts dist/", "storybook": "start-storybook -p 8080 -s ./stories/demo", "storybook:build": "npm run bundle:prod && build-storybook -c .storybook -s ./stories/demo -o .out", "storybook:deploy": "storybook-to-ghpages --existing-output-dir=.out", @@ -63,7 +65,8 @@ }, "homepage": "https://github.com/fpapado/react-lazy-images#readme", "dependencies": { - "react-intersection-observer": "^6.1.0" + "react-intersection-observer": "^6.1.0", + "unionize": "^2.1.2" }, "peerDependencies": { "react": "^15 || ^16", @@ -88,7 +91,7 @@ "eslint-plugin-jsx-a11y": "^6.1.1", "husky": "^1.0.0-rc.13", "lint-staged": "^7.2.0", - "microbundle": "^0.5.1", + "microbundle": "0.5.1", "npm-run-all": "^4.1.3", "prettier": "^1.13.7", "react": "^16.4.1", @@ -97,7 +100,7 @@ "react-docgen-typescript-webpack-plugin": "^1.1.0", "react-dom": "^16.4.1", "tachyons": "^4.10.0", - "typescript": "^2.9.2", - "typescript-eslint-parser": "^16.0.1" + "typescript": "^3.0.1", + "typescript-eslint-parser": "^17.0.1" } } diff --git a/src/LazyImageFull.tsx b/src/LazyImageFull.tsx index bb85f2f..ddd0bfe 100644 --- a/src/LazyImageFull.tsx +++ b/src/LazyImageFull.tsx @@ -1,10 +1,14 @@ import React from "react"; import Observer from "react-intersection-observer"; +import { unionize, ofType, UnionOf } from "unionize"; /** * Valid props for LazyImage components */ export type CommonLazyImageProps = ImageProps & { + // NOTE: if you add props here, remember to destructure them out of being + // passed to the children, in the render() callback. + /** Whether to skip checking for viewport and always show the 'actual' component * @see https://github.com/fpapado/react-lazy-images/#eager-loading--server-side-rendering-ssr */ @@ -22,6 +26,15 @@ export type CommonLazyImageProps = ImageProps & { * @see: https://www.chromestatus.com/feature/5637156160667648 */ experimentalDecode?: boolean; + + /** Whether to log out internal state transitions for the component */ + debugActions?: boolean; + + /** Delay a certain duration before starting to load, in ms. + * This can help avoid loading images while the user scrolls quickly past them. + * TODO: naming things. + */ + debounceDurationMs?: number; }; /** Valid props for LazyImageFull */ @@ -70,14 +83,9 @@ export interface ObserverProps { threshold?: number; } -/** The component's state */ -export type LazyImageFullState = { - hasBeenInView: boolean; - imageState: ImageState; -}; - /** States that the image loading can be in. - * Used together with LazyImageFull render props + * Used together with LazyImageFull render props. + * External representation of the internal state. * */ export enum ImageState { NotAsked = "NotAsked", @@ -86,6 +94,94 @@ export enum ImageState { LoadError = "LoadError" } +/** The component's state */ +const LazyImageFullState = unionize({ + NotAsked: {}, + Buffering: {}, + // Could try to make it Promise, + // but we don't use the element anyway, and we cache promises + Loading: {}, + LoadSuccess: {}, + LoadError: ofType<{ msg: string }>() +}); + +type LazyImageFullState = UnionOf; + +/** Actions that change the component's state. + * These are not unlike Actions in Redux or, the ones I'm inspired by, + * Msg in Elm. + */ +const Action = unionize({ + ViewChanged: ofType<{ inView: boolean }>(), + BufferingEnded: {}, + // MAYBE: Load: {}, + LoadSuccess: {}, + LoadError: ofType<{ msg: string }>() +}); + +type Action = UnionOf; + +/** Commands (Cmd) describe side-effects as functions that take the instance */ +// FUTURE: These should be tied to giving back a Msg / asynchronoulsy giving a Msg with conditions +type Cmd = (instance: LazyImageFull) => void; + +/** The output from a reducer is the next state and maybe a command */ +type ReducerResult = { + nextState: LazyImageFullState; + cmd?: Cmd; +}; + +///// Commands, things that perform side-effects ///// +/** Get a command that sets a buffering Promise */ +const getBufferingCmd = (durationMs: number): Cmd => instance => { + // Make cancelable buffering Promise + const bufferingPromise = makeCancelable(delayedPromise(durationMs)); + + // Kick off promise chain + bufferingPromise.promise + .then(() => instance.update(Action.BufferingEnded())) + .catch( + _reason => {} + //console.log({ isCanceled: _reason.isCanceled }) + ); + + // Side-effect; set the promise in the cache + instance.promiseCache.buffering = bufferingPromise; +}; + +/** Get a command that sets an image loading Promise */ +const getLoadingCmd = ( + imageProps: ImageProps, + experimentalDecode?: boolean +): Cmd => instance => { + // Make cancelable loading Promise + const loadingPromise = makeCancelable( + loadImage(imageProps, experimentalDecode) + ); + + // Kick off request for Image and attach listeners for response + loadingPromise.promise + .then(_res => instance.update(Action.LoadSuccess({}))) + .catch(e => { + // If the Loading Promise was canceled, it means we have stopped + // loading due to unmount, rather than an error. + if (!e.isCanceled) { + // TODO: think more about the error here + instance.update(Action.LoadError({ msg: "Failed to load" })); + } + }); + + // Side-effect; set the promise in the cache + instance.promiseCache.loading = loadingPromise; +}; + +/** Command that cancels the buffering Promise */ +const cancelBufferingCmd: Cmd = instance => { + // Side-effect; cancel the promise in the cache + // We know this exists if we are in a Buffering state + instance.promiseCache.buffering.cancel(); +}; + /** * Component that preloads the image once it is in the viewport, * and then swaps it in. Takes a render prop that allows to specify @@ -97,89 +193,162 @@ export class LazyImageFull extends React.Component< > { static displayName = "LazyImageFull"; - initialState = { hasBeenInView: false, imageState: ImageState.NotAsked }; + /** A central place to store promises. + * A bit silly, but passing promsises directly in the state + * was giving me weird timing issues. This way we can keep + * the promises in check, and pick them up from the respective methods. + * FUTURE: Could pass the relevant key in Buffering and Loading, so + * that at least we know where they are from a single source. + */ + promiseCache: { + [key: string]: CancelablePromise; + } = {}; + + initialState = LazyImageFullState.NotAsked(); + + /** Emit the next state based on actions. + * This is the core of the component! + */ + static reducer( + action: Action, + prevState: LazyImageFullState, + props: LazyImageFullProps + ): ReducerResult { + return Action.match(action, { + ViewChanged: ({ inView }) => { + if (inView === true) { + // If src is not specified, then there is nothing to preload; skip to Loaded state + if (!props.src) { + return { nextState: LazyImageFullState.LoadSuccess() }; // Error wtf + } else { + // If in view, only load something if NotAsked, otherwise leave untouched + return LazyImageFullState.match(prevState, { + NotAsked: () => { + // If debounce is specified, then start buffering + if (!!props.debounceDurationMs) { + return { + nextState: LazyImageFullState.Buffering(), + cmd: getBufferingCmd(props.debounceDurationMs) + }; + } else { + // If no debounce is specified, then start loading immediately + return { + nextState: LazyImageFullState.Loading(), + cmd: getLoadingCmd(props, props.experimentalDecode) + }; + } + }, + // Do nothing in other states + default: () => ({ nextState: prevState }) + }); + } + } else { + // If out of view, cancel if Buffering, otherwise leave untouched + return LazyImageFullState.match(prevState, { + Buffering: () => ({ + nextState: LazyImageFullState.NotAsked(), + cmd: cancelBufferingCmd + }), + // Do nothing in other states + default: () => ({ nextState: prevState }) + }); + } + }, + // Buffering has ended/succeeded, kick off request for image + BufferingEnded: () => ({ + nextState: LazyImageFullState.Loading(), + cmd: getLoadingCmd(props, props.experimentalDecode) + }), + // Loading the image succeeded, simple + LoadSuccess: () => ({ nextState: LazyImageFullState.LoadSuccess() }), + // Loading the image failed, simple + LoadError: e => ({ nextState: LazyImageFullState.LoadError(e) }) + }); + } constructor(props: LazyImageFullProps) { super(props); this.state = this.initialState; // Bind methods - // This would be nicer with arrow functions and class properties, - // but holding off until they are settled. - this.onInView = this.onInView.bind(this); - this.onLoadSuccess = this.onLoadSuccess.bind(this); - this.onLoadError = this.onLoadError.bind(this); + this.update = this.update.bind(this); } - // Update functions - onInView(inView: boolean) { - if (inView === true) { - // If src is not specified, then there is nothing to preload; skip to Loaded state - if (!this.props.src) { - this.setState((state, _props) => ({ - ...state, - imageState: ImageState.LoadSuccess - })); - } else { - // Kick off request for Image and attach listeners for response - this.setState((state, _props) => ({ - ...state, - imageState: ImageState.Loading - })); - - loadImage( - { - src: this.props.src, - srcSet: this.props.srcSet, - alt: this.props.alt, - sizes: this.props.sizes - }, - this.props.experimentalDecode - ) - .then(this.onLoadSuccess) - .catch(this.onLoadError); + update(action: Action) { + // Get the next state and any effects + const { nextState, cmd } = LazyImageFull.reducer( + action, + this.state, + this.props + ); + + // Debugging + if (this.props.debugActions) { + if (process.env.NODE_ENV === "production") { + console.warn( + 'You are running LazyImage with debugActions="true" in production. This might have performance implications.' + ); } + console.log({ action, prevState: this.state, nextState }); } - } - onLoadSuccess() { - this.setState((state, _props) => ({ - ...state, - imageState: ImageState.LoadSuccess - })); + // Actually set the state, and kick off any effects after that + this.setState(nextState, () => cmd && cmd(this)); } - onLoadError() { - this.setState((state, _props) => ({ - ...state, - imageState: ImageState.LoadError - })); + componentWillUnmount() { + // Clear the Promise Cache + if (this.promiseCache.loading) { + // NOTE: This does not cancel the request, only the callback. + // We weould need fetch() and an AbortHandler for that. + this.promiseCache.loading.cancel(); + } + if (this.promiseCache.buffering) { + this.promiseCache.buffering.cancel(); + } + this.promiseCache = {}; } // Render function render() { + // This destructuring is silly const { children, loadEagerly, observerProps, experimentalDecode, + debounceDurationMs, + debugActions, ...imageProps } = this.props; if (loadEagerly) { // If eager, skip the observer and view changing stuff; resolve the imageState as loaded. - return children({ imageState: ImageState.LoadSuccess, imageProps }); + return children({ + // We know that the state tags and the enum match up + imageState: LazyImageFullState.LoadSuccess().tag as ImageState, + imageProps + }); } else { return ( this.update(Action.ViewChanged({ inView }))} > {({ ref }) => - children({ imageState: this.state.imageState, imageProps, ref }) + children({ + // We know that the state tags and the enum match up, apart + // from Buffering not being exposed + imageState: + this.state.tag === "Buffering" + ? ImageState.Loading + : (this.state.tag as ImageState), + imageProps, + ref + }) } ); @@ -187,7 +356,7 @@ export class LazyImageFull extends React.Component< } } -// Utilities +///// Utilities ///// /** Promise constructor for loading an image */ const loadImage = ( @@ -209,15 +378,54 @@ const loadImage = ( /** @see: https://www.chromestatus.com/feature/5637156160667648 */ if (experimentalDecode && "decode" in image) { - image - // NOTE: .decode() is not in the TS defs yet - //@ts-ignore - .decode() - .then(() => resolve()) - .catch((err: any) => reject(err)); - return; + return ( + image + // NOTE: .decode() is not in the TS defs yet + // TODO: consider writing the .decode() definition and sending a PR + //@ts-ignore + .decode() + .then((image: HTMLImageElement) => resolve(image)) + .catch((err: any) => reject(err)) + ); } image.onload = resolve; image.onerror = reject; }); + +/** Promise that resolves after a specified number of ms */ +const delayedPromise = (ms: number) => + new Promise(resolve => setTimeout(resolve, ms)); + +interface CancelablePromise { + promise: Promise<{}>; + cancel: () => void; +} + +/** Make a Promise "cancelable". + * + * Rejects with {isCanceled: true} if canceled. + * + * The way this works is by wrapping it with internal hasCanceled_ state + * and checking it before resolving. + */ +const makeCancelable = (promise: Promise): CancelablePromise => { + let hasCanceled_ = false; + + const wrappedPromise = new Promise((resolve, reject) => { + promise.then( + (val: any) => (hasCanceled_ ? reject({ isCanceled: true }) : resolve(val)) + ); + promise.catch( + (error: any) => + hasCanceled_ ? reject({ isCanceled: true }) : reject(error) + ); + }); + + return { + promise: wrappedPromise, + cancel() { + hasCanceled_ = true; + } + }; +}; diff --git a/src/react-intersection-observer.d.ts b/src/react-intersection-observer.d.ts index ef63a45..39168b8 100644 --- a/src/react-intersection-observer.d.ts +++ b/src/react-intersection-observer.d.ts @@ -55,7 +55,7 @@ declare module "react-intersection-observer" { triggerOnce?: boolean; /** Call this function whenever the in view state changes */ - onChange?(inView: boolean | React.FormEvent): void; + onChange?(inView: boolean): void; } export default class IntersectionObserver extends React.Component< diff --git a/stories/LazyImage.story.tsx b/stories/LazyImage.story.tsx index fb5742d..ceae46f 100644 --- a/stories/LazyImage.story.tsx +++ b/stories/LazyImage.story.tsx @@ -3,7 +3,9 @@ import { storiesOf } from "@storybook/react"; import { action } from "@storybook/addon-actions"; import { withInfo } from "@storybook/addon-info"; import { LazyImage } from "../src/index"; -import { Container } from "./utils"; +import { Container, Divider } from "./utils"; +import { LazyImageOpinionated } from "./OpinionatedComponents"; +import "./styles.css"; // Helpers to save typing. You can imagine that in // some cases, you too will have more specific components. @@ -23,6 +25,11 @@ const ActualImage = () => ( /> ); +// Utils for scrolling +const scrollToRef = (ref: React.RefObject) => + ref.current.scrollIntoView({ behavior: "smooth" }); +const endRef: React.RefObject = React.createRef(); + // Component that preloads the image and only swaps once ready //@ts-ignore const stories = storiesOf("LazyImage", module); @@ -125,6 +132,48 @@ stories )) ) + .add( + "Delayed loading", + withInfo( + "By specifying `debounceDurationMs`, you can prevent an image from loading, unless it has been in the viewport for a set amount of time." + )(() => { + return ( +
+ +

react-lazy-images Debounce test

+

+ The desired behaviour is to not to start loading the images before + them being in the viewport for X amount of time. Press the button + to scroll alll the way to the end of the page. Only the final + image should be loaded. +

+

+ Open your devTools network monitor and check the "Img" tab. Only + the last image should load. +

+

+ (Note that smooth scrolling should be working in your browser for + the test to be realistic. If not, you can scroll manually. Instant + scrolling does not trigger the IntersectionObserver afaict). +

+ +
+ + + + + +

Only things below here should be loaded

+ +
+ ); + }) + ) // Loading state as render prop .add( "Loading state", diff --git a/stories/OpinionatedComponents.tsx b/stories/OpinionatedComponents.tsx new file mode 100644 index 0000000..293df88 --- /dev/null +++ b/stories/OpinionatedComponents.tsx @@ -0,0 +1,54 @@ +import React from "react"; +import { LazyImage } from "../src/index"; + +/* Have you settled on how the actual images render in your application? + * For instance, you might have a container and a class. You might have + * tuned some options that match your use case, and just want to pass + * the src. + * Then you can easily abstract away to a level that works for you + * and your team. +*/ +interface IOpinionated { + src: string; + alt: string; +} + +/** Component that shows a box as placeholder, fades the image in, and keeps the aspect ratio at 3x2 + * This is probably not what you would name things, but it works for demonstration :) + */ +export const LazyImageOpinionated: React.SFC = ({ src, alt }) => ( +
+
} + actual={({ imageProps }) => ( + + )} + debounceDurationMs={1000} + debugActions={true} + /> +
+); + +/** Component that shows an image as placeholder, fades the image in, and keeps the aspect ratio at 3x2 + * Note that since we want the image to cross-fade on top of the placeholder one, + * we have to always show the placeholder. + * You could just as well toggle the placeholder/actual props to do this; it's mostly preference. + */ +export const LazyImageHoCWithPlaceholderSrc: React.SFC< + IOpinionated & { placeholderSrc: string } +> = ({ src, alt, placeholderSrc }) => ( +
+ {alt} +
} + actual={({ imageProps }) => ( + + )} + debounceDurationMs={1000} + /> +
+); diff --git a/stories/styles.css b/stories/styles.css new file mode 100644 index 0000000..ccf6dde --- /dev/null +++ b/stories/styles.css @@ -0,0 +1,92 @@ +.bg-near-white { + background-color: #f4f4f4; +} + +.lh-copy { + line-height: 1.5; +} + +.sans-serif { + font-family: sans-serif; +} + +.intrinsic { + display: block; + + position: relative; + height: 0; + width: 100%; +} + +.intrinsic--square { + padding-top: 100%; +} + +.intrinsic--4x3 { + padding-top: 75%; +} + +.intrinsic--3x2 { + padding-top: 66.6%; +} + +.intrinsic-item { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; +} + +.img { + max-width: 100%; +} + +.relative { + position: relative; +} + +.z-0 { + z-index: 0; +} + +.z-1 { + z-index: 1; +} + +.z-2 { + z-index: 2; +} + +/* Animations */ +.animated { + -webkit-animation-duration: 0.4s; + animation-duration: 0.4s; + -webkit-animation-fill-mode: both; + animation-fill-mode: both; +} + +@-webkit-keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +.fadeIn { + -webkit-animation-name: fadeIn; + animation-name: fadeIn; +} diff --git a/stories/utils.tsx b/stories/utils.tsx index 51b442a..3518574 100644 --- a/stories/utils.tsx +++ b/stories/utils.tsx @@ -15,3 +15,7 @@ export const Container: React.SFC<{}> = ({ children }) => (
{children}
); + +export const Divider: React.SFC<{}> = ({ children }) => ( +
{children}
+); diff --git a/tsconfig.json b/tsconfig.json index 1f9f315..96638e4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,23 +1,6 @@ { + "extends": "./tsconfig_base.json", "compilerOptions": { - "module": "es2015", - "target": "es5", - "declaration": true, - "sourceMap": true, - "jsx": "react", - "allowJs": false, - "strict": true, - "rootDirs": ["src", "stories"], - "outDir": "ts-build", - "moduleResolution": "node", - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "lib": ["es6", "dom"], - "baseUrl": "./", - "paths": { - "react-intersection-observer": ["src/react-intersection-observer.d.ts"] - } - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] + "declaration": false + } } diff --git a/tsconfig_base.json b/tsconfig_base.json new file mode 100644 index 0000000..1f9f315 --- /dev/null +++ b/tsconfig_base.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "module": "es2015", + "target": "es5", + "declaration": true, + "sourceMap": true, + "jsx": "react", + "allowJs": false, + "strict": true, + "rootDirs": ["src", "stories"], + "outDir": "ts-build", + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "lib": ["es6", "dom"], + "baseUrl": "./", + "paths": { + "react-intersection-observer": ["src/react-intersection-observer.d.ts"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}