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/MenuControl.tsx b/packages/core/src/menu/MenuControl.tsx new file mode 100644 index 000000000..310e4ed8a --- /dev/null +++ b/packages/core/src/menu/MenuControl.tsx @@ -0,0 +1,25 @@ +import {PureComponent} from 'react' + +import RenderChild from '../RenderChild' +import {MenuLevel, BaseMenuItem, MenuLevelBuilder} from './MenuLevelBuilder' + +export interface MenuControlProps { + items: Item[] + selected: string + children: RenderChild<{ + levels: MenuLevel[] + }> +} + +export class MenuControl extends PureComponent< + MenuControlProps +> { + + render() { + const {props} = this + return props.children({ + levels: new MenuLevelBuilder(props.items).levels(props.selected), + }) + } + +} diff --git a/packages/core/src/menu/MenuLevelBuilder.ts b/packages/core/src/menu/MenuLevelBuilder.ts new file mode 100644 index 000000000..130d487a5 --- /dev/null +++ b/packages/core/src/menu/MenuLevelBuilder.ts @@ -0,0 +1,69 @@ +import {ReactNode} from 'react' + +export interface BaseMenuItem { + id: string + title: ReactNode +} + +export type MenuTree = Item & { + sub?: MenuTree[] +} + +export type MenuLevel = { + items: Item[] + selected: string +} | undefined + +export class MenuLevelBuilder { + + constructor(protected items: MenuTree[]) {} + + protected buildLevels( + selected: string, + items: MenuTree[], + levels: MenuLevel[], + 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 === selected + const sub = item.sub + const childActive = + sub && sub.length > 0 + ? this.buildLevels( + selected, + sub, + levels, + depth + 1, + currentActive || parentActive, + ) + : false + if (currentActive) activeItem = item + if (childActive && !activeItem) activeItem = item + } + + if (activeItem || parentActive) { + levels[depth] = { + items, + selected: activeItem ? activeItem.id : items[0].id, + } + } + + return !!activeItem + } + + /** + * Split MenuTree structure to levels. + * Returns array, where index is a menu level. + * + * @param selected current menu item id + */ + levels(selected: string): MenuLevel[] { + const result: MenuLevel[] = [] + this.buildLevels(selected, 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..c05dea70d --- /dev/null +++ b/packages/core/src/menu/index.ts @@ -0,0 +1,2 @@ +export * from './MenuControl' +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..2a0be62db --- /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 = { + selected: 'requests', + } + } + + render() { + return ( + this.setState({selected: 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..3becf551f --- /dev/null +++ b/packages/mobile/src/horizontal-menu/HorizontalMenu.tsx @@ -0,0 +1,62 @@ +import {BaseMenuItem, styled, Flex, Card, Box} from '@qiwi/pijma-core' +import React from 'react' + +import {HorizontalMenuItem} from './HorizontalMenuItem' + +// @see https://iamsteve.me/blog/entry/using-flexbox-for-horizontal-scrolling-navigation +const ScrollableFlex = styled(Flex)({ + overflowX: 'auto', + webkitOverflowScrolling: 'auto', + msOverflowStyle: '-ms-autohiding-scrollbar', + '&::-webkit-scrollbar': { + display: 'none', + }, +}) + +export interface HorizontalMenuProps { + /** + * Menu html id and item ids prefix + */ + id: string + /** + * Active menu item id + */ + selected: string + items: Item[] + onSelect: (item: Item) => void +} + +export const HorizontalMenu = ({ + items, + onSelect, + selected, + id, +}: HorizontalMenuProps) => ( + ( + + } + /> + ))} + /> + } + /> +) diff --git a/packages/mobile/src/horizontal-menu/HorizontalMenuItem.tsx b/packages/mobile/src/horizontal-menu/HorizontalMenuItem.tsx new file mode 100644 index 000000000..f48762bff --- /dev/null +++ b/packages/mobile/src/horizontal-menu/HorizontalMenuItem.tsx @@ -0,0 +1,43 @@ +import {Card, LinkControl, Lnk} from '@qiwi/pijma-core' +import React from 'react' + +import {Text} from '../typography' + +export interface HorizontalMenuItemProps { + active: boolean + id: string + href?: string + target?: string + reverse?: boolean + onClick?: () => void +} + +const CardLink = Card.withComponent(Lnk) + +export const HorizontalMenuItem: React.FC = props => ( + ( + } + /> + )} + /> +) 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..f88556f0c --- /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 = { + selected: 't2', + } + } + + render() { + return ( + this.setState({selected: 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..410b15de3 --- /dev/null +++ b/packages/mobile/src/level-menu/LevelMenu.tsx @@ -0,0 +1,56 @@ +import {BaseMenuItem, Box, MenuControl} from '@qiwi/pijma-core' +import React, {Fragment} from 'react' + +import {HorizontalMenu} from '../horizontal-menu' +import {SelectMenu} from '../select-menu' + +export interface LevelMenuProps { + id: string + selected: string + items: Item[] + onSelect: (item: Item) => void +} + +export const LevelMenu = ({ + id, + onSelect, + selected, + items, +}: LevelMenuProps) => ( + ( + + {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..de658b363 --- /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 = { + selected: 'transactions', + } + } + + render() { + return ( + this.setState({selected: 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..01364d27c --- /dev/null +++ b/packages/mobile/src/select-menu/SelectMenu.tsx @@ -0,0 +1,89 @@ +import {BaseMenuItem, Card, Flex, FlexItem, Icon} from '@qiwi/pijma-core' +import React, {Component} from 'react' + +import {SelectMenuItem} from './SelectMenuItem' +import {Text} from '../typography' + +export interface SelectMenuProps { + /** + * Menu html id and item ids prefix + */ + id: string + /** + * Active menu item id + */ + selected: string + items: Item[] + onSelect: (item: Item) => void +} + +export interface SelectMenuState { + expanded: boolean +} + +export class SelectMenu< + Item extends BaseMenuItem = BaseMenuItem +> extends Component, SelectMenuState> { + + state: SelectMenuState = { + expanded: false, + } + + protected toggle(item: Item) { + this.setState({expanded: !this.state.expanded}) + this.props.onSelect(item) + } + + render() { + const { + toggle, + state: {expanded}, + props: {items, selected, id}, + } = this + const activeItem = items.find(item => item.id === selected) || items[0] + return ( + + + + + + + + + + + {expanded && ( + + item.id === selected ? null : ( + + ), + )} + /> + )} + + ) + } + +} diff --git a/packages/mobile/src/select-menu/SelectMenuItem.tsx b/packages/mobile/src/select-menu/SelectMenuItem.tsx new file mode 100644 index 000000000..cf1b66bf3 --- /dev/null +++ b/packages/mobile/src/select-menu/SelectMenuItem.tsx @@ -0,0 +1,41 @@ +import {Card, LinkControl, Lnk} from '@qiwi/pijma-core' +import React from 'react' + +import {Text} from '../typography' + +export interface SelectMenuItemProps { + id: string + href?: string + target?: string + reverse?: boolean + onClick?: () => void +} + +const CardLink = Card.withComponent(Lnk) + +export const SelectMenuItem: React.FC = props => ( + ( + } + /> + )} + /> +) 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'