From 5557e16871440cd1b065a18d40a4801c9ee64c65 Mon Sep 17 00:00:00 2001 From: Sergey Yuferev Date: Wed, 20 Mar 2019 18:33:05 +0300 Subject: [PATCH] feat(mobile): mobile menus: horizontal, select, level --- packages/core/src/index.ts | 1 + packages/core/src/menu/MenuLevelBuilder.ts | 66 ++++++++ packages/core/src/menu/index.ts | 1 + .../src/horizontal-menu/HorizontalMenu.md | 47 ++++++ .../src/horizontal-menu/HorizontalMenu.tsx | 91 +++++++++++ .../horizontal-menu/HorizontalMenuItem.tsx | 33 ++++ packages/mobile/src/horizontal-menu/index.ts | 2 + packages/mobile/src/index.ts | 3 + packages/mobile/src/level-menu/LevelMenu.md | 81 ++++++++++ packages/mobile/src/level-menu/LevelMenu.tsx | 58 +++++++ packages/mobile/src/level-menu/index.ts | 1 + packages/mobile/src/select-menu/SelectMenu.md | 42 +++++ .../mobile/src/select-menu/SelectMenu.tsx | 153 ++++++++++++++++++ .../mobile/src/select-menu/SelectMenuItem.tsx | 29 ++++ packages/mobile/src/select-menu/index.ts | 2 + 15 files changed, 610 insertions(+) create mode 100644 packages/core/src/menu/MenuLevelBuilder.ts create mode 100644 packages/core/src/menu/index.ts create mode 100644 packages/mobile/src/horizontal-menu/HorizontalMenu.md create mode 100644 packages/mobile/src/horizontal-menu/HorizontalMenu.tsx create mode 100644 packages/mobile/src/horizontal-menu/HorizontalMenuItem.tsx create mode 100644 packages/mobile/src/horizontal-menu/index.ts create mode 100644 packages/mobile/src/level-menu/LevelMenu.md create mode 100644 packages/mobile/src/level-menu/LevelMenu.tsx create mode 100644 packages/mobile/src/level-menu/index.ts create mode 100644 packages/mobile/src/select-menu/SelectMenu.md create mode 100644 packages/mobile/src/select-menu/SelectMenu.tsx create mode 100644 packages/mobile/src/select-menu/SelectMenuItem.tsx create mode 100644 packages/mobile/src/select-menu/index.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 45e42fc05..4adcf6bee 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -40,3 +40,4 @@ export * from './block' export * from './recaptcha' export * from './recaptcha-field' export * from './link' +export * from './menu' diff --git a/packages/core/src/menu/MenuLevelBuilder.ts b/packages/core/src/menu/MenuLevelBuilder.ts new file mode 100644 index 000000000..01da8ab0f --- /dev/null +++ b/packages/core/src/menu/MenuLevelBuilder.ts @@ -0,0 +1,66 @@ +import {ReactNode} from 'react' + +export interface BaseMenuItem { + id: string + title: ReactNode +} + +export type MenuTree = Item & { + sub?: MenuTree[] +} + +export class MenuLevel { + constructor(public items: Item[], public activeId: string) {} +} + +export class MenuLevelBuilder { + constructor(protected items: MenuTree[]) {} + + protected buildLevels( + activeId: string, + items: MenuTree[], + levels: (MenuLevel | undefined)[], + depth = 0, + parentActive = false + ): boolean { + if (levels.length <= depth) levels.push(undefined) + let activeItem: MenuTree | undefined + for (let item of items) { + const currentActive = item.id === activeId + const sub = item.sub + const childActive = + sub && sub.length > 0 + ? this.buildLevels( + activeId, + sub, + levels, + depth + 1, + currentActive || parentActive + ) + : false + if (currentActive) activeItem = item + if (childActive && !activeItem) activeItem = item + } + + if (activeItem || parentActive) { + levels[depth] = new MenuLevel( + items, + activeItem ? activeItem.id : items[0].id + ) + } + + return !!activeItem + } + + /** + * Split MenuTree structure to levels. + * Returns array, where index is a menu level. + * + * @param activeId current menu item id + */ + levels(activeId: string): (MenuLevel | undefined)[] { + const result: (MenuLevel[] | undefined) = [] + this.buildLevels(activeId, this.items, result) + return result + } +} diff --git a/packages/core/src/menu/index.ts b/packages/core/src/menu/index.ts new file mode 100644 index 000000000..a8db89a5b --- /dev/null +++ b/packages/core/src/menu/index.ts @@ -0,0 +1 @@ +export * from './MenuLevelBuilder' diff --git a/packages/mobile/src/horizontal-menu/HorizontalMenu.md b/packages/mobile/src/horizontal-menu/HorizontalMenu.md new file mode 100644 index 000000000..532cf6887 --- /dev/null +++ b/packages/mobile/src/horizontal-menu/HorizontalMenu.md @@ -0,0 +1,47 @@ +## Horizontal menu + +Горизонтальное меню с прокруткой через touch-события. Используется как первый уровень меню. + +```jsx +class HorizontalMenuDemo extends React.Component { + constructor() { + super() + this.state = { + activeId: 'requests', + } + } + + render() { + return ( + this.setState({activeId: item.id})} + items={[ + { + title: 'Услуги', + id: 'merchants', + }, + { + title: 'Заявки', + id: 'requests', + }, + + { + title: 'Пользователи', + id: 'users', + }, + + { + title: 'Поддержка', + id: 'support', + }, + ]} + /> + ) + } +} +; + + +``` diff --git a/packages/mobile/src/horizontal-menu/HorizontalMenu.tsx b/packages/mobile/src/horizontal-menu/HorizontalMenu.tsx new file mode 100644 index 000000000..ab1fb9bd4 --- /dev/null +++ b/packages/mobile/src/horizontal-menu/HorizontalMenu.tsx @@ -0,0 +1,91 @@ +import {BaseMenuItem, ButtonControl, styled} from '@qiwi/pijma-core' +import React, {ReactNode} from 'react' + +import {HorizontalMenuItem} from './HorizontalMenuItem' + +export interface HorizontalMenuRenderItemProps { + /** + * Html item id + */ + id: string + item: Item + active: boolean + onClick: () => void +} + +const horizontalMenuRenderItemDefault = ({ + item, + active, + id, + onClick, +}: HorizontalMenuRenderItemProps): ReactNode => ( + ( + + )} + /> +) + +// @see https://iamsteve.me/blog/entry/using-flexbox-for-horizontal-scrolling-navigation +export const HorizontalMenuContainer = styled('nav')(({theme}) => ({ + display: 'flex', + flexWrap: 'nowrap', + overflowX: 'auto', + webkitOverflowScrolling: 'auto', + msOverflowStyle: '-ms-autohiding-scrollbar', + '&::-webkit-scrollbar': { + display: 'none', + }, + '&:last-child': { + marginRight: 0, + }, + paddingLeft: '16px', + paddingRight: '16px', + borderBottom: '1px solid ' + theme.color.gray.light, +})) + +export interface HorizontalMenuProps { + /** + * Menu html id and item ids prefix + */ + id: string + /** + * Active menu item id + */ + activeId: string + items: Item[] + onClick: (item: Item) => void + renderItem: (props: HorizontalMenuRenderItemProps) => ReactNode +} + +// Do not use React.FC +// See https://github.com/sw-yx/react-typescript-cheatsheet#typing-defaultprops +export const HorizontalMenu = ({ + items, + renderItem, + onClick, + activeId, + id, +}: HorizontalMenuProps) => ( + + renderItem({ + item, + id: `${id}-${item.id}`, + active: item.id === activeId, + onClick: onClick.bind(null, item), + }) + )} + /> +) +HorizontalMenu.defaultProps = { + renderItem: horizontalMenuRenderItemDefault, +} diff --git a/packages/mobile/src/horizontal-menu/HorizontalMenuItem.tsx b/packages/mobile/src/horizontal-menu/HorizontalMenuItem.tsx new file mode 100644 index 000000000..8ae9bf0a6 --- /dev/null +++ b/packages/mobile/src/horizontal-menu/HorizontalMenuItem.tsx @@ -0,0 +1,33 @@ +import {styled} from '@qiwi/pijma-core' + +export interface HorizontalMenuItemProps { + active: boolean + id: string + href?: string + target?: string + reverse?: boolean + onClick?: (e: React.MouseEvent) => void +} + +export const HorizontalMenuItem = styled('a', { + shouldForwardProp: prop => prop !== 'active', +})(({active, theme}) => ({ + cursor: 'pointer', + fontFamily: theme.font.family, + fontWeight: theme.font.weight.normal, + fontSize: '16px', + textDecoration: 'none', + color: theme.color.black, + flexShrink: 0, + flexGrow: 0, + whiteSpace: 'nowrap', + paddingBottom: '4px', + marginRight: '24px', + borderBottomWidth: '4px', + borderBottomStyle: 'solid', + borderBottomColor: active ? theme.color.brand : 'transparent', + '&:hover, &:active, &:focus': { + textDecoration: 'none', + borderBottomColor: theme.color.brand, + }, +})) diff --git a/packages/mobile/src/horizontal-menu/index.ts b/packages/mobile/src/horizontal-menu/index.ts new file mode 100644 index 000000000..93fe7d2f6 --- /dev/null +++ b/packages/mobile/src/horizontal-menu/index.ts @@ -0,0 +1,2 @@ +export * from './HorizontalMenu' +export * from './HorizontalMenuItem' diff --git a/packages/mobile/src/index.ts b/packages/mobile/src/index.ts index 5d3772d72..ea193a689 100644 --- a/packages/mobile/src/index.ts +++ b/packages/mobile/src/index.ts @@ -7,6 +7,9 @@ export * from './checkbox-field' export * from './radio-field' export * from './typography' export * from './link' +export * from './horizontal-menu' +export * from './select-menu' +export * from './level-menu' export {TextField as MaskTextField} from './text-field' export {PasswordField as MaskPasswordField} from './password-field' diff --git a/packages/mobile/src/level-menu/LevelMenu.md b/packages/mobile/src/level-menu/LevelMenu.md new file mode 100644 index 000000000..92d144213 --- /dev/null +++ b/packages/mobile/src/level-menu/LevelMenu.md @@ -0,0 +1,81 @@ +## Level menu + +Горизонтальное меню + вертикальное меню. + +```jsx +class LevelMenuDemo extends React.Component { + constructor() { + super() + this.state = { + activeId: 't2', + } + } + + render() { + return ( + this.setState({activeId: item.id})} + items={[ + { + title: 'Услуги', + id: 'merchants', + sub: [ + { + title: 'Транзакции', + id: 'transactions', + sub: [ + { + title: 'Что-то там 1', + id: 't1', + }, + { + title: 'Что-то там 2', + id: 't2', + }, + ], + }, + { + title: 'Акты', + id: 'reports', + }, + { + title: 'Реестры', + id: 'registry', + }, + ], + }, + { + title: 'Заявки', + id: 'requests', + sub: [ + { + title: 'Список 1', + id: 'list1', + }, + { + title: 'Список 2', + id: 'list2', + }, + ], + }, + + { + title: 'Пользователи', + id: 'users', + }, + + { + title: 'Поддержка', + id: 'support', + }, + ]} + /> + ) + } +} +; + + +``` diff --git a/packages/mobile/src/level-menu/LevelMenu.tsx b/packages/mobile/src/level-menu/LevelMenu.tsx new file mode 100644 index 000000000..972470817 --- /dev/null +++ b/packages/mobile/src/level-menu/LevelMenu.tsx @@ -0,0 +1,58 @@ +import {BaseMenuItem, Box, MenuLevelBuilder} from '@qiwi/pijma-core' +import React, {Component} from 'react' + +import {HorizontalMenu} from '../horizontal-menu' +import {SelectMenu} from '../select-menu' + +export interface LevelMenuProps { + id: string + activeId: string + items: Item[] + onClick: (item: Item) => void +} + +export class LevelMenu extends Component< + LevelMenuProps +> { + render() { + const { + props: {id, onClick, activeId, items}, + } = this + const levels = new MenuLevelBuilder(items).levels(activeId) + const level0 = levels[0] + const level1 = levels[1] + const level2 = levels[2] + return ( +
+ {level0 && ( + + )} + {level1 && ( + + + + )} + {level2 && ( + + + + )} +
+ ) + } +} diff --git a/packages/mobile/src/level-menu/index.ts b/packages/mobile/src/level-menu/index.ts new file mode 100644 index 000000000..2920316e2 --- /dev/null +++ b/packages/mobile/src/level-menu/index.ts @@ -0,0 +1 @@ +export * from './LevelMenu' diff --git a/packages/mobile/src/select-menu/SelectMenu.md b/packages/mobile/src/select-menu/SelectMenu.md new file mode 100644 index 000000000..d3bb0ee23 --- /dev/null +++ b/packages/mobile/src/select-menu/SelectMenu.md @@ -0,0 +1,42 @@ +## Select menu + +Вертикальное меню, похожее на dropdown. Используется как второй уровень. + +```jsx +class SelectMenuDemo extends React.Component { + constructor() { + super() + this.state = { + activeId: 'transactions', + } + } + + render() { + return ( + this.setState({activeId: item.id})} + items={[ + { + title: 'Транзакции и отчеты', + id: 'transactions', + }, + { + title: 'Реестры', + id: 'registry', + }, + + { + title: 'Акты', + id: 'reports', + }, + ]} + /> + ) + } +} +; + + +``` diff --git a/packages/mobile/src/select-menu/SelectMenu.tsx b/packages/mobile/src/select-menu/SelectMenu.tsx new file mode 100644 index 000000000..2e4f48975 --- /dev/null +++ b/packages/mobile/src/select-menu/SelectMenu.tsx @@ -0,0 +1,153 @@ +import { + BaseMenuItem, + ButtonControl, + Card, + Flex, + FlexItem, + Icon, + styled, +} from '@qiwi/pijma-core' +import React, {Component, ReactNode} from 'react' + +import {SelectMenuItem} from './SelectMenuItem' + +export interface SelectMenuRenderItemProps { + /** + * Html item id + */ + id: string + item: Item + onClick: () => void +} + +const selectMenuRenderItemDefault = ({ + item, + id, + onClick, +}: SelectMenuRenderItemProps): ReactNode => ( + ( + + )} + /> +) + +export const SelectMenuActiveItemContainer = styled(Flex)(({theme}) => ({ + fontWeight: theme.font.weight.normal, + fontFamily: theme.font.family, + fontSize: '16px', + cursor: 'pointer', + alignItems: 'center', + justifyContent: 'space-between', + padding: '8px 16px', + whiteSpace: 'nowrap', +})) + +export interface SelectMenuRenderActiveItemProps + extends SelectMenuRenderItemProps { + expanded: boolean +} + +const selectMenuRenderActiveItemDefault = ({ + item, + id, + onClick, + expanded, +}: SelectMenuRenderActiveItemProps) => ( + + + {item.title} + + + + + +) + +// Flex.withComponent('nav') not working +export const SelectMenuActiveItemsContainer = styled('nav')({ + flexDirection: 'column', + display: 'flex', +}) + +export interface SelectMenuProps { + /** + * Menu html id and item ids prefix + */ + id: string + /** + * Active menu item id + */ + activeId: string + items: Item[] + onClick: (item: Item) => void + renderItem: (props: SelectMenuRenderItemProps) => ReactNode + renderActiveItem: (props: SelectMenuRenderActiveItemProps) => ReactNode +} + +export interface SelectMenuState { + expanded: boolean +} + +export class SelectMenu< + Item extends BaseMenuItem = BaseMenuItem +> extends Component, SelectMenuState> { + static defaultProps = { + renderItem: selectMenuRenderItemDefault, + renderActiveItem: selectMenuRenderActiveItemDefault, + } + + state: SelectMenuState = { + expanded: false, + } + + protected toggle(item: Item) { + this.setState({expanded: !this.state.expanded}) + this.props.onClick(item) + } + + render() { + const { + toggle, + state: {expanded}, + props: {items, renderItem, renderActiveItem, activeId, id}, + } = this + const activeItem = items.find(item => item.id === activeId) || items[0] + return ( + + {renderActiveItem({ + id: `${id}-active`, + item: activeItem, + expanded, + onClick: toggle.bind(this, activeItem), + })} + + {expanded && ( + + {items.map(item => + item.id === activeId + ? null + : renderItem({ + item, + id: `${id}-item-${item.id}`, + onClick: toggle.bind(this, item), + }) + )} + + )} + + ) + } +} diff --git a/packages/mobile/src/select-menu/SelectMenuItem.tsx b/packages/mobile/src/select-menu/SelectMenuItem.tsx new file mode 100644 index 000000000..b1f03f90a --- /dev/null +++ b/packages/mobile/src/select-menu/SelectMenuItem.tsx @@ -0,0 +1,29 @@ +import {styled} from '@qiwi/pijma-core' + +export interface SelectMenuItemProps { + id: string + href?: string + target?: string + reverse?: boolean + onClick?: (e: React.MouseEvent) => void +} + +export const SelectMenuItem = styled('a')< + SelectMenuItemProps +>(({theme}) => ({ + cursor: 'pointer', + fontFamily: theme.font.family, + fontWeight: theme.font.weight.normal, + fontSize: '16px', + textDecoration: 'none', + color: theme.color.black, + flexShrink: 0, + flexGrow: 0, + whiteSpace: 'nowrap', + lineHeight: '21px', + padding: '8px 16px', + '&:hover, &:active, &:focus': { + textDecoration: 'none', + backgroundColor: theme.color.gray.lightest, + }, +})) diff --git a/packages/mobile/src/select-menu/index.ts b/packages/mobile/src/select-menu/index.ts new file mode 100644 index 000000000..b28d489d8 --- /dev/null +++ b/packages/mobile/src/select-menu/index.ts @@ -0,0 +1,2 @@ +export * from './SelectMenu' +export * from './SelectMenuItem'