diff --git a/src/components/Image/ImageStylePropTypes.js b/src/components/Image/ImageStylePropTypes.js
new file mode 100644
index 00000000..699761fa
--- /dev/null
+++ b/src/components/Image/ImageStylePropTypes.js
@@ -0,0 +1,4 @@
+import View from '../View'
+export default {
+ ...(View.stylePropTypes)
+}
diff --git a/src/components/Image/index.js b/src/components/Image/index.js
new file mode 100644
index 00000000..7329a9bd
--- /dev/null
+++ b/src/components/Image/index.js
@@ -0,0 +1,215 @@
+/* global window */
+import { pickProps } from '../../modules/filterObjectProps'
+import CoreComponent from '../CoreComponent'
+import ImageStylePropTypes from './ImageStylePropTypes'
+import React, { PropTypes } from 'react'
+import View from '../View'
+
+const STATUS_ERRORED = 'ERRORED'
+const STATUS_LOADED = 'LOADED'
+const STATUS_LOADING = 'LOADING'
+const STATUS_PENDING = 'PENDING'
+const STATUS_IDLE = 'IDLE'
+
+const styles = {
+ initial: {
+ alignSelf: 'flex-start',
+ backgroundRepeat: 'no-repeat',
+ backgroundPosition: 'center',
+ backgroundSize: 'cover'
+ },
+ img: {
+ borderWidth: 0,
+ height: 'auto',
+ maxHeight: '100%',
+ maxWidth: '100%',
+ opacity: 0
+ },
+ children: {
+ bottom: 0,
+ left: 0,
+ position: 'absolute',
+ right: 0,
+ top: 0
+ },
+ resizeMode: {
+ clip: {
+ backgroundSize: 'auto'
+ },
+ contain: {
+ backgroundSize: 'contain'
+ },
+ cover: {
+ backgroundSize: 'cover'
+ },
+ stretch: {
+ backgroundSize: '100% 100%'
+ }
+ }
+}
+
+class Image extends React.Component {
+ constructor(props, context) {
+ super(props, context)
+
+ // state
+ this.state = { status: props.source.uri ? STATUS_PENDING : STATUS_IDLE }
+
+ // autobinding
+ this._onError = this._onError.bind(this)
+ this._onLoad = this._onLoad.bind(this)
+ }
+
+ static propTypes = {
+ accessibilityLabel: PropTypes.string,
+ children: PropTypes.any,
+ defaultSource: PropTypes.object,
+ onError: PropTypes.func,
+ onLoad: PropTypes.func,
+ onLoadEnd: PropTypes.func,
+ onLoadStart: PropTypes.func,
+ resizeMode: PropTypes.oneOf(['clip', 'contain', 'cover', 'stretch']),
+ source: PropTypes.object,
+ style: PropTypes.shape(ImageStylePropTypes),
+ testID: CoreComponent.propTypes.testID
+ }
+
+ static stylePropTypes = ImageStylePropTypes
+
+ static defaultProps = {
+ defaultSource: {},
+ resizeMode: 'cover',
+ source: {},
+ style: styles.initial
+ }
+
+ _cancelEvent(event) {
+ event.preventDefault()
+ event.stopPropagation()
+ }
+
+ _createImageLoader() {
+ const { source } = this.props
+
+ this._destroyImageLoader()
+ this.image = new window.Image()
+ this.image.onerror = this._onError
+ this.image.onload = this._onLoad
+ this.image.src = source.uri
+ this._onLoadStart()
+ }
+
+ _destroyImageLoader() {
+ if (this.image) {
+ this.image.onload = null
+ this.image.onerror = null
+ this.image = null
+ }
+ }
+
+ _onError(e) {
+ const { onError } = this.props
+ const event = { nativeEvent: e }
+
+ this._destroyImageLoader()
+ this.setState({ status: STATUS_ERRORED })
+ if (onError) onError(event)
+ this._onLoadEnd()
+ }
+
+ _onLoad(e) {
+ const { onLoad } = this.props
+ const event = { nativeEvent: e }
+
+ this._destroyImageLoader()
+ this.setState({ status: STATUS_LOADED })
+ if (onLoad) onLoad(event)
+ this._onLoadEnd()
+ }
+
+ _onLoadEnd() {
+ const { onLoadEnd } = this.props
+ if (onLoadEnd) onLoadEnd()
+ }
+
+ _onLoadStart() {
+ const { onLoadStart } = this.props
+ this.setState({ status: STATUS_LOADING })
+ if (onLoadStart) onLoadStart()
+ }
+
+ componentDidMount() {
+ if (this.state.status === STATUS_PENDING) {
+ this._createImageLoader()
+ }
+ }
+
+ componentDidUpdate() {
+ if (this.state.status === STATUS_PENDING && !this.image) {
+ this._createImageLoader()
+ }
+ }
+
+ componentWillReceiveProps(nextProps) {
+ if (this.props.source.uri !== nextProps.source.uri) {
+ this.setState({
+ status: nextProps.source.uri ? STATUS_PENDING : STATUS_IDLE
+ })
+ }
+ }
+
+ componentWillUnmount() {
+ this._destroyImageLoader()
+ }
+
+ render() {
+ const {
+ accessibilityLabel,
+ children,
+ defaultSource,
+ resizeMode,
+ source,
+ style,
+ testID
+ } = this.props
+
+ const isLoaded = this.state.status === STATUS_LOADED
+ const defaultImage = defaultSource.uri || null
+ const displayImage = !isLoaded ? defaultImage : source.uri
+ const resolvedStyle = pickProps(style, Object.keys(ImageStylePropTypes))
+ const backgroundImage = displayImage ? `url(${displayImage})` : null
+
+ /**
+ * Image is a non-stretching View. The image is displayed as a background
+ * image to support `resizeMode`. The HTML image is hidden but used to
+ * provide the correct responsive image dimensions, and to support the
+ * image context menu. Child content is rendered into an element absolutely
+ * positioned over the image.
+ */
+ return (
+
+
+ {children ? (
+
+ ) : null}
+
+ )
+ }
+}
+
+export default Image
diff --git a/src/components/Image/index.spec.js b/src/components/Image/index.spec.js
new file mode 100644
index 00000000..52dcde0b
--- /dev/null
+++ b/src/components/Image/index.spec.js
@@ -0,0 +1,104 @@
+import assert from 'assert'
+import React from 'react/addons'
+
+import Image from '.'
+import View from '../View'
+
+const ReactTestUtils = React.addons.TestUtils
+
+function shallowRender(component, context = {}) {
+ const shallowRenderer = React.addons.TestUtils.createRenderer()
+ shallowRenderer.render(component, context)
+ return shallowRenderer.getRenderOutput()
+}
+
+function render(component, node) {
+ return node ? React.render(component, node) : ReactTestUtils.renderIntoDocument(component)
+}
+
+function getImageDOM(props) {
+ const result = ReactTestUtils.renderIntoDocument()
+ return React.findDOMNode(result)
+}
+
+suite('Image', () => {
+ test('defaults', () => {
+ const result = shallowRender()
+ assert.equal(result.type, View)
+ })
+
+ test('prop "accessibilityLabel"', () => {
+ const accessibilityLabel = 'accessibilityLabel'
+ const element = getImageDOM()
+ const elementHasLabel = getImageDOM({ accessibilityLabel })
+
+ assert.equal(element.getAttribute('aria-label'), null)
+ assert.equal(elementHasLabel.getAttribute('aria-label'), accessibilityLabel)
+ })
+
+ test.skip('prop "children"', () => { })
+
+ test('prop "defaultSource"', () => {
+ const defaultSource = { uri: 'https://google.com/favicon.ico' }
+ const elementHasdefaultSource = getImageDOM({ defaultSource })
+
+ assert.equal(elementHasdefaultSource.style.backgroundImage, `url(${defaultSource.uri})`)
+ })
+
+ test('prop "onError"', (done) => {
+ function onError(e) {
+ assert.equal(e.nativeEvent.type, 'error')
+ done()
+ }
+
+ render()
+ })
+
+ test('prop "onLoad"', (done) => {
+ function onLoad(e) {
+ assert.equal(e.nativeEvent.type, 'load')
+ done()
+ }
+
+ render()
+ })
+
+ test.skip('prop "onLoadEnd"', () => { })
+
+ test.skip('prop "onLoadStart"', () => { })
+
+ test.skip('prop "resizeMode"', () => { })
+
+ test.skip('prop "source"', () => { })
+
+ test('prop "style"', () => {
+ const initial = shallowRender()
+ assert.deepEqual(
+ initial.props.style,
+ Image.defaultProps.style
+ )
+
+ const unsupported = shallowRender()
+ assert.deepEqual(
+ unsupported.props.style.unsupported,
+ null,
+ 'unsupported "style" properties must not be transferred'
+ )
+ })
+
+ test('prop "testID"', () => {
+ const testID = 'Example.image'
+ const elementHasTestID = getImageDOM({ testID })
+
+ assert.equal(
+ elementHasTestID.getAttribute('data-testid'),
+ testID
+ )
+ })
+})
diff --git a/src/modules/Image/ImageStylePropTypes.js b/src/modules/Image/ImageStylePropTypes.js
deleted file mode 100644
index 0d8b3cd5..00000000
--- a/src/modules/Image/ImageStylePropTypes.js
+++ /dev/null
@@ -1,17 +0,0 @@
-import { StylePropTypes } from '../react-native-web-style'
-import { PropTypes } from 'react'
-
-export default {
- ...StylePropTypes.BorderThemePropTypes,
- ...StylePropTypes.LayoutPropTypes,
- backgroundColor: PropTypes.string,
- boxShadow: PropTypes.string,
- opacity: PropTypes.number,
- transform: PropTypes.string
-}
-
-export const ImageDefaultStyle = {
- backgroundColor: 'lightGray',
- borderWidth: 0,
- maxWidth: '100%'
-}
diff --git a/src/modules/Image/index.js b/src/modules/Image/index.js
deleted file mode 100644
index a16f2383..00000000
--- a/src/modules/Image/index.js
+++ /dev/null
@@ -1,38 +0,0 @@
-import { pickProps } from '../filterObjectProps'
-import { WebStyleComponent } from '../react-native-web-style'
-import ImageStylePropTypes, { ImageDefaultStyle } from './ImageStylePropTypes'
-import React, { PropTypes } from 'react'
-
-class Image extends React.Component {
- static propTypes = {
- accessibilityLabel: PropTypes.string,
- className: PropTypes.string,
- source: PropTypes.object,
- style: PropTypes.shape(ImageStylePropTypes)
- }
-
- static defaultProps = {
- className: '',
- source: 'data:image/gif;base64,' +
- 'R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'
- }
-
- render() {
- const { accessibilityLabel, className, source, style, ...other } = this.props
- const filteredStyle = pickProps(style, Object.keys(ImageStylePropTypes))
- const mergedStyle = { ...ImageDefaultStyle, ...filteredStyle }
-
- return (
-
- )
- }
-}
-
-export default Image
diff --git a/src/modules/Image/index.spec.js b/src/modules/Image/index.spec.js
deleted file mode 100644
index 9f4458ba..00000000
--- a/src/modules/Image/index.spec.js
+++ /dev/null
@@ -1,64 +0,0 @@
-import assert from 'assert'
-import React from 'react/addons'
-
-import { ImageDefaultStyle } from './ImageStylePropTypes'
-import Image from '.'
-
-const ReactTestUtils = React.addons.TestUtils
-
-function shallowRender(component, context = {}) {
- const shallowRenderer = React.addons.TestUtils.createRenderer()
- shallowRenderer.render(component, context)
- return shallowRenderer.getRenderOutput()
-}
-
-suite('Image', () => {
- test('defaults', () => {
- const result = ReactTestUtils.renderIntoDocument()
- const root = React.findDOMNode(result)
-
- assert.equal((root.tagName).toLowerCase(), 'img')
- })
-
- test('prop "accessibilityLabel"', () => {
- const label = 'accessibilityLabel'
- const result = ReactTestUtils.renderIntoDocument()
- const root = React.findDOMNode(result)
-
- assert.equal(root.getAttribute('alt'), label)
- })
-
- test('prop "className"', () => {
- const className = 'className'
- const result = shallowRender()
-
- assert.ok(
- (result.props.className).indexOf(className) > -1,
- '"className" was not transferred'
- )
- })
-
- test('prop "source"', () => {
- const source = { uri: 'path-to-image' }
- const result = ReactTestUtils.renderIntoDocument()
- const root = React.findDOMNode(result)
-
- assert.equal(root.getAttribute('src'), source.uri)
- })
-
- test('prop "style"', () => {
- const initial = shallowRender()
- assert.deepEqual(
- initial.props.style,
- ImageDefaultStyle,
- 'default "style" is incorrect'
- )
-
- const unsupported = shallowRender()
- assert.deepEqual(
- unsupported.props.style.unsupported,
- null,
- 'unsupported "style" properties must not be transferred'
- )
- })
-})