diff --git a/docs/apis/AppRegistry.md b/docs/apis/AppRegistry.md new file mode 100644 index 00000000..c824a3fb --- /dev/null +++ b/docs/apis/AppRegistry.md @@ -0,0 +1,60 @@ +# AppRegistry + +`AppRegistry` is the control point for registering, running, prerendering, and +unmounting all apps. App root components should register themselves with +`AppRegistry.registerComponent`. Apps can be run by invoking +`AppRegistry.runApplication`, and prerendered by invoking +`AppRegistry.prerenderApplication` (see the [client and server rendering +guide](../guides/rendering.md) for more details). + +To "stop" an application when a view should be destroyed, call +`AppRegistry.unmountApplicationComponentAtRootTag` with the tag that was passed +into `runApplication`. These should always be used as a pair. + +## Methods + +(web) static **prerenderApplication**(appKey:string, appParameters: object) + +Renders the given application to an HTML string. Use this for server-side +rendering. Return object is of type `{ html: string; style: string; }`, where +`html` the prerendered HTML, and `style` is the prerendered style sheet. + +static **registerConfig**(config: Array) + +Registry multiple applications. `AppConfig` is of type `{ appKey: string; +component: ComponentProvider; run?: Function }`. + +static **registerComponent**(appKey: string, getComponentFunc: ComponentProvider) + +Register a component provider under the given `appKey`. + +static **registerRunnable**(appKey: string, run: Function) + +Register a custom render function for an application. The function will receive +the `appParameters` passed to `runApplication`. + +static **getAppKeys**() + +Returns all registered app keys. + +static **runApplication**(appKey: string, appParameters?: object) + +Runs the application that was registered under `appKey`. The `appParameters` +must include the `rootTag` into which the application is rendered, and +optionally any `initialProps`. + +static **unmountApplicationComponentAtRootTag**(rootTag: HTMLElement) + +To "stop" an application when a view should be destroyed, call +`AppRegistry.unmountApplicationComponentAtRootTag` with the tag that was passed +into `runApplication` + +## Example + +``` +AppRegistry.registerComponent('MyApp', () => AppComponent) +AppRegistry.runApplication('MyApp', { + initialProps: {}, + rootTag: document.getElementById('react-root') +}) +``` diff --git a/docs/apis/AppState.md b/docs/apis/AppState.md new file mode 100644 index 00000000..7d26b213 --- /dev/null +++ b/docs/apis/AppState.md @@ -0,0 +1,61 @@ +## AppState + +`AppState` can tell you if the app is in the foreground or background, and +notify you when the state changes. + +States + +* `active` - The app is running in the foreground +* `background` - The app is running in the background (i.e., the user has not focused the app's tab). + +## Properties + +static **currentState** + +Returns the current state of the app: `active` or `background`. + +## Methods + +static **addEventListener**(type: string, handler: Function) + +Add a handler to `AppState` changes by listening to the `change` event type and +providing the `handler`. The handler is called with the app state value. + +static **removeEventListener**(type: string, handler: Function) + +Remove a handler by passing the change event `type` and the `handler`. + +## Examples + +To see the current state, you can check `AppStateIOS.currentState`, which will +be kept up-to-date. This example will only ever appear to say "Current state +is: active" because the app is only visible to the user when in the `active` +state, and the null state will happen only momentarily. + +```js +class Example extends React.Component { + constructor(props) { + super(props) + this.state = { currentAppState: AppState.currentState } + this._handleAppStateChange = this._handleAppStateChange.bind(this) + } + + componentDidMount() { + AppState.addEventListener('change', this._handleAppStateChange); + } + + componentWillUnmount() { + AppState.removeEventListener('change', this._handleAppStateChange); + } + + _handleAppStateChange(currentAppState) { + this.setState({ currentAppState }); + } + + render() { + return ( + Current state is: {this.state.currentAppState} + ) + } +} +``` diff --git a/docs/apis/AsyncStorage.md b/docs/apis/AsyncStorage.md new file mode 100644 index 00000000..6c83b3cf --- /dev/null +++ b/docs/apis/AsyncStorage.md @@ -0,0 +1,71 @@ +# AsyncStorage + +`AsyncStorage` is a simple, asynchronous, persistent, key-value storage system +that is global to the domain. It's a facade over, and should be used instead of +`window.localStorage` to provide an asynchronous API and multi functions. Each +method returns a `Promise` object. + +It is recommended that you use an abstraction on top of `AsyncStorage` instead +of `AsyncStorage` directly for anything more than light usage since it operates +globally. + +The batched functions are useful for executing a lot of operations at once, +allowing for optimizations to provide the convenience of a single promise after +all operations are complete. + +## Methods + +static **clear**() + +Erases all AsyncStorage. You probably don't want to call this - use +`removeItem` or `multiRemove` to clear only your own keys instead. Returns a +Promise object. + +static **getAllKeys**() + +Gets all known keys. Returns a Promise object. + +static **getItem**(key: string) + +Fetches the value of the given key. Returns a Promise object. + +static **mergeItem**(key: string, value: string) + +Merges existing value with input value, assuming they are stringified JSON. +Returns a Promise object. + +static **multiGet**(keys: Array) + +`multiGet` results in an array of key-value pair arrays that matches the input +format of `multiSet`. Returns a Promise object. + +```js +multiGet(['k1', 'k2']) -> [['k1', 'val1'], ['k2', 'val2']] +``` + +static **multiMerge**(keyValuePairs: Array>) + +multiMerge takes an array of key-value array pairs that match the output of +`multiGet`. It merges existing values with input values, assuming they are +stringified JSON. Returns a Promise object. + +static **multiRemove**(keys: Array) + +Delete all the keys in the keys array. Returns a Promise object. + +static **multiSet**(keyValuePairs: Array>) + +`multiSet` takes an array of key-value array pairs that match the output of +`multiGet`. Returns a Promise object. + +```js +multiSet([['k1', 'val1'], ['k2', 'val2']]); +``` + +static **removeItem**(key: string) + +Removes the value of the given key. Returns a Promise object. + +static **setItem**(key: string, value: string) + +Sets the value of the given key. Returns a Promise object. diff --git a/docs/apis/Dimensions.md b/docs/apis/Dimensions.md new file mode 100644 index 00000000..20a7b09f --- /dev/null +++ b/docs/apis/Dimensions.md @@ -0,0 +1,13 @@ +# Dimensions + +Note: dimensions may change (e.g due to device rotation) so any rendering logic +or styles that depend on these constants should try to call this function on +every render, rather than caching the value. + +## Methods + +static **get**(dimension: string) + +Get a dimension (e.g., `"window"` or `"screen"`). + +Example: `const { height, width } = Dimensions.get('window')` diff --git a/docs/apis/NativeMethods.md b/docs/apis/NativeMethods.md new file mode 100644 index 00000000..b878c85f --- /dev/null +++ b/docs/apis/NativeMethods.md @@ -0,0 +1,42 @@ +# NativeMethods + +React Native for Web provides several methods to directly access the underlying +DOM node. This can be useful in cases when you want to focus a view or measure +its on-screen dimensions, for example. + +The methods described are available on most of the default components provided +by React Native for Web. Note, however, that they are *not* available on the +composite components that you define in your own app. For more information, see +[Direct Manipulation](../guides/direct-manipulation.md). + +## Methods + +**blur**() + +Removes focus from an input or view. This is the opposite of `focus()`. + +**focus**() + +Requests focus for the given input or view. The exact behavior triggered will +depend the type of view. + +**measure**(callback: (x, y, width, height, pageX, pageY) => void) + +For a given view, `measure` determines the offset relative to the parent view, +width, height, and the offset relative to the viewport. Returns the values via +an async callback. + +Note that these measurements are not available until after the rendering has +been completed. + +**measureLayout**(relativeToNativeNode: DOMNode, onSuccess: (x, y, width, height) => void) + +Like `measure`, but measures the view relative to another view, specified as +`relativeToNativeNode`. This means that the returned `x`, `y` are relative to +the origin `x`, `y` of the ancestor view. + +**setNativeProps**(nativeProps: Object) + +This function sends props straight to the underlying DOM node. See the [direct +manipulation](../guides/direct-manipulation.md) guide for cases where +`setNativeProps` should be used. diff --git a/docs/apis/NetInfo.md b/docs/apis/NetInfo.md new file mode 100644 index 00000000..c379200f --- /dev/null +++ b/docs/apis/NetInfo.md @@ -0,0 +1,77 @@ +# NetInfo + +`NetInfo` asynchronously determines the online/offline status of the +application. + +Connection types: + +* `bluetooth` - The user agent is using a Bluetooth connection. +* `cellular` - The user agent is using a cellular connection (e.g., EDGE, HSPA, LTE, etc.). +* `ethernet` - The user agent is using an Ethernet connection. +* `mixed` - The user agent is using multiple connection types. +* `none` - The user agent will not contact the network (offline). +* `other` - The user agent is using a connection type that is not one of enumerated connection types. +* `unknown` - The user agent has established a network connection, but is unable to determine what is the underlying connection technology. +* `wifi` - The user agent is using a Wi-Fi connection. +* `wimax` - The user agent is using a WiMAX connection. + +## Methods + +Note that support for retrieving the connection type depends upon browswer +support (and is limited to mobile browsers). It will default to `unknown` when +support is missing. + +static **addEventListener**(eventName: ChangeEventName, handler: Function) + +static **fetch**(): Promise + +static **removeEventListener**(eventName: ChangeEventName, handler: Function) + +## Properties + +**isConnected** + +Available on all user agents. Asynchronously fetch a boolean to determine +internet connectivity. + +**isConnected.addEventListener**(eventName: ChangeEventName, handler: Function) + +**isConnected.fetch**(): Promise + +**isConnected.removeEventListener**(eventName: ChangeEventName, handler: Function) + +## Examples + +Fetching the connection type: + +``` +NetInfo.fetch().then((connectionType) => { + console.log('Connection type:', connectionType); +}); +``` + +Subscribing to changes in the connection type: + +```js +const handleConnectivityTypeChange = (connectionType) => { + console.log('Current connection type:', connectionType); +} +NetInfo.addEventListener('change', handleConnectivityTypeChange); +``` + +Fetching the connection status: + +```js +NetInfo.isConnected.fetch().then((isConnected) => { + console.log('Connection status:', (isConnected ? 'online' : 'offline')); +}); +``` + +Subscribing to changes in the connection status: + +```js +const handleConnectivityStatusChange = (isConnected) => { + console.log('Current connection status:', (isConnected ? 'online' : 'offline')); +} +NetInfo.isConnected.addEventListener('change', handleConnectivityStatusChange); +``` diff --git a/docs/apis/PixelRatio.md b/docs/apis/PixelRatio.md new file mode 100644 index 00000000..0acc3e09 --- /dev/null +++ b/docs/apis/PixelRatio.md @@ -0,0 +1,51 @@ +# PixelRatio + +`PixelRatio` gives access to the device pixel density. + +## Methods + +static **get**() + +Returns the device pixel density. Some examples: + +* PixelRatio.get() === 1 + * mdpi Android devices (160 dpi) +* PixelRatio.get() === 1.5 + * hdpi Android devices (240 dpi) +* PixelRatio.get() === 2 + * iPhone 4, 4S + * iPhone 5, 5c, 5s + * iPhone 6 + * xhdpi Android devices (320 dpi) +* PixelRatio.get() === 3 + * iPhone 6 plus + * xxhdpi Android devices (480 dpi) +* PixelRatio.get() === 3.5 + * Nexus 6 + +static **getPixelSizeForLayoutSize**(layoutSize: number) + +Converts a layout size (dp) to pixel size (px). Guaranteed to return an integer +number. + +static **roundToNearestPixel**(layoutSize: number) + +Rounds a layout size (dp) to the nearest layout size that corresponds to an +integer number of pixels. For example, on a device with a PixelRatio of 3, +`PixelRatio.roundToNearestPixel(8.4)` = `8.33`, which corresponds to exactly +`(8.33 * 3)` = `25` pixels. + +## Examples + +Fetching a correctly sized image. You should get a higher resolution image if +you are on a high pixel density device. A good rule of thumb is to multiply the +size of the image you display by the pixel ratio. + +```js +const image = getImage({ + width: PixelRatio.getPixelSizeForLayoutSize(200), + height: PixelRatio.getPixelSizeForLayoutSize(100), +}); + + +``` diff --git a/docs/apis/Platform.md b/docs/apis/Platform.md new file mode 100644 index 00000000..25aa3d6c --- /dev/null +++ b/docs/apis/Platform.md @@ -0,0 +1,28 @@ +# Platform + +Detect what is the platform in which the app is running. This piece of +functionality can be useful when only small parts of a component are platform +specific. + +## Properties + +**OS**: string + +`Platform.OS` will be `web` when running in a Web browser. + +**userAgent**: string + +On Web, the `Platform` module can be also be used to detect the browser +`userAgent`. + +## Examples + +```js +const styles = StyleSheet.create({ + height: (Platform.OS === 'web') ? 200 : 100, +}); + +if (Platform.userAgent.includes('Android')) { + console.log('Running on Android!'); +} +``` diff --git a/docs/apis/StyleSheet.md b/docs/apis/StyleSheet.md index 7d1c042f..4cc94d49 100644 --- a/docs/apis/StyleSheet.md +++ b/docs/apis/StyleSheet.md @@ -5,7 +5,11 @@ CSS without requiring a compile-time step. Some styles cannot be resolved outside of the render loop and are applied as inline styles. Read more about to [how style your application](docs/guides/style). -Create a new StyleSheet: +## Methods + +**create**(obj: {[key: string]: any}) + +## Example ```js const styles = StyleSheet.create({ @@ -36,7 +40,3 @@ Use styles: /> ``` - -## Methods - -**create**(obj: {[key: string]: any}) diff --git a/package.json b/package.json index 0eca56d1..3f85bfc6 100644 --- a/package.json +++ b/package.json @@ -12,12 +12,13 @@ "examples": "webpack-dev-server --config config/webpack.config.example.js --inline --hot --colors --quiet", "lint": "eslint config examples src", "prepublish": "npm run build && npm run build:umd", - "test": "npm run lint && npm run test:unit", - "test:unit": "karma start config/karma.config.js", + "test": "karma start config/karma.config.js", "test:watch": "npm run test:unit -- --no-single-run" }, "dependencies": { + "exenv": "^1.2.0", "inline-style-prefixer": "^0.5.3", + "invariant": "^2.2.0", "lodash.debounce": "^3.1.1", "react-tappable": "^0.7.1", "react-textarea-autosize": "^3.1.0" @@ -53,8 +54,12 @@ "webpack": "^1.12.9", "webpack-dev-server": "^1.14.0" }, + "peerDependencies": { + "react": "^0.14.3", + "react-dom": "^0.14.3" + }, "author": "Nicolas Gallagher", - "license": "MIT", + "license": "BSD-3-Clause", "repository": { "type": "git", "url": "git://github.com/necolas/react-native-web.git" diff --git a/src/apis/AppRegistry/ReactNativeApp.js b/src/apis/AppRegistry/ReactNativeApp.js new file mode 100644 index 00000000..c3e1eda4 --- /dev/null +++ b/src/apis/AppRegistry/ReactNativeApp.js @@ -0,0 +1,47 @@ +import Portal from '../../components/Portal' +import React, { Component, PropTypes } from 'react' +import ReactDOM from 'react-dom' +import StyleSheet from '../../apis/StyleSheet' +import View from '../../components/View' + +export default class ReactNativeApp extends Component { + static propTypes = { + initialProps: PropTypes.object, + rootComponent: PropTypes.any.isRequired, + rootTag: PropTypes.any + }; + + constructor(props, context) { + super(props, context) + this._handleModalVisibilityChange = this._handleModalVisibilityChange.bind(this) + } + + _handleModalVisibilityChange(modalVisible) { + ReactDOM.findDOMNode(this._root).setAttribute('aria-hidden', `${modalVisible}`) + } + + render() { + const { initialProps, rootComponent: RootComponent, rootTag } = this.props + + return ( + + { this._root = c }} rootTag={rootTag} /> + + + ) + } +} + +const styles = StyleSheet.create({ + /** + * Ensure that the application covers the whole screen. This prevents the + * Portal content from being clipped. + */ + appContainer: { + position: 'absolute', + left: 0, + top: 0, + right: 0, + bottom: 0 + } +}) diff --git a/src/apis/AppRegistry/__tests__/index-test.js b/src/apis/AppRegistry/__tests__/index-test.js new file mode 100644 index 00000000..e69de29b diff --git a/src/apis/AppRegistry/__tests__/renderApplication-test.js b/src/apis/AppRegistry/__tests__/renderApplication-test.js new file mode 100644 index 00000000..e69de29b diff --git a/src/apis/AppRegistry/index.js b/src/apis/AppRegistry/index.js new file mode 100644 index 00000000..6ddfc53a --- /dev/null +++ b/src/apis/AppRegistry/index.js @@ -0,0 +1,90 @@ +/** + * Copyright (c) 2015-present, Nicolas Gallagher. + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * @flow + */ + +import { Component } from 'react' +import invariant from 'invariant' +import ReactDOM from 'react-dom' +import renderApplication, { prerenderApplication } from './renderApplication' + +const runnables = {} + +type ComponentProvider = () => Component + +type AppConfig = { + appKey: string; + component?: ComponentProvider; + run?: Function; +}; + +/** + * `AppRegistry` is the JS entry point to running all React Native apps. + */ +export default class AppRegistry { + static getAppKeys(): Array { + return Object.keys(runnables) + } + + static prerenderApplication(appKey: string, appParameters?: Object): string { + invariant( + runnables[appKey] && runnables[appKey].prerender, + `Application ${appKey} has not been registered. ` + + `This is either due to an import error during initialization or failure to call AppRegistry.registerComponent.` + ) + + return runnables[appKey].prerender(appParameters) + } + + static registerComponent(appKey: string, getComponentFunc: ComponentProvider): string { + runnables[appKey] = { + run: ({ initialProps, rootTag }) => renderApplication(getComponentFunc(), initialProps, rootTag), + prerender: ({ initialProps } = {}) => prerenderApplication(getComponentFunc(), initialProps) + } + return appKey + } + + static registerConfig(config: Array) { + config.forEach(({ appKey, component, run }) => { + if (run) { + AppRegistry.registerRunnable(appKey, run) + } else { + invariant(component, 'No component provider passed in') + AppRegistry.registerComponent(appKey, component) + } + }) + } + + // TODO: fix style sheet creation when using this method + static registerRunnable(appKey: string, run: Function): string { + runnables[appKey] = { run } + return appKey + } + + static runApplication(appKey: string, appParameters?: Object): void { + const isDevelopment = process.env.NODE_ENV === 'development' + const params = { ...appParameters } + params.rootTag = `#${params.rootTag.id}` + + console.log( + `Running application "${appKey}" with appParams: ${JSON.stringify(params)}. ` + + `development-level warnings are ${isDevelopment ? 'ON' : 'OFF'}, ` + + `performance optimizations are ${isDevelopment ? 'OFF' : 'ON'}` + ) + + invariant( + runnables[appKey] && runnables[appKey].run, + `Application "${appKey}" has not been registered. ` + + `This is either due to an import error during initialization or failure to call AppRegistry.registerComponent.` + ) + + runnables[appKey].run(appParameters) + } + + static unmountApplicationComponentAtRootTag(rootTag) { + ReactDOM.unmountComponentAtNode(rootTag) + } +} diff --git a/src/apis/AppRegistry/renderApplication.js b/src/apis/AppRegistry/renderApplication.js new file mode 100644 index 00000000..f82c8286 --- /dev/null +++ b/src/apis/AppRegistry/renderApplication.js @@ -0,0 +1,46 @@ +/** + * Copyright (c) 2015-present, Nicolas Gallagher. + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * @flow + */ + +import invariant from 'invariant' +import React, { Component } from 'react' +import ReactDOM from 'react-dom' +import ReactDOMServer from 'react-dom/server' +import ReactNativeApp from './ReactNativeApp' +import StyleSheet from '../../apis/StyleSheet' + +const STYLESHEET_ID = 'react-stylesheet' +const renderStyleSheetToString = () => `` + +export default function renderApplication(RootComponent: Component, initialProps: Object, rootTag: any) { + invariant(rootTag, 'Expect to have a valid rootTag, instead got ', rootTag) + + // insert style sheet if needed + const styleElement = document.getElementById(STYLESHEET_ID) + if (!styleElement) { rootTag.insertAdjacentHTML('beforebegin', renderStyleSheetToString()) } + + const component = ( + + ) + ReactDOM.render(component, rootTag) +} + +export function prerenderApplication(RootComponent: Component, initialProps: Object): string { + const component = ( + + ) + const html = ReactDOMServer.renderToString(component) + const style = renderStyleSheetToString() + return { html, style } +} diff --git a/src/apis/AppState/__tests__/index-test.js b/src/apis/AppState/__tests__/index-test.js new file mode 100644 index 00000000..7870afbf --- /dev/null +++ b/src/apis/AppState/__tests__/index-test.js @@ -0,0 +1,5 @@ +/* eslint-env mocha */ + +suite('apis/AppState', () => { + test.skip('NO TEST COVERAGE', () => {}) +}) diff --git a/src/apis/AppState/index.js b/src/apis/AppState/index.js new file mode 100644 index 00000000..0810313a --- /dev/null +++ b/src/apis/AppState/index.js @@ -0,0 +1,29 @@ +import invariant from 'invariant' + +const listeners = {} +const eventTypes = [ 'change' ] + +export default class AppState { + static get currentState() { + switch (document.visibilityState) { + case 'hidden': + case 'prerender': + case 'unloaded': + return 'background' + default: + return 'active' + } + } + + static addEventListener(type: string, handler: Function) { + listeners[handler] = () => handler(AppState.currentState) + invariant(eventTypes.indexOf(type) !== -1, 'Trying to subscribe to unknown event: "%s"', type) + document.addEventListener('visibilitychange', listeners[handler], false) + } + + static removeEventListener(type: string, handler: Function) { + invariant(eventTypes.indexOf(type) !== -1, 'Trying to remove listener for unknown event: "%s"', type) + document.removeEventListener('visibilitychange', listeners[handler], false) + delete listeners[handler] + } +} diff --git a/src/apis/AsyncStorage/__tests__/index-test.js b/src/apis/AsyncStorage/__tests__/index-test.js new file mode 100644 index 00000000..000366ce --- /dev/null +++ b/src/apis/AsyncStorage/__tests__/index-test.js @@ -0,0 +1,5 @@ +/* eslint-env mocha */ + +suite('apis/AsyncStorage', () => { + test.skip('NO TEST COVERAGE', () => {}) +}) diff --git a/src/apis/AsyncStorage/index.js b/src/apis/AsyncStorage/index.js new file mode 100644 index 00000000..c975709a --- /dev/null +++ b/src/apis/AsyncStorage/index.js @@ -0,0 +1,159 @@ +/** + * Copyright (c) 2015-present, Nicolas Gallagher. + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + */ + +const mergeLocalStorageItem = (key, value) => { + const oldValue = window.localStorage.getItem(key) + const oldObject = JSON.parse(oldValue) + const newObject = JSON.parse(value) + const nextValue = JSON.stringify({ ...oldObject, ...newObject }) + window.localStorage.setItem(key, nextValue) +} + +export default class AsyncStorage { + /** + * Erases *all* AsyncStorage for the domain. + */ + static clear() { + return new Promise((resolve, reject) => { + try { + window.localStorage.clear() + resolve(null) + } catch (err) { + reject(err) + } + }) + } + + /** + * Gets *all* keys known to the app, for all callers, libraries, etc. + */ + static getAllKeys() { + return new Promise((resolve, reject) => { + try { + const numberOfKeys = window.localStorage.length + const keys = [] + for (let i = 0; i < numberOfKeys; i += 1) { + const key = window.localStorage.key(i) + keys.push(key) + } + resolve(keys) + } catch (err) { + reject(err) + } + }) + } + + /** + * Fetches `key` value. + */ + static getItem(key: string) { + return new Promise((resolve, reject) => { + try { + const value = window.localStorage.getItem(key) + resolve(value) + } catch (err) { + reject(err) + } + }) + } + + /** + * Merges existing value with input value, assuming they are stringified JSON. + */ + static mergeItem(key: string, value: string) { + return new Promise((resolve, reject) => { + try { + mergeLocalStorageItem(key, value) + resolve(null) + } catch (err) { + reject(err) + } + }) + } + + /** + * multiGet resolves to an array of key-value pair arrays that matches the + * input format of multiSet. + * + * multiGet(['k1', 'k2']) -> [['k1', 'val1'], ['k2', 'val2']] + */ + static multiGet(keys: Array) { + const promises = keys.map((key) => AsyncStorage.getItem(key)) + + return Promise.all(promises).then( + (result) => Promise.resolve(result.map((value, i) => [ keys[i], value ])), + (error) => Promise.reject(error) + ) + } + + /** + * Takes an array of key-value array pairs and merges them with existing + * values, assuming they are stringified JSON. + * + * multiMerge([['k1', 'val1'], ['k2', 'val2']]) + */ + static multiMerge(keyValuePairs: Array>) { + const promises = keyValuePairs.map((item) => AsyncStorage.mergeItem(item[0], item[1])) + + return Promise.all(promises).then( + () => Promise.resolve(null), + (error) => Promise.reject(error) + ) + } + + /** + * Delete all the keys in the `keys` array. + */ + static multiRemove(keys: Array) { + const promises = keys.map((key) => AsyncStorage.removeItem(key)) + + return Promise.all(promises).then( + () => Promise.resolve(null), + (error) => Promise.reject(error) + ) + } + + /** + * Takes an array of key-value array pairs. + * multiSet([['k1', 'val1'], ['k2', 'val2']]) + */ + static multiSet(keyValuePairs: Array>) { + const promises = keyValuePairs.map((item) => AsyncStorage.setItem(item[0], item[1])) + + return Promise.all(promises).then( + () => Promise.resolve(null), + (error) => Promise.reject(error) + ) + } + + /** + * Removes a `key` + */ + static removeItem(key: string) { + return new Promise((resolve, reject) => { + try { + window.localStorage.removeItem(key) + resolve(null) + } catch (err) { + reject(err) + } + }) + } + + /** + * Sets `value` for `key`. + */ + static setItem(key: string, value: string) { + return new Promise((resolve, reject) => { + try { + window.localStorage.setItem(key, value) + resolve(null) + } catch (err) { + reject(err) + } + }) + } +} diff --git a/src/apis/Dimensions/__tests__/index-test.js b/src/apis/Dimensions/__tests__/index-test.js new file mode 100644 index 00000000..56228914 --- /dev/null +++ b/src/apis/Dimensions/__tests__/index-test.js @@ -0,0 +1,5 @@ +/* eslint-env mocha */ + +suite('apis/Dimensions', () => { + test.skip('NO TEST COVERAGE', () => {}) +}) diff --git a/src/apis/Dimensions/index.js b/src/apis/Dimensions/index.js new file mode 100644 index 00000000..f1ea8634 --- /dev/null +++ b/src/apis/Dimensions/index.js @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2015-present, Nicolas Gallagher. + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * @flow + */ + +import invariant from 'invariant' + +const dimensions = { + screen: { + fontScale: 1, + get height() { return window.screen.height }, + scale: window.devicePixelRatio || 1, + get width() { return window.screen.width } + }, + window: { + fontScale: 1, + get height() { return document.documentElement.clientHeight }, + scale: window.devicePixelRatio || 1, + get width() { return document.documentElement.clientWidth } + } +} + +export default class Dimensions { + static get(dimension: string): Object { + invariant(dimensions[dimension], 'No dimension set for key ' + dimension) + return dimensions[dimension] + } +} diff --git a/src/apis/NetInfo/__tests__/index-test.js b/src/apis/NetInfo/__tests__/index-test.js new file mode 100644 index 00000000..958b2724 --- /dev/null +++ b/src/apis/NetInfo/__tests__/index-test.js @@ -0,0 +1,5 @@ +/* eslint-env mocha */ + +suite('apis/NetInfo', () => { + test.skip('NO TEST COVERAGE', () => {}) +}) diff --git a/src/apis/NetInfo/index.js b/src/apis/NetInfo/index.js new file mode 100644 index 00000000..26115423 --- /dev/null +++ b/src/apis/NetInfo/index.js @@ -0,0 +1,77 @@ +/** + * Copyright (c) 2015-present, Nicolas Gallagher. + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * @flow + */ + +import invariant from 'invariant' + +const connection = window.navigator.connection || window.navigator.mozConnection || window.navigator.webkitConnection +const eventTypes = [ 'change' ] + +/** + * Navigator online: https://developer.mozilla.org/en-US/docs/Web/API/NavigatorOnLine/onLine + * Network Connection API: https://developer.mozilla.org/en-US/docs/Web/API/NetworkInformation + */ +const NetInfo = { + addEventListener(type: string, handler: Function): { remove: () => void } { + invariant(eventTypes.indexOf(type) !== -1, 'Trying to subscribe to unknown event: "%s"', type) + if (!connection) { + console.error('Network Connection API is not supported. Not listening for connection type changes.') + return { + remove: () => {} + } + } + + connection.addEventListener(type, handler) + return { + remove: () => NetInfo.removeEventListener(type, handler) + } + }, + + removeEventListener(type: string, handler: Function): void { + invariant(eventTypes.indexOf(type) !== -1, 'Trying to subscribe to unknown event: "%s"', type) + if (!connection) { return } + connection.removeEventListener(type, handler) + }, + + fetch(): Promise { + return new Promise((resolve, reject) => { + try { + resolve(connection.type) + } catch (err) { + resolve('unknown') + } + }) + }, + + isConnected: { + addEventListener(type: string, handler: Function): { remove: () => void } { + invariant(eventTypes.indexOf(type) !== -1, 'Trying to subscribe to unknown event: "%s"', type) + window.addEventListener('online', handler.bind(true), false) + window.addEventListener('offline', handler.bind(false), false) + + return { + remove: () => NetInfo.isConnected.removeEventListener(type, handler) + } + }, + + removeEventListener(type: string, handler: Function): void { + invariant(eventTypes.indexOf(type) !== -1, 'Trying to subscribe to unknown event: "%s"', type) + window.removeEventListener('online', handler.bind(true), false) + window.removeEventListener('offline', handler.bind(false), false) + }, + + fetch(): Promise { + return new Promise((resolve, reject) => { + try { + resolve(window.navigator.onLine) + } catch (err) { + resolve(true) + } + }) + } + } +} diff --git a/src/apis/PixelRatio/__tests__/index-test.js b/src/apis/PixelRatio/__tests__/index-test.js new file mode 100644 index 00000000..1b52c460 --- /dev/null +++ b/src/apis/PixelRatio/__tests__/index-test.js @@ -0,0 +1,5 @@ +/* eslint-env mocha */ + +suite('apis/PixelRatio', () => { + test.skip('NO TEST COVERAGE', () => {}) +}) diff --git a/src/apis/PixelRatio/index.js b/src/apis/PixelRatio/index.js new file mode 100644 index 00000000..07770e25 --- /dev/null +++ b/src/apis/PixelRatio/index.js @@ -0,0 +1,47 @@ +/** + * Copyright (c) 2015-present, Nicolas Gallagher. + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * @flow + */ + +import Dimensions from '../Dimensions' + +/** + * PixelRatio gives access to the device pixel density. + */ +export default class PixelRatio { + /** + * Returns the device pixel density. + */ + static get(): number { + return Dimensions.get('window').scale + } + + /** + * No equivalent for Web + */ + static getFontScale(): number { + return Dimensions.get('window').fontScale || PixelRatio.get() + } + + /** + * Converts a layout size (dp) to pixel size (px). + * Guaranteed to return an integer number. + */ + static getPixelSizeForLayoutSize(layoutSize: number): number { + return Math.round(layoutSize * PixelRatio.get()) + } + + /** + * Rounds a layout size (dp) to the nearest layout size that corresponds to + * an integer number of pixels. For example, on a device with a PixelRatio + * of 3, `PixelRatio.roundToNearestPixel(8.4) = 8.33`, which corresponds to + * exactly (8.33 * 3) = 25 pixels. + */ + static roundToNearestPixel(layoutSize: number): number { + const ratio = PixelRatio.get() + return Math.round(layoutSize * ratio) / ratio + } +} diff --git a/src/apis/Platform/index.js b/src/apis/Platform/index.js new file mode 100644 index 00000000..3daad955 --- /dev/null +++ b/src/apis/Platform/index.js @@ -0,0 +1,8 @@ +import { canUseDOM } from 'exenv' + +const Platform = { + OS: 'web', + userAgent: canUseDOM ? window.navigator.userAgent : '' +} + +export default Platform diff --git a/src/apis/UIManager/__tests__/index-test.js b/src/apis/UIManager/__tests__/index-test.js new file mode 100644 index 00000000..e12cf88f --- /dev/null +++ b/src/apis/UIManager/__tests__/index-test.js @@ -0,0 +1,100 @@ +/* eslint-env mocha */ + +import assert from 'assert' +import UIManager from '..' + +const createNode = (style = {}) => { + const root = document.createElement('div') + Object.keys(style).forEach((prop) => { + root.style[prop] = style[prop] + }) + return root +} + +let defaultBodyMargin + +suite('apis/UIManager', () => { + setup(() => { + // remove default body margin so we can predict the measured offsets + defaultBodyMargin = document.body.style.margin + document.body.style.margin = 0 + }) + + teardown(() => { + document.body.style.margin = defaultBodyMargin + }) + + suite('measure', () => { + test('provides correct layout to callback', () => { + const node = createNode({ height: '5000px', left: '100px', position: 'relative', top: '100px', width: '5000px' }) + document.body.appendChild(node) + + UIManager.measure(node, (x, y, width, height, pageX, pageY) => { + assert.equal(x, 100) + assert.equal(y, 100) + assert.equal(width, 5000) + assert.equal(height, 5000) + assert.equal(pageX, 100) + assert.equal(pageY, 100) + }) + + // test values account for scroll position + window.scrollTo(200, 200) + UIManager.measure(node, (x, y, width, height, pageX, pageY) => { + assert.equal(x, 100) + assert.equal(y, 100) + assert.equal(width, 5000) + assert.equal(height, 5000) + assert.equal(pageX, -100) + assert.equal(pageY, -100) + }) + + document.body.removeChild(node) + }) + }) + + suite('measureLayout', () => { + test('provides correct layout to onSuccess callback', () => { + const node = createNode({ height: '10px', width: '10px' }) + const middle = createNode({ padding: '20px' }) + const context = createNode({ padding: '20px' }) + middle.appendChild(node) + context.appendChild(middle) + document.body.appendChild(context) + + UIManager.measureLayout(node, context, () => {}, (x, y, width, height) => { + assert.equal(x, 40) + assert.equal(y, 40) + assert.equal(width, 10) + assert.equal(height, 10) + }) + + document.body.removeChild(context) + }) + }) + + suite('updateView', () => { + test('adds new className to existing className', () => { + const node = createNode() + node.className = 'existing' + const props = { className: 'extra' } + UIManager.updateView(node, props) + assert.equal(node.getAttribute('class'), 'existing extra') + }) + + test('adds new style to existing style', () => { + const node = createNode({ color: 'red' }) + const props = { style: { opacity: 0 } } + UIManager.updateView(node, props) + assert.equal(node.getAttribute('style'), 'color: red; opacity: 0;') + }) + + test('sets attribute values', () => { + const node = createNode() + const props = { 'aria-level': '4', 'data-of-type': 'string' } + UIManager.updateView(node, props) + assert.equal(node.getAttribute('aria-level'), '4') + assert.equal(node.getAttribute('data-of-type'), 'string') + }) + }) +}) diff --git a/src/apis/UIManager/index.js b/src/apis/UIManager/index.js new file mode 100644 index 00000000..073864ff --- /dev/null +++ b/src/apis/UIManager/index.js @@ -0,0 +1,35 @@ +import CSSPropertyOperations from 'react/lib/CSSPropertyOperations' + +const measureAll = (node, callback, relativeToNativeNode) => { + const { height, left, top, width } = node.getBoundingClientRect() + const relativeNode = relativeToNativeNode || node.parentNode + const relativeRect = relativeNode.getBoundingClientRect() + const x = left - relativeRect.left + const y = top - relativeRect.top + callback(x, y, width, height, left, top) +} + +const UIManager = { + measure(node, callback) { + measureAll(node, callback) + }, + + measureLayout(node, relativeToNativeNode, onFail, onSuccess) { + measureAll(node, (x, y, width, height) => onSuccess(x, y, width, height), relativeToNativeNode) + }, + + updateView(node, props) { + for (const prop in props) { + const value = props[prop] + if (prop === 'style') { + CSSPropertyOperations.setValueForStyles(node, value) + } else if (prop === 'className') { + node.classList.add(value) + } else { + node.setAttribute(prop, value) + } + } + } +} + +export default UIManager diff --git a/src/modules/NativeMethodsMixin/index.js b/src/modules/NativeMethodsMixin/index.js new file mode 100644 index 00000000..503cf0d8 --- /dev/null +++ b/src/modules/NativeMethodsMixin/index.js @@ -0,0 +1,100 @@ +/** + * Copyright (c) 2015-present, Nicolas Gallagher. + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * @flow + */ + +import { Component } from 'react' +import ReactDOM from 'react-dom' +import UIManager from '../../apis/UIManager' + +type MeasureOnSuccessCallback = ( + x: number, + y: number, + width: number, + height: number, + pageX: number, + pageY: number +) => void + +type MeasureLayoutOnSuccessCallback = ( + left: number, + top: number, + width: number, + height: number +) => void + +const NativeMethodsMixin = { + /** + * Removes focus from an input or view. This is the opposite of `focus()`. + */ + blur() { + ReactDOM.findDOMNode(this).blur() + }, + + /** + * Requests focus for the given input or view. + * The exact behavior triggered will depend the type of view. + */ + focus() { + ReactDOM.findDOMNode(this).focus() + }, + + /** + * Determines the position and dimensions of the view + */ + measure(callback: MeasureOnSuccessCallback) { + UIManager.measure( + ReactDOM.findDOMNode(this), + mountSafeCallback(this, callback) + ) + }, + + /** + * Measures the view relative to another view (usually an ancestor) + */ + measureLayout( + relativeToNativeNode: number, + onSuccess: MeasureLayoutOnSuccessCallback, + onFail: () => void /* currently unused */ + ) { + UIManager.measureLayout( + ReactDOM.findDOMNode(this), + relativeToNativeNode, + mountSafeCallback(this, onFail), + mountSafeCallback(this, onSuccess) + ) + }, + + /** + * This function sends props straight to the underlying DOM node. + */ + setNativeProps(nativeProps: Object) { + UIManager.updateView( + ReactDOM.findDOMNode(this), + nativeProps + ) + } +} + +/** + * In the future, we should cleanup callbacks by cancelling them instead of + * using this. + */ +const mountSafeCallback = (context: Component, callback: ?Function) => () => { + if (!callback || (context.isMounted && !context.isMounted())) { + return + } + return callback.apply(context, arguments) +} + +export default NativeMethodsMixin + +export const nativeMethodsDecorator = (Component) => { + Object.keys(NativeMethodsMixin).forEach((method) => { + Component.prototype[method] = NativeMethodsMixin[method] + }) + return Component +}