Merge pull request #59 from instea/menuoptions-improvement

Menuoptions improvement
This commit is contained in:
Stanislav Miklik 2017-12-06 17:21:29 +01:00 committed by GitHub
commit 56b773cfd4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 294 additions and 126 deletions

View File

@ -50,18 +50,22 @@ describe('Menu', () => {
);
expect(output.type).toEqual(View);
expect(output.props.children.length).toEqual(3);
expect(output.props.children[0]).toEqual(
<Text>Some text</Text>
);
// React.Children.toArray modifies components keys
// using the same function to create expected children
const expectedChildren = React.Children.toArray([
<Text>Some text</Text>,
<MenuTrigger />, // trigger will be modified
<MenuOptions />, // options will be removed
<Text>Some other text</Text>,
]);
expect(output.props.children[0]).toEqual(expectedChildren[0]);
expect(output.props.children[1]).toEqual(objectContaining({
type: MenuTrigger,
props: objectContaining({
onRef: any(Function)
})
}));
expect(output.props.children[2]).toEqual(
<Text>Some other text</Text>
);
expect(output.props.children[2]).toEqual(expectedChildren[3]);
});
it('should subscribe menu and notify context', () => {
@ -87,12 +91,16 @@ describe('Menu', () => {
expect(ctx.menuRegistry.subscribe).not.toHaveBeenCalled();
const output = renderer.getRenderOutput();
expect(output.type).toEqual(View);
expect(output.props.children).toEqual([
const expectedChildren = React.Children.toArray([
<MenuTrigger />,
<Text>Some text</Text>,
]);
expect(output.props.children[0]).toEqual(
objectContaining({
type: MenuTrigger
}),
<Text>Some text</Text>
]);
})
);
expect(output.props.children[1]).toEqual(expectedChildren[1]);
});
it('should not subscribe menu because of missing trigger', () => {
@ -106,9 +114,9 @@ describe('Menu', () => {
expect(ctx.menuRegistry.subscribe).not.toHaveBeenCalled();
const output = renderer.getRenderOutput();
expect(output.type).toEqual(View);
expect(output.props.children).toEqual([
expect(output.props.children).toEqual(React.Children.toArray(
<Text>Some text</Text>
]);
));
});
it('should not fail without any children', () => {
@ -158,7 +166,7 @@ describe('Menu', () => {
expect(ctx.menuActions._notify).toHaveBeenCalled();
});
it('should forward on select handler to menu options', () => {
it('should get menu options', () => {
const onSelect = () => 0;
const { instance } = renderMenu(
<Menu onSelect={ onSelect }>
@ -168,7 +176,6 @@ describe('Menu', () => {
);
const options = instance._getOptions();
expect(options.type).toEqual(MenuOptions);
expect(options.props.onSelect).toEqual(onSelect);
});
it('declarative opened takes precedence over imperative', () => {

View File

@ -9,11 +9,22 @@ const { createSpy, objectContaining } = jasmine;
describe('MenuOption', () => {
const makeMockContext = ({ optionsCustomStyles, onSelect, closeMenu } = {}) => ({
menuActions: {
_getOpenedMenu: () => ({
optionsCustomStyles: optionsCustomStyles || {},
instance: { props: { onSelect: onSelect } }
}),
closeMenu: closeMenu || createSpy(),
},
});
it('should render component', () => {
const { output } = render(
<MenuOption>
<Text>Option 1</Text>
</MenuOption>
</MenuOption>,
makeMockContext()
);
expect(output.type).toEqual(TouchableHighlight);
expect(nthChild(output, 1).type).toEqual(View);
@ -24,18 +35,30 @@ describe('MenuOption', () => {
it('should be enabled by default', () => {
const { instance } = render(
<MenuOption />
<MenuOption />,
makeMockContext()
);
expect(instance.props.disabled).toBe(false);
});
it('should trigger on select event with value', () => {
const spy = createSpy();
const { instance, renderer } = render(
<MenuOption onSelect={spy} value='hello' />
const { renderer } = render(
<MenuOption onSelect={spy} value='hello' />,
makeMockContext()
);
const touchable = renderer.getRenderOutput();
touchable.props.onPress();
expect(spy).toHaveBeenCalledWith('hello');
expect(spy.calls.count()).toEqual(1);
});
it('should trigger onSelect event from Menu', () => {
const spy = createSpy();
const { renderer } = render(
<MenuOption value='hello' />,
makeMockContext({ onSelect: spy })
);
const menuActions = { closeMenu: createSpy() };
instance.context = { menuActions };
const touchable = renderer.getRenderOutput();
touchable.props.onPress();
expect(spy).toHaveBeenCalledWith('hello');
@ -44,34 +67,35 @@ describe('MenuOption', () => {
it('should close menu on select', () => {
const spy = createSpy();
const { instance, renderer } = render(
<MenuOption onSelect={spy} value='hello' />
const closeMenu = createSpy();
const { renderer } = render(
<MenuOption onSelect={spy} value='hello' />,
makeMockContext({ closeMenu })
);
const menuActions = { closeMenu: createSpy() };
instance.context = { menuActions };
const touchable = renderer.getRenderOutput();
touchable.props.onPress();
expect(spy).toHaveBeenCalled();
expect(menuActions.closeMenu).toHaveBeenCalled();
expect(closeMenu).toHaveBeenCalled();
});
it('should not close menu on select', () => {
const spy = createSpy().and.returnValue(false);
const { instance, renderer } = render(
<MenuOption onSelect={spy} value='hello' />
const closeMenu = createSpy()
const { renderer } = render(
<MenuOption onSelect={spy} value='hello' />,
makeMockContext({ closeMenu })
);
const menuActions = { closeMenu: createSpy() };
instance.context = { menuActions };
const touchable = renderer.getRenderOutput();
touchable.props.onPress();
expect(spy).toHaveBeenCalled();
expect(menuActions.closeMenu).not.toHaveBeenCalled();
expect(closeMenu).not.toHaveBeenCalled();
});
it('should not trigger event when disabled', () => {
const spy = createSpy();
const { output } = render(
<MenuOption onSelect={spy} disabled={true} />
<MenuOption onSelect={spy} disabled={true} />,
makeMockContext()
);
expect(output.type).toBe(View);
expect(output.props.onPress).toBeUndefined();
@ -79,7 +103,8 @@ describe('MenuOption', () => {
it('should render text passed in props', () => {
const { output } = render(
<MenuOption text='Hello world' />
<MenuOption text='Hello world' />,
makeMockContext()
);
expect(output.type).toEqual(TouchableHighlight);
expect(output.props.children.type).toEqual(View);
@ -96,7 +121,8 @@ describe('MenuOption', () => {
optionTouchable: { underlayColor: 'green' },
};
const { output } = render(
<MenuOption text='some text' customStyles={customStyles} />
<MenuOption text='some text' customStyles={customStyles} />,
makeMockContext()
);
const touchable = output;
const view = nthChild(output, 1);
@ -109,4 +135,28 @@ describe('MenuOption', () => {
.toEqual(objectContaining(customStyles.optionText));
});
it('should render component with inherited custom styles', () => {
const optionsCustomStyles = {
optionWrapper: { backgroundColor: 'pink' },
optionText: { color: 'yellow' },
};
const customStyles = {
optionText: { color: 'blue' },
optionTouchable: { underlayColor: 'green' },
};
const { output } = render(
<MenuOption text='some text' customStyles={customStyles} />,
makeMockContext({ optionsCustomStyles })
);
const touchable = output;
const view = nthChild(output, 1);
const text = nthChild(output, 2);
expect(normalizeStyle(touchable.props))
.toEqual(objectContaining({ underlayColor: 'green' }));
expect(normalizeStyle(view.props.style))
.toEqual(objectContaining(optionsCustomStyles.optionWrapper));
expect(normalizeStyle(text.props.style))
.toEqual(objectContaining(customStyles.optionText));
})
});

View File

@ -1,29 +1,40 @@
import React from 'react';
import { View } from 'react-native';
import { render, normalizeStyle } from './helpers';
import { render } from './helpers';
import { MenuOption } from '../src/index';
jest.dontMock('../src/MenuOptions');
const MenuOptions = require('../src/MenuOptions').default;
const { objectContaining } = jasmine;
describe('MenuOptions', () => {
function mockCtx() {
return {
menuActions: {
_getOpenedMenu: () => ({
instance: { getName: () => 'menu1' }
}),
},
menuRegistry: {
setOptionsCustomStyles: jest.fn(),
},
};
}
it('should render component', () => {
const onSelect = () => 0;
const { output } = render(
<MenuOptions onSelect={onSelect}>
<MenuOptions>
<MenuOption />
<MenuOption />
<MenuOption />
</MenuOptions>
</MenuOptions>,
mockCtx()
);
expect(output.type).toEqual(View);
const children = output.props.children;
expect(children.length).toEqual(3);
children.forEach(ch => {
expect(ch.type).toBe(MenuOption);
expect(ch.props.onSelect).toEqual(onSelect);
});
});
@ -34,70 +45,45 @@ describe('MenuOptions', () => {
<MenuOption />
{option ? <MenuOption />: null}
<MenuOption />
</MenuOptions>
</MenuOptions>,
mockCtx()
);
expect(output.type).toEqual(View);
const children = output.props.children;
expect(children.length).toEqual(2);
});
it("should prioritize option's onSelect handler", () => {
const onSelect = () => 0;
const onSelectOption = () => 1;
const { output } = render(
<MenuOptions onSelect={onSelect}>
<MenuOption onSelect={onSelectOption} />
<MenuOption />
</MenuOptions>
);
expect(output.type).toEqual(View);
const children = output.props.children;
expect(children.length).toEqual(2);
expect(children[0].type).toBe(MenuOption);
expect(children[1].type).toBe(MenuOption);
expect(children[0].props.onSelect).toEqual(onSelectOption);
expect(children[1].props.onSelect).toEqual(onSelect);
expect(children.length).toEqual(3);
});
it('should work with user defined options', () => {
const UserOption = (props) => <MenuOption {...props} text='user-defined' />;
const onSelect = () => 0;
const { output } = render(
<MenuOptions onSelect={onSelect}>
<MenuOptions>
<UserOption />
</MenuOptions>
</MenuOptions>,
mockCtx()
);
expect(output.type).toEqual(View);
const children = output.props.children;
expect(children.length).toEqual(1);
const ch = children[0];
expect(ch.type).toBe(UserOption);
expect(ch.props.onSelect).toEqual(onSelect);
expect(children.type).toBe(UserOption);
});
it('should render options with custom styles', () => {
const onSelect = () => 0;
it('should register custom styles', () => {
const customStyles = {
optionsWrapper: { backgroundColor: 'red' },
optionText: { color: 'blue' },
};
const customOptionStyles = {
optionText: { color: 'pink' },
const customStyles2 = {
optionsWrapper: { backgroundColor: 'blue' },
};
const { output } = render(
<MenuOptions onSelect={onSelect} customStyles={customStyles}>
<MenuOption />
<MenuOption customStyles={customOptionStyles} />
<MenuOption />
</MenuOptions>
const ctx = mockCtx();
const { instance } = render(
<MenuOptions customStyles={customStyles} />,
ctx
);
expect(normalizeStyle(output.props.style))
.toEqual(objectContaining(customStyles.optionsWrapper));
const options = output.props.children;
expect(options[0].props.customStyles).toEqual(customStyles);
expect(options[1].props.customStyles).toEqual(customOptionStyles);
expect(options[2].props.customStyles).toEqual(customStyles);
expect(ctx.menuRegistry.setOptionsCustomStyles)
.toHaveBeenLastCalledWith('menu1', customStyles)
instance.componentWillReceiveProps({ customStyles: customStyles2 })
expect(ctx.menuRegistry.setOptionsCustomStyles)
.toHaveBeenLastCalledWith('menu1', customStyles2)
});
});

View File

@ -148,6 +148,8 @@ To style `<MenuOptions />` and it's `<MenuOption />` components you can pass `cu
**Note:** `optionWrapper`, `optionTouchable` and `optionText` styles of particular menu option can be overriden by `customStyles` prop of `<MenuOption />` component.
**Note:** In order to change `customStyles` dynamically, it is required that no child of `MenuOptions` stops the update (e.g. `shouldComponentUpdate` returning `false`).
**Note:** `Style` type is any valid RN style parameter.
See more in custom [styling example](../examples/StylingExample.js) and [touchable example](../examples/TouchableExample.js).

View File

@ -80,11 +80,24 @@ Another nice use case is to have menu options with icons.
</MenuOptions>
...
const CheckedOption = (props) => (
<MenuOption {...props} text={(props.checked ? '\u2713 ' : '') + props.text} />
<MenuOption value={props.value} text={(props.checked ? '\u2713 ' : '') + props.text} />
)
```
It is important to pass all (other) props to underlaying `MenuOption`.
For more details see [extensions](extensions.md) documentation.
## Menu within scroll view
If you want to display menu options in scroll view, simply wrap all menu options with `<ScrollView />` component. For example:
```js
<MenuOptions>
<ScrollView style={{ maxHeight: 200 }}>
<MenuOption value={1} text='One' />
<MenuOption value={2} text='Two' />
...
</ScrollView>
</MenuOptions>
```
You can also check our [FlatListExample](../examples/FlatListExample.js).
## Styled menu
[StylingExample](../examples/StylingExample.js):

View File

@ -6,14 +6,32 @@
Simplest example that adds checkmark symbol (unicode 2713).
```
const CheckedOption = (props) => (
<MenuOption {...props} text={'\u2713 ' + props.text} />
<MenuOption value={props.value} text={'\u2713 ' + props.text} />
)
```
**Note:** It is important that you pass all properties to underlying `MenuOption`. We internally pass `onSelect` handler to all menu options so that we can react to user actions. Although for now it might suffice to pass only `onSelect` in addition to other standard props, we highly recommend to pass any properties (as in example) in order to stay compatible with any further versions of the library.
**Note:** `MenuOption` can be placed anywhere inside of `MenuOptions` container. For example it can be rendered using `FlatList`.
## MenuOptions - renderOptionsContainer
You can control rendering of `<MenuOptions />` component by passing rendering function into `renderOptionsContainer` property. It takes `<MenuOptions />` component as argument and it have to return react component. For example if you want to wrap options with custom component and add some text above options:
## MenuOptions
`<MenuOption />` components are not required to be direct children of `<MenuOptions />`. You can pass any children to `<MenuOptions />` component. For example if you want to wrap options with custom component and add some text above options:
```
const menu = (props) => (
<Menu>
<MenuTrigger />
<MenuOptions>
<SomeCustomContainer>
<Text>Some text</Text>
<MenuOption value={1} text="value 1" />
<MenuOption value={2} text="value 2" />
</SomeCustomContainer>
</MenuOptions>
</Menu>
);
```
#### Using `renderOptionsContainer` prop (DEPRECATED)
You can also control rendering of `<MenuOptions />` component by passing rendering function into `renderOptionsContainer` property. It takes `<MenuOptions />` component as argument and it have to return react component.
```
const optionsRenderer = (options) => (
@ -25,10 +43,14 @@ const optionsRenderer = (options) => (
const menu = (props) => (
<Menu>
<MenuTrigger />
<MenuOptions renderOptionsContainer={optionsRenderer} />
<MenuOptions renderOptionsContainer={optionsRenderer}>
<MenuOption value={1} text="value 1" />
<MenuOption value={2} text="value 2" />
</MenuOptions>
</Menu>
);
```
**Note:** It is highly recommended to use first approach to extend menu options. `renderOptionsContainer` property might be removed in the future versions of the library.
## Custom renderer
It is possible to use different renderer to display menu. There are already few predefined renderers: e.g. `ContextMenu` and `SlideInMenu` (from the `renderers` module). To use it you need to pass it to the `<Menu />` props or use `setDefaultRenderer` (see [API](api.md#static-functions)):

View File

@ -14,6 +14,7 @@ import NavigatorExample from './NavigatorExample';
import TouchableExample from './TouchableExample';
import MenuMethodsExample from './MenuMethodsExample';
import CloseOnBackExample from './CloseOnBackExample';
import FlatListExample from './FlatListExample';
const demos = [
{ Component: BasicExample, name: 'Basic example' },
@ -28,6 +29,7 @@ const demos = [
{ Component: NonRootExample, name: 'Non root example' },
{ Component: NavigatorExample, name: 'Example with react-native-router-flux' },
{ Component: CloseOnBackExample, name: 'Close on back button press example' },
{ Component: FlatListExample, name: 'Using FlatList' },
];
// show debug messages for demos.

View File

@ -10,11 +10,14 @@ import Menu, {
import Icon from 'react-native-vector-icons/FontAwesome';
const CheckedOption = (props) => (
<MenuOption {...props} text={(props.checked ? '\u2713 ' : '') + props.text} />
<MenuOption
value={props.value}
text={(props.checked ? '\u2713 ' : '') + props.text}
/>
)
const IconOption = ({iconName, text, ...others}) => (
<MenuOption {...others} >
const IconOption = ({iconName, text, value}) => (
<MenuOption value={value}>
<Text>
<Icon name={iconName} />
{' ' + text}

View File

@ -0,0 +1,43 @@
import React, { Component } from 'react';
import { FlatList, Alert, StyleSheet } from 'react-native';
import {
MenuContext,
Menu,
MenuTrigger,
MenuOptions,
MenuOption,
} from 'react-native-popup-menu';
Menu.debug = true;
const data = new Array(500)
.fill(0)
.map((a, i) => ({ key: i, value: 'item' + i }));
export default class App extends Component {
render() {
return (
<MenuContext style={styles.container}>
<Menu onSelect={value => Alert.alert(value)}>
<MenuTrigger text="Select option" />
<MenuOptions>
<FlatList
data={data}
renderItem={({ item }) => (
<MenuOption value={item.value} text={item.value} />
)}
style={{ height: 200 }}
/>
</MenuOptions>
</Menu>
</MenuContext>
);
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
paddingTop: 20,
},
});

View File

@ -10,13 +10,6 @@ const isRegularComponent = c => c.type !== MenuOptions && c.type !== MenuTrigger
const isTrigger = c => c.type === MenuTrigger;
const isMenuOptions = c => c.type === MenuOptions;
const childrenToArray = children => {
if (children) {
return Array.isArray(children) ? children : [ children ];
}
return [];
};
export default class Menu extends Component {
constructor(props, ctx) {
@ -53,6 +46,12 @@ export default class Menu extends Component {
this.context.menuRegistry.unsubscribe(this);
}
componentWillReceiveProps(nextProps) {
if (this.props.name !== nextProps.name) {
console.warn('Menu name cannot be changed');
}
}
open() {
this.context.menuActions.openMenu(this._name);
}
@ -76,7 +75,7 @@ export default class Menu extends Component {
}
_reduceChildren() {
return childrenToArray(this.props.children).reduce((r, child) => {
return React.Children.toArray(this.props.children).reduce((r, child) => {
if (isTrigger(child)) {
r.push(React.cloneElement(child, {
key: null,
@ -103,9 +102,7 @@ export default class Menu extends Component {
}
_getOptions() {
const { children, onSelect } = this.props;
const optionsElem = childrenToArray(children).find(isMenuOptions);
return React.cloneElement(optionsElem, { onSelect });
return React.Children.toArray(this.props.children).find(isMenuOptions);
}
_getOpened() {
@ -117,7 +114,7 @@ export default class Menu extends Component {
}
_validateChildren() {
const children = childrenToArray(this.props.children);
const children = React.Children.toArray(this.props.children);
const options = children.find(isMenuOptions);
if (!options) {
console.warn('Menu has to contain MenuOptions component');

View File

@ -27,7 +27,8 @@ export default class MenuContext extends Component {
closeMenu: () => this.closeMenu(),
toggleMenu: name => this.toggleMenu(name),
isMenuOpen: () => this.isMenuOpen(),
_notify: force => this._notify(force)
_getOpenedMenu: () => this._getOpenedMenu(),
_notify: force => this._notify(force),
};
const menuRegistry = this._menuRegistry;
return { menuRegistry, menuActions };
@ -155,7 +156,7 @@ export default class MenuContext extends Component {
debug('setState ignored - maybe the context was unmounted')
return
}
this._placeholderRef.setState({ openedMenu: this.openedMenu }, afterSetState);
this._placeholderRef.setState({ openedMenuName: this.openedMenu && this.openedMenu.name }, afterSetState);
debug('notify ended');
});
}
@ -207,7 +208,8 @@ export default class MenuContext extends Component {
_onPlaceholderRef = r => this._placeholderRef = r;
_getOpenedMenu() {
return this._placeholderRef && this._placeholderRef.state.openedMenu
const name = this._placeholderRef && this._placeholderRef.state.openedMenuName;
return name ? this._menuRegistry.getMenu(name) : undefined;
}
_onBackdropPress = () => {

View File

@ -7,7 +7,8 @@ import { makeTouchable } from './helpers';
export default class MenuOption extends Component {
_onSelect() {
const { value, onSelect } = this.props;
const { value } = this.props;
const onSelect = this.props.onSelect || this._getMenusOnSelect()
const shouldClose = onSelect(value) !== false;
debug('select option', value, shouldClose);
if (shouldClose) {
@ -15,8 +16,22 @@ export default class MenuOption extends Component {
}
}
_getMenusOnSelect() {
const menu = this.context.menuActions._getOpenedMenu();
return menu.instance.props.onSelect;
}
_getCustomStyles() {
const { optionsCustomStyles } = this.context.menuActions._getOpenedMenu();
return {
...optionsCustomStyles,
...this.props.customStyles,
}
}
render() {
const { text, disabled, disableTouchable, children, style, customStyles } = this.props;
const { text, disabled, disableTouchable, children, style } = this.props;
const customStyles = this._getCustomStyles()
if (text && React.Children.count(children) > 0) {
console.warn("MenuOption: Please don't use text property together with explicit children. Children are ignored.");
}

View File

@ -2,22 +2,34 @@ import React from 'react';
import PropTypes from 'prop-types';
import { View } from 'react-native';
const MenuOptions = ({ style, children, onSelect, customStyles }) => (
<View style={[customStyles.optionsWrapper, style]}>
{
React.Children.map(children, c =>
React.isValidElement(c) ?
React.cloneElement(c, {
onSelect: c.props.onSelect || onSelect,
customStyles: Object.keys(c.props.customStyles || {}).length ? c.props.customStyles : customStyles
}) : c
)
}
</View>
);
class MenuOptions extends React.Component {
updateCustomStyles(_props) {
const { customStyles } = _props
const menu = this.context.menuActions._getOpenedMenu()
const menuName = menu.instance.getName()
this.context.menuRegistry.setOptionsCustomStyles(menuName, customStyles)
}
componentWillReceiveProps(nextProps) {
this.updateCustomStyles(nextProps)
}
componentWillMount() {
this.updateCustomStyles(this.props)
}
render() {
const { customStyles, style, children } = this.props
return (
<View style={[customStyles.optionsWrapper, style]}>
{children}
</View>
)
}
}
MenuOptions.propTypes = {
onSelect: PropTypes.func,
customStyles: PropTypes.object,
renderOptionsContainer: PropTypes.func,
optionsContainerStyle: PropTypes.oneOfType([
@ -31,4 +43,9 @@ MenuOptions.defaultProps = {
customStyles: {},
};
MenuOptions.contextTypes = {
menuRegistry: PropTypes.object,
menuActions: PropTypes.object,
};
export default MenuOptions;

View File

@ -7,6 +7,7 @@ import { iterator2array } from './helpers';
* instance: react instance
* triggerLayout: Object - layout of menu trigger if known
* optionsLayout: Object - layout of menu options if known
* optionsCustomStyles: Object - custom styles of options
* }
*/
export default function makeMenuRegistry(menus = new Map()) {
@ -46,6 +47,14 @@ export default function makeMenuRegistry(menus = new Map()) {
menus.set(name, menu);
}
function setOptionsCustomStyles(name, optionsCustomStyles) {
if (!menus.has(name)) {
return;
}
const menu = { ...menus.get(name), optionsCustomStyles };
menus.set(name, menu);
}
/**
* Get `menu data` by name.
*/
@ -60,5 +69,5 @@ export default function makeMenuRegistry(menus = new Map()) {
return iterator2array(menus.values());
}
return { subscribe, unsubscribe, updateLayoutInfo, getMenu, getAll };
return { subscribe, unsubscribe, updateLayoutInfo, getMenu, getAll, setOptionsCustomStyles };
}