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' - ) - }) -})