From 205eb6d69f5853abeb8b0ee20693c27f2d06c8f9 Mon Sep 17 00:00:00 2001 From: Artur Yorsh Date: Fri, 28 Feb 2020 14:59:07 +0300 Subject: [PATCH] BREAKING: refactoe Menu to new api --- src/components/ui/menu/menu.component.tsx | 110 ++----- src/components/ui/menu/menu.service.ts | 37 --- src/components/ui/menu/menu.spec.tsx | 311 ++++++------------ src/components/ui/menu/menuItem.component.tsx | 241 +++++++++----- src/components/ui/menu/subMenu.component.tsx | 246 -------------- 5 files changed, 298 insertions(+), 647 deletions(-) delete mode 100644 src/components/ui/menu/menu.service.ts delete mode 100644 src/components/ui/menu/subMenu.component.tsx diff --git a/src/components/ui/menu/menu.component.tsx b/src/components/ui/menu/menu.component.tsx index e47ef7b5b..fee94bcf0 100644 --- a/src/components/ui/menu/menu.component.tsx +++ b/src/components/ui/menu/menu.component.tsx @@ -5,40 +5,39 @@ */ import React from 'react'; -import { - ListRenderItemInfo, - GestureResponderEvent, -} from 'react-native'; +import { ListRenderItemInfo } from 'react-native'; +import { Overwrite } from 'utility-types'; +import { ChildrenWithProps } from '../../devsupport'; import { styled, StyledComponentProps, -} from '@kitten/theme'; +} from '../../theme'; +import { Divider } from '../divider/divider.component'; import { List, + ListElement, ListProps, } from '../list/list.component'; import { - Divider, - DividerElement, -} from '../divider/divider.component'; -import { - MenuItem, - MenuItemType, MenuItemElement, MenuItemProps, } from './menuItem.component'; -import { SubMenu } from './subMenu.component'; -import { MenuService } from './menu.service'; -export interface MenuProps extends StyledComponentProps, Omit { - selectedIndex?: number; - onSelect: (index: number, event?: GestureResponderEvent) => void; +type MenuStyledProps = Overwrite; + +type MenuListProps = Omit; + +export interface MenuProps extends MenuListProps, MenuStyledProps { + children?: ChildrenWithProps; } export type MenuElement = React.ReactElement; /** - * `Menu` renders vertical list of `MenuItems`. + * Styled `Menu` component. + * Renders UI Kitten List component with additional styles provided by Eva. * * @extends React.Component * @@ -46,14 +45,10 @@ export type MenuElement = React.ReactElement; * Can be `default` or `noDivider`. * Default is `default`. * - * @property {MenuItemType[]} data - Determines menu items. - * - * @property {number} selectedIndex - The index of selected item. - * - * @property {(index: number, event?: GestureResponderEvent) => void} onSelect - Fires when - * selected item is changed. + * @property {ReactElement | ReactElement[]} children - Determines items of the Menu. * - * @property {Omit} ...ListProps - Any props applied to List component, excluding `renderItem`. + * @property {ListProps} ...ListProps - Any props applied to List component, + * excluding `renderItem` and `data`. * * @overview-example MenuSimpleUsage * @@ -71,66 +66,27 @@ class MenuComponent extends React.Component { static styledComponentName: string = 'Menu'; - private service: MenuService = new MenuService(); - - private onSelect = (selectedIndex: number, event: GestureResponderEvent): void => { - if (this.props.onSelect) { - this.props.onSelect(selectedIndex, event); - } - }; - - private isDividerAbsent = (): boolean => { - const { appearance } = this.props; - - return appearance !== 'noDivider'; - }; - - private areThereSubItems = (item: MenuItemProps): boolean => { - return item.subItems && item.subItems.length !== 0; - }; - - private getIsSelected = (item: MenuItemType): boolean => { - const { selectedIndex } = this.props; - - return selectedIndex === item.menuIndex; - }; - - private renderMenuItem = (info: ListRenderItemInfo): MenuItemElement => { - const { selectedIndex } = this.props; - const isSelected: boolean = this.getIsSelected(info.item); + private get data(): any[] { + return React.Children.toArray(this.props.children || []); + } - return this.areThereSubItems(info.item) ? ( - - ) : ( - - ); - }; + private get shouldRenderDividers(): boolean { + return this.props.appearance !== 'noDivider'; + } - private renderDivider = (): DividerElement => { - return this.isDividerAbsent() && ( - - ); + private renderItem = (info: ListRenderItemInfo): MenuItemElement => { + return info.item; }; - public render(): React.ReactNode { - const { appearance, data, ...restProps } = this.props; - const items: MenuItemType[] = this.service.setIndexes(data); + public render(): ListElement { + const { appearance, ...listProps } = this.props; return ( ); } diff --git a/src/components/ui/menu/menu.service.ts b/src/components/ui/menu/menu.service.ts deleted file mode 100644 index 7f2156562..000000000 --- a/src/components/ui/menu/menu.service.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * @license - * Copyright Akveo. All Rights Reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - */ - -import { MenuItemType } from './menuItem.component'; - -/** - * Support service for the menu component. Can be expanded. - */ - -export class MenuService { - - /** - * Makes custom indexes for the MenuItems array for proper handling group items. - * - * @param {ReadonlyArray} data - * @returns {MenuItemType[]} pack by name - */ - public setIndexes(data: ReadonlyArray): MenuItemType[] { - let tempIndex: number = 0; - return data.map((item: MenuItemType) => { - if (!item.subItems || item.subItems.length === 0) { - item.menuIndex = tempIndex; - tempIndex = tempIndex + 1; - } else { - item.subItems = item.subItems.map((sub: MenuItemType) => { - sub.menuIndex = tempIndex; - tempIndex = tempIndex + 1; - return sub; - }); - } - return item; - }); - } -} diff --git a/src/components/ui/menu/menu.spec.tsx b/src/components/ui/menu/menu.spec.tsx index 3d88451be..d38cb18d2 100644 --- a/src/components/ui/menu/menu.spec.tsx +++ b/src/components/ui/menu/menu.spec.tsx @@ -1,264 +1,169 @@ +/** + * @license + * Copyright Akveo. All Rights Reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + import React from 'react'; import { Image, ImageProps, + Text, + TouchableOpacity, } from 'react-native'; import { - render, + light, + mapping, +} from '@eva-design/eva'; +import { fireEvent, - RenderAPI, + render, } from 'react-native-testing-library'; -import { - ApplicationProvider, - StyleType, -} from '@kitten/theme'; +import { ApplicationProvider } from '../../theme'; import { Menu } from './menu.component'; import { - MenuItemType, MenuItem, + MenuItemProps, } from './menuItem.component'; -import { MenuService } from './menu.service'; -import { - mapping, - theme, -} from '../support/tests'; jest.useFakeTimers(); -const Icon = (style: StyleType): React.ReactElement => ( - -); - -const data: MenuItemType[] = [ - { title: 'Option 1', icon: Icon }, - { title: 'Option 2', disabled: true }, - { - title: 'Option 3', - subItems: [ - { title: 'Option 31', disabled: true }, - { title: 'Option 32' }, - { title: 'Option 33' }, - ], - }, - { title: 'Option 4', icon: Icon }, - { title: 'Option 5' }, - { title: 'Option 6' }, - { title: 'Option 8' }, - { title: 'Option 9' }, -]; - -interface State { - selectedIndex: number; -} - -class TestApplication extends React.Component { - - public state: State = { - selectedIndex: null, - }; - - private onSelect = (selectedIndex: number): void => { - this.setState({ selectedIndex }); - }; - - public render(): React.ReactNode { - return ( - - - - ); - } -} +describe('@menu-item: component checks', () => { -describe('@ menu component checks', () => { + const TestMenuItem = (props?: MenuItemProps) => ( + + + + ); - it('* menu item onPress prop checks', () => { - const onPress = jest.fn(); - const title: string = 'Option'; - - const menuItem: RenderAPI = render( - - - , + it('should render text passed to title prop', () => { + const component = render( + , ); - fireEvent(menuItem.getAllByText(title)[0], 'press'); + const title = component.getByText('I love Babel'); - expect(onPress).toHaveBeenCalled(); + expect(title).toBeTruthy(); }); - it('* menu item onPressIn prop checks', () => { - const onPressIn = jest.fn(); - const title: string = 'Option'; - - const menuItem: RenderAPI = render( - - - , + it('should render component passed to title prop', () => { + const component = render( + Title as Component}/>, ); - fireEvent(menuItem.getAllByText(title)[0], 'pressIn'); + const titleAsComponent = component.getByText('Title as Component'); - expect(onPressIn).toHaveBeenCalled(); + expect(titleAsComponent).toBeTruthy(); }); - it('* menu item onPressOut prop checks', () => { - const onPressOut = jest.fn(); - const title: string = 'Option'; - - const menuItem: RenderAPI = render( - - - , - ); - fireEvent(menuItem.getAllByText(title)[0], 'pressOut'); + it('should render components passed to accessoryLeft or accessoryRight props', () => { + const AccessoryLeft = (props): React.ReactElement => ( + + ); - expect(onPressOut).toHaveBeenCalled(); - }); + const AccessoryRight = (props): React.ReactElement => ( + + ); - it('* menu item onLongPress prop checks', () => { - const onLongPress = jest.fn(); - const title: string = 'Option'; - - const menuItem: RenderAPI = render( - - - , + const component = render( + , ); - fireEvent(menuItem.getAllByText(title)[0], 'press'); - fireEvent(menuItem.getAllByText(title)[0], 'pressIn'); - fireEvent(menuItem.getAllByText(title)[0], 'pressOut'); - fireEvent(menuItem.getAllByText(title)[0], 'longPress'); + const [accessoryLeft, accessoryRight] = component.getAllByType(Image); - expect(onLongPress).toHaveBeenCalled(); + expect(accessoryLeft).toBeTruthy(); + expect(accessoryRight).toBeTruthy(); + + expect(accessoryLeft.props.source.uri).toEqual('https://akveo.github.io/eva-icons/fill/png/128/star.png'); + expect(accessoryRight.props.source.uri).toEqual('https://akveo.github.io/eva-icons/fill/png/128/home.png'); }); - it('* menu onSelect works properly', () => { - const application: RenderAPI = render( - , + it('should render 2 menu items passed to children', () => { + const component = render( + + + + , ); - fireEvent.press(application.getAllByText('Option 1')[0]); - - const { selectedIndex } = application.getByType(Menu).props; + const nestedItem1 = component.getByText('Nested Item 1'); + const nestedItem2 = component.getByText('Nested Item 2'); - expect(selectedIndex).toBe(0); + expect(nestedItem1).toBeTruthy(); + expect(nestedItem2).toBeTruthy(); }); - it('* menu-item text renders properly', () => { - const item: MenuItemType = { title: 'Option 1' }; - const application: RenderAPI = render( - , + it('should call onPress', () => { + const onPress = jest.fn(); + + const component = render( + , ); - const { children } = application.getAllByText(item.title)[0].props; + fireEvent.press(component.getByType(TouchableOpacity)); - expect(children).toBe(item.title); + expect(onPress).toHaveBeenCalled(); }); - it('* menu-item icon renders properly', () => { - const expectedUri: string = 'https://akveo.github.io/eva-icons/fill/png/128/star.png'; - const application: RenderAPI = render( - , + it('should call onPressIn', () => { + const onPressIn = jest.fn(); + + const component = render( + , ); - const { source } = application.getAllByType(Image)[0].props; + fireEvent(component.getByType(TouchableOpacity), 'pressIn'); - expect(source.uri).toBe(expectedUri); + expect(onPressIn).toBeCalled(); }); - it('* group menu works properly', () => { - const expectedSelectedItem: MenuItemType = { title: 'Option 32' }; - const application: RenderAPI = render( - , + it('should call onPressOut', () => { + const onPressOut = jest.fn(); + + const component = render( + , ); - fireEvent.press(application.getAllByText('Option 3')[0]); - const { selectedIndex: selectedIndex1 } = application.getByType(Menu).props; - expect(selectedIndex1).toBeNull(); + fireEvent(component.getByType(TouchableOpacity), 'pressOut'); - fireEvent.press(application.getAllByText('Option 32')[0]); - const { selectedIndex: selectedIndex2 } = application.getByType(Menu).props; - expect(selectedIndex2).toBe(3); + expect(onPressOut).toBeCalled(); }); }); -describe('@ menu-service checks', () => { - - const stringify = (obj: any): string => JSON.stringify(obj); - - const menuData: MenuItemType[] = [ - { title: 'Item 1' }, - { - title: 'Item 2', - subItems: [ - { title: 'Item 21' }, - { title: 'Item 22' }, - { title: 'Item 23' }, - ], - }, - { title: 'Item 3' }, - ]; - - it('* setIndexes method', () => { - const expectedMenuItems: MenuItemType[] = [ - { - title: 'Item 1', - menuIndex: 0, - }, - { - title: 'Item 2', - subItems: [ - { - title: 'Item 21', - menuIndex: 1, - }, - { - title: 'Item 22', - menuIndex: 2, - }, - { - title: 'Item 23', - menuIndex: 3, - }, - ], - }, - { - title: 'Item 3', - menuIndex: 4, - }, - ]; - const service: MenuService = new MenuService(); - const result: MenuItemType[] = service.setIndexes(menuData); - - expect(stringify(result)).toBe(stringify(expectedMenuItems)); - }); +describe('@menu: component checks', () => { + + const TestMenu = () => ( + + + + + + + ); + + it('should render two menu items passed to children', () => { + const component = render( + , + ); + const items = component.getAllByType(MenuItem); + expect(items.length).toEqual(2); + }); }); - diff --git a/src/components/ui/menu/menuItem.component.tsx b/src/components/ui/menu/menuItem.component.tsx index 641e86c62..97f5f5cdb 100644 --- a/src/components/ui/menu/menuItem.component.tsx +++ b/src/components/ui/menu/menuItem.component.tsx @@ -6,73 +6,114 @@ import React from 'react'; import { + Animated, GestureResponderEvent, + ImageProps, Platform, - StyleProp, StyleSheet, - TextStyle, - TouchableOpacity, + TouchableOpacityProps, View, + ViewProps, + ViewStyle, } from 'react-native'; +import { + ChildrenWithProps, + FalsyFC, + FalsyText, + Frame, + MeasureElement, + MeasuringElement, + PropsService, + RenderProp, + TouchableWithoutFeedback, + WebEventResponder, + WebEventResponderCallbacks, + WebEventResponderInstance, +} from '../../devsupport'; import { Interaction, styled, StyledComponentProps, StyleType, -} from '@kitten/theme'; -import { - Text, - TextElement, -} from '../text/text.component'; -import { IconElement } from '../icon/icon.component'; -import { TouchableIndexedProps } from '../support/typings/type'; -import { - allWithPrefix, - WebEventResponder, - WebEventResponderCallbacks, - WebEventResponderInstance, -} from '../support/services'; - -export interface MenuItemType { - title: string; - disabled?: boolean; - subItems?: MenuItemType[]; - titleStyle?: StyleProp; - menuIndex?: number; - icon?: (style: StyleType) => IconElement; - accessory?: (style: StyleType) => React.ReactElement; -} - -export interface MenuItemProps extends StyledComponentProps, TouchableIndexedProps, MenuItemType { +} from '../../theme'; +import { TextProps } from '../text/text.component'; +import { ChevronDown } from '../shared/chevronDown.component'; + +export interface MenuItemProps extends TouchableOpacityProps, StyledComponentProps { + title?: RenderProp | React.ReactText; + accessoryLeft?: RenderProp>; + accessoryRight?: RenderProp>; selected?: boolean; + children?: ChildrenWithProps; } export type MenuItemElement = React.ReactElement; +interface State { + submenuHeight: number; +} + /** - * `MenuItem` is a support component for `Menu`. + * `MenuItem` component is a part of the `Menu`. + * Menu items should be passed to in Menu as children to provide a usable component. * * @extends React.Component * - * @property {string} title - Determines the title of the ListItem. + * @property {string | (props: TextProps) => ReactElement} title - A string or a function component + * to render within the button. + * If it is a function, it will be called with props provided by Eva. + * Otherwise, renders a Text styled by Eva. * - * @property {StyleProp} titleStyle - Customizes title style. + * @property {(props: ImageProps) => ReactElement} accessoryLeft - A function component + * to render to start of the `title`. + * Called with props provided by Eva. * - * @property {(style: StyleType) => ReactElement} accessory - Determines the accessory of the component. + * @property {(props: ImageProps) => ReactElement} accessoryRight - A function component + * to render to end of the `title`. + * Called with props provided by Eva. * - * @property {(style: ImageStyle) => ReactElement} icon - Determines the icon of the component. + * @property {boolean} selected * - * @property {MenuItemType[]} subItems - Determines the sub-items of the MenuItem. - * - * @property {(index: number, event: GestureResponderEvent) => void} onPress - Emits when component is pressed. + * @property {ReactElement | ReactElement[]} children - One or several components to render as `submenu`. * * @property {TouchableOpacityProps} ...TouchableOpacityProps - Any props applied to TouchableOpacity component. */ -class MenuItemComponent extends React.Component implements WebEventResponderCallbacks { +class MenuItemComponent extends React.Component implements WebEventResponderCallbacks { static styledComponentName: string = 'MenuItem'; private webEventResponder: WebEventResponderInstance = WebEventResponder.create(this); + private expandAnimation: Animated.Value = new Animated.Value(0); + + public state: State = { + submenuHeight: 1, + }; + + private get hasSubmenu(): boolean { + return React.Children.count(this.props.children) > 0; + } + + private get shouldMeasureSubmenu(): boolean { + return this.state.submenuHeight === 1; + } + + private get expandAnimationValue(): number { + // @ts-ignore - private api, but let's us avoid creating animation listeners. + // `this.expandAnimation.addListener` + return this.expandAnimation._value; + } + + private get expandToRotateInterpolation(): Animated.AnimatedInterpolation { + return this.expandAnimation.interpolate({ + inputRange: [-this.state.submenuHeight, 0], + outputRange: ['-180deg', '0deg'], + }); + } + + private get submenuStyle(): ViewStyle { + // @ts-ignore - issue of `@types/react-native` package + return this.shouldMeasureSubmenu ? styles.outscreen : { height: this.expandAnimation }; + } public onMouseEnter = (): void => { this.props.eva.dispatch([Interaction.HOVER]); @@ -91,8 +132,14 @@ class MenuItemComponent extends React.Component implements WebEve }; private onPress = (event: GestureResponderEvent): void => { + if (this.hasSubmenu) { + const expandValue: number = this.expandAnimationValue > 0 ? 0 : this.state.submenuHeight; + this.createExpandAnimation(expandValue).start(); + return; + } + if (this.props.onPress) { - this.props.onPress(this.props.menuIndex, event); + this.props.onPress(event); } }; @@ -100,7 +147,7 @@ class MenuItemComponent extends React.Component implements WebEve this.props.eva.dispatch([Interaction.ACTIVE]); if (this.props.onPressIn) { - this.props.onPressIn(this.props.menuIndex, event); + this.props.onPressIn(event); } }; @@ -108,22 +155,20 @@ class MenuItemComponent extends React.Component implements WebEve this.props.eva.dispatch([]); if (this.props.onPressOut) { - this.props.onPressOut(this.props.menuIndex, event); + this.props.onPressOut(event); } }; - private onLongPress = (event: GestureResponderEvent): void => { - if (this.props.onLongPress) { - this.props.onLongPress(this.props.menuIndex, event); - } + private onSubmenuMeasure = (frame: Frame): void => { + this.setState({ submenuHeight: frame.size.height }); }; - private getComponentStyles = (style: StyleType): StyleType => { + private getComponentStyle = (style: StyleType) => { const { paddingHorizontal, paddingVertical, backgroundColor } = style; - const titleStyles: StyleType = allWithPrefix(style, 'title'); - const indicatorStyles: StyleType = allWithPrefix(style, 'indicator'); - const iconStyles: StyleType = allWithPrefix(style, 'icon'); + const titleStyles: StyleType = PropsService.allWithPrefix(style, 'title'); + const indicatorStyles: StyleType = PropsService.allWithPrefix(style, 'indicator'); + const iconStyles: StyleType = PropsService.allWithPrefix(style, 'icon'); return { container: { @@ -152,58 +197,81 @@ class MenuItemComponent extends React.Component implements WebEve }; }; - private renderIcon = (style: StyleType): IconElement => { - const iconElement: IconElement = this.props.icon(style); - - return React.cloneElement(iconElement, { - style: [style, iconElement.props.style], + private createExpandAnimation = (toValue: number): Animated.CompositeAnimation => { + return Animated.timing(this.expandAnimation, { + toValue: toValue, + duration: 200, }); }; - private renderTitle = (style: StyleType): TextElement => { - const { title, titleStyle } = this.props; + private renderSubmenuIconIfNeeded = (evaStyle): React.ReactElement => { + if (!this.hasSubmenu) { + return null; + } + + return ( + + + + ); + }; + private renderSubmenu = (evaStyle): React.ReactElement => { return ( - {title} + + {this.props.children} + ); }; - private renderAccessory = (style: StyleType): IconElement => { - return this.props.accessory(style); + private renderMeasuringSubmenu = (evaStyle): MeasuringElement => { + return ( + + {this.renderSubmenu(evaStyle)} + + ); }; - private renderComponentChildren = (style: StyleType): React.ReactNodeArray => { - const { title, icon, accessory } = this.props; + private renderSubmenuIfNeeded = (evaStyle): React.ReactNode => { + if (!this.hasSubmenu) { + return null; + } - return [ - icon && this.renderIcon(style.icon), - title && this.renderTitle(style.title), - accessory && this.renderAccessory(style.icon), - ]; + return this.shouldMeasureSubmenu ? this.renderMeasuringSubmenu(evaStyle) : this.renderSubmenu(evaStyle); }; public render(): React.ReactNode { - const { eva, style, ...restProps } = this.props; - const { container, indicator, ...restStyles } = this.getComponentStyles(eva.style); - const [iconElement, textElement, accessoryElement] = this.renderComponentChildren(restStyles); + const { eva, style, title, accessoryLeft, accessoryRight, children, ...touchableProps } = this.props; + const evaStyle = this.getComponentStyle(eva.style); return ( - - - - {iconElement} - {textElement} - - {accessoryElement} - + + + + + + + + + + {this.renderSubmenuIfNeeded({})} + ); } } @@ -222,6 +290,11 @@ const styles = StyleSheet.create({ ...StyleSheet.absoluteFillObject, zIndex: 2, }, + outscreen: { + position: 'absolute', + left: -999, + top: -999, + }, }); const webStyles = Platform.OS === 'web' && StyleSheet.create({ diff --git a/src/components/ui/menu/subMenu.component.tsx b/src/components/ui/menu/subMenu.component.tsx deleted file mode 100644 index e620aec05..000000000 --- a/src/components/ui/menu/subMenu.component.tsx +++ /dev/null @@ -1,246 +0,0 @@ -/** - * @license - * Copyright Akveo. All Rights Reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - */ - -import React from 'react'; -import { SvgProps } from 'react-native-svg'; -import { - Animated, - GestureResponderEvent, - ImageProps, - StyleSheet, - TouchableOpacityProps, - View, -} from 'react-native'; -import { - styled, - StyledComponentProps, - StyleType, -} from '@kitten/theme'; -import { - MenuItem, - MenuItemElement, - MenuItemType, -} from './menuItem.component'; -import { - MeasureElement, - MeasuringElement, -} from '../measure/measure.component'; -import { Frame } from '../measure/type'; -import { ChevronDown } from '../support/components/chevronDown.component'; -import { DividerElement } from '../divider/divider.component'; - -interface ComponentProps { - item: MenuItemType; - selectedIndex: number; - divider?: DividerElement; - onSelect?: (index: number, event?: GestureResponderEvent) => void; -} - -interface ComponentState { - subItemsVisible: boolean; - subItemsHeight: number; -} - -export type SubMenuProps = ComponentProps & StyledComponentProps & TouchableOpacityProps; -export type SubMenuElement = React.ReactElement; -type OnPressHandler = (index: number, event?: GestureResponderEvent) => void; -type IconElement = React.ReactElement; - -const MAIN_ITEM_KEY: string = 'Main Item'; -const DIVIDER_ELEMENT_KEY: string = 'Divider'; - -class SubMenuComponent extends React.Component { - - static styledComponentName: string = 'SubMenu'; - - public state: ComponentState = { - subItemsVisible: false, - subItemsHeight: 0, - }; - - private subItemsAnimation: Animated.Value = new Animated.Value(0); - private iconAnimation: Animated.Value = new Animated.Value(0); - - public componentDidUpdate(prevProps: SubMenuProps, prevState: ComponentState): void { - if (prevState.subItemsVisible !== this.state.subItemsVisible) { - if (this.state.subItemsVisible) { - this.subItemsExpandAnimate(this.state.subItemsHeight); - this.animateIcon(-180); - } else { - this.subItemsExpandAnimate(0); - this.animateIcon(0); - } - } - } - - private subItemsExpandAnimate = (toValue: number): void => { - Animated.spring(this.subItemsAnimation, { - toValue: toValue, - }).start(); - }; - - private animateIcon = (toValue: number): void => { - Animated.timing(this.iconAnimation, { - toValue: toValue, - duration: 200, - }).start(); - }; - - private onMainItemPress = (): void => { - const subItemsVisible: boolean = !this.state.subItemsVisible; - - this.setState({ subItemsVisible }); - }; - - private onSubItemPress = (index: number, event: GestureResponderEvent): void => { - if (this.props.onSelect) { - this.props.onSelect(index, event); - } - }; - - private getComponentStyles = (style: StyleType): StyleType => { - return { - subContainer: { - paddingHorizontal: style.subItemsPaddingHorizontal, - }, - }; - }; - - private onSubMenuMeasure = (frame: Frame): void => { - this.setState({ subItemsHeight: frame.size.height }); - }; - - private getIsSelected = (item: MenuItemType): boolean => { - const { selectedIndex } = this.props; - - return selectedIndex === item.menuIndex; - }; - - private isMainItemDividerExist = (): boolean => { - const { divider } = this.props; - const { subItemsVisible } = this.state; - - return subItemsVisible && divider !== null; - }; - - private isSubItemDividerExist = (item: MenuItemType, index: number): boolean => { - const { divider } = this.props; - - return (index !== item.subItems.length - 1) && (divider !== null); - }; - - private renderDivider = (): DividerElement => { - const { divider } = this.props; - - return divider && React.cloneElement(divider, { - key: DIVIDER_ELEMENT_KEY, - }); - }; - - private renderMainItemAccessory = (style: SvgProps): IconElement => { - const rotateInterpolate = this.iconAnimation.interpolate({ - inputRange: [-180, 0], - outputRange: ['-180deg', '0deg'], - }); - const animatedStyle: StyleType = { transform: [{ rotate: rotateInterpolate }] }; - - return ( - - - - ); - }; - - private renderMenuItem = (item: MenuItemType, - isMainItem: boolean, - index: number | string): MenuItemElement => { - - const onPressHandler: OnPressHandler = isMainItem ? this.onMainItemPress : this.onSubItemPress; - const mainMenuItemAccessory = isMainItem ? this.renderMainItemAccessory : null; - - return ( - - ); - }; - - private renderSubItemsInvisible = (subItems: React.ReactNode): MeasuringElement => { - return ( - - - {subItems} - - - ); - }; - - private renderSubItems = (): React.ReactFragment => { - const { item, eva, divider } = this.props; - - return item.subItems.map((sub: MenuItemType, index: number) => { - const { subContainer } = this.getComponentStyles(eva.style); - const isSelected: boolean = this.getIsSelected(sub); - - const element: MenuItemElement = React.cloneElement(this.renderMenuItem(sub, false, index), { - style: subContainer, - selected: isSelected, - }); - const dividerElement: DividerElement = this.isSubItemDividerExist(item, index) ? - this.renderDivider() : null; - - return ( - - {element} - {dividerElement} - - ); - }); - }; - - private renderComponentChildren = (): React.ReactNodeArray => { - const { item } = this.props; - - return [ - this.renderMenuItem(item, true, MAIN_ITEM_KEY), - this.renderSubItems(), - this.isMainItemDividerExist() ? this.renderDivider() : null, - ]; - }; - - public render(): React.ReactFragment { - const { subItemsVisible } = this.state; - const [mainItem, subItems, divider] = this.renderComponentChildren(); - const invisibleSubs: React.ReactElement = this.renderSubItemsInvisible(subItems); - - const animatedStyle: StyleType = { height: this.subItemsAnimation }; - - return ( - - {mainItem} - {divider} - - {subItemsVisible && subItems} - - {invisibleSubs} - - ); - } -} - -const styles = StyleSheet.create({ - invisibleMenu: { - opacity: 0, - position: 'absolute', - }, -}); - -export const SubMenu = styled(SubMenuComponent);