feat: 宫格导航 (#3044)

* feat: 宫格导航

* feat: 添加直接页面跳转配置

* feat: 宫格导航兼容url与link格式
This commit is contained in:
张涛 2021-11-24 20:10:04 +08:00 committed by GitHub
parent 0d3606fb14
commit 729924cc53
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 949 additions and 0 deletions

View File

@ -0,0 +1,373 @@
---
title: GridNav 宫格导航
description:
type: 0
group: ⚙ 组件
menuName: GridNav 宫格导航
icon:
order: 54
---
宫格菜单导航,不支持配置初始化接口初始化数据域,所以需要搭配类似像`Service`、`Form`或`CRUD`这样的,具有配置接口初始化数据域功能的组件,或者手动进行数据域初始化,然后通过`source`属性,获取数据链中的数据,完成菜单展示。
## 基本用法
通过 source 关联上下文数据,或者通过 name 关联。
```schema
{
"type": "page",
"data": {
"items": [
{
"icon": "https://internal-amis-res.cdn.bcebos.com/images/icon-1.png",
"text": "导航1"
},
{
"icon": "https://internal-amis-res.cdn.bcebos.com/images/icon-1.png",
"text": "导航2"
},
{
"icon": "https://internal-amis-res.cdn.bcebos.com/images/icon-1.png",
"text": "导航3"
},
{
"icon": "https://internal-amis-res.cdn.bcebos.com/images/icon-1.png",
"text": "导航4"
}
]
},
"body": [
{
"type": "grid-nav",
"source": "${items}"
},
{
"type": "divider"
},
{
"type": "grid-nav",
"name": "items"
}
]
}
```
也可以静态展示,即不关联数据固定显示。
```schema
{
"type": "page",
"body": {
"type": "grid-nav",
"options": [
{
"icon": "https://internal-amis-res.cdn.bcebos.com/images/icon-1.png",
"text": "导航1"
},
{
"icon": "https://internal-amis-res.cdn.bcebos.com/images/icon-1.png",
"text": "导航2"
},
{
"icon": "https://internal-amis-res.cdn.bcebos.com/images/icon-1.png",
"text": "导航3"
},
{
"icon": "https://internal-amis-res.cdn.bcebos.com/images/icon-1.png",
"text": "导航4"
}
]
}
}
```
## 自定义列数
默认一行展示四个格子,可以通过 `columnNum` 自定义列数
```schema
{
"type": "page",
"data": {
"items": [
{
"icon": "https://internal-amis-res.cdn.bcebos.com/images/icon-1.png",
"text": "导航1"
},
{
"icon": "https://internal-amis-res.cdn.bcebos.com/images/icon-1.png",
"text": "导航2"
},
{
"icon": "https://internal-amis-res.cdn.bcebos.com/images/icon-1.png",
"text": "导航3"
}
]
},
"body": {
"type": "grid-nav",
"columnNum": 3,
"source": "${items}"
}
}
```
## 正方形格子
设置 `square` 属性后,格子的高度会和宽度保持一致。
```schema
{
"type": "page",
"data": {
"items": [
{
"icon": "https://internal-amis-res.cdn.bcebos.com/images/icon-1.png",
"text": "导航1"
},
{
"icon": "https://internal-amis-res.cdn.bcebos.com/images/icon-1.png",
"text": "导航2"
},
{
"icon": "https://internal-amis-res.cdn.bcebos.com/images/icon-1.png",
"text": "导航3"
},
{
"icon": "https://internal-amis-res.cdn.bcebos.com/images/icon-1.png",
"text": "导航4"
}
]
},
"body": {
"type": "grid-nav",
"source": "${items}",
"square": true
}
}
```
## 格子间距
通过 `gutter` 属性设置格子之间的距离。
```schema
{
"type": "page",
"data": {
"items": [
{
"icon": "https://internal-amis-res.cdn.bcebos.com/images/icon-1.png",
"text": "导航1"
},
{
"icon": "https://internal-amis-res.cdn.bcebos.com/images/icon-1.png",
"text": "导航2"
},
{
"icon": "https://internal-amis-res.cdn.bcebos.com/images/icon-1.png",
"text": "导航3"
},
{
"icon": "https://internal-amis-res.cdn.bcebos.com/images/icon-1.png",
"text": "导航4"
}
]
},
"body": {
"type": "grid-nav",
"source": "${items}",
"gutter": 20
}
}
```
## 内容横排
`direction` 属性设置为 `horizontal`,可以让宫格的内容呈横向排列。
```schema
{
"type": "page",
"data": {
"items": [
{
"icon": "https://internal-amis-res.cdn.bcebos.com/images/icon-1.png",
"text": "导航1"
},
{
"icon": "https://internal-amis-res.cdn.bcebos.com/images/icon-1.png",
"text": "导航2"
},
{
"icon": "https://internal-amis-res.cdn.bcebos.com/images/icon-1.png",
"text": "导航3"
},
{
"icon": "https://internal-amis-res.cdn.bcebos.com/images/icon-1.png",
"text": "导航4"
}
]
},
"body": {
"type": "grid-nav",
"direction": "horizontal",
"source": "${items}"
}
}
```
## 图标占比
设置 `iconRatio` 可以控制图标宽度占比,默认 60%,设置 1-100 的数字。
```schema
{
"type": "page",
"data": {
"items": [
{
"icon": "https://internal-amis-res.cdn.bcebos.com/images/icon-1.png",
"text": "导航1"
},
{
"icon": "https://internal-amis-res.cdn.bcebos.com/images/icon-1.png",
"text": "导航2"
},
{
"icon": "https://internal-amis-res.cdn.bcebos.com/images/icon-1.png",
"text": "导航3"
},
{
"icon": "https://internal-amis-res.cdn.bcebos.com/images/icon-1.png",
"text": "导航4"
}
]
},
"body": {
"type": "grid-nav",
"iconRatio": "40",
"source": "${items}"
}
}
```
## 角标提示
设置 badge 属性后,会在图标右上角展示相应的角标,支持红点、数字、彩带模式。
```schema
{
"type": "page",
"data": {
"items": [
{
"icon": "https://internal-amis-res.cdn.bcebos.com/images/icon-1.png",
"text": "导航1",
"badge": {
"mode": "text",
"text": "10"
}
},
{
"icon": "https://internal-amis-res.cdn.bcebos.com/images/icon-1.png",
"text": "导航2",
"badge": {
"mode": "dot"
}
},
{
"icon": "https://internal-amis-res.cdn.bcebos.com/images/icon-1.png",
"text": "导航3",
"badge": {
"mode": "ribbon",
"text": "热销"
}
},
{
"icon": "https://internal-amis-res.cdn.bcebos.com/images/icon-1.png",
"text": "导航4"
}
]
},
"body": {
"type": "grid-nav",
"source": "${items}",
"border": false
}
}
```
## 点击交互
设置 clickAction 属性支持通用点击交互,详见 [Action](./action) 配置
```schema
{
"type": "page",
"data": {
"items": [
{
"icon": "https://internal-amis-res.cdn.bcebos.com/images/icon-1.png",
"text": "外部跳转",
"link": "https://www.baidu.com",
"blank": true
},
{
"icon": "https://internal-amis-res.cdn.bcebos.com/images/icon-1.png",
"text": "弹框",
"clickAction": {
"actionType": "dialog",
"dialog": {
"title": "弹框",
"body": "这是个简单的弹框。"
}
}
},
{
"icon": "https://internal-amis-res.cdn.bcebos.com/images/icon-1.png",
"text": "内部跳转",
"link": "/docs/index"
},
{
"icon": "https://internal-amis-res.cdn.bcebos.com/images/icon-1.png",
"text": "导航4"
}
]
},
"body": {
"type": "grid-nav",
"source": "${items}",
"border": false
}
}
```
## 属性表
| 属性名 | 类型 | 默认值 | 说明 |
| ------------------- | --------------- | ---------- | -------------------------------------------------------- |
| type | `string` | `grid-nav` | |
| className | `string` | | 外层 CSS 类名 |
| itemClassName | `string` | | 列表项 css 类名 |
| value | `Array<object>` | | 图片数组 |
| source | `string` | | 数据源 |
| square | `boolean` | | 是否将列表项固定为正方形 |
| center | `boolean` | `true` | 是否将列表项内容居中显示 |
| border | `boolean` | `true` | 是否显示列表项边框 |
| gutter | `number` | | 列表项之间的间距,默认单位为`px` |
| reverse | `boolean` | | 是否调换图标和文本的位置 |
| iconRatio | `number` | 60 | 图标宽度占比,单位% |
| direction | `string` | `vertical` | 列表项内容排列的方向,可选值为 `horizontal` 、`vertical` |
| columnNum | `number` | 4 | 列数 |
| options.icon | `string` | | 列表项图标 |
| options.text | `string` | | 列表项文案 |
| options.badge | `BadgeSchema` | | 列表项角标,详见 [Badge](./badge) |
| options.link | `string` | | 内部页面路径或外部跳转 URL 地址,优先级高于 clickAction |
| options.blank | `boolean` | | 是否新页面打开link 为 url 时有效 |
| options.clickAction | `ActionSchema` | | 列表项点击交互 详见 [Action](./action) |
```
```

View File

@ -854,6 +854,14 @@ export const components = [
makeMarkdownRenderer
)
},
{
label: 'GridNav 宫格导航',
path: '/zh-CN/components/grid-nav',
getComponent: () =>
import('../../docs/zh-CN/components/grid-nav.md').then(
makeMarkdownRenderer
)
},
{
label: 'Json',
path: '/zh-CN/components/json',

View File

@ -0,0 +1,128 @@
.u-hairline::after {
position: absolute;
box-sizing: border-box;
content: ' ';
pointer-events: none;
top: -50%;
right: -50%;
bottom: -50%;
left: -50%;
border: 0 solid var(--borderColorLight);
z-index: 1;
transform: scale(0.5);
}
.#{$ns}GridNav {
display: flex;
flex-wrap: wrap;
&-top {
position: relative;
&::after {
border-top-width: px2rem(1px);
}
}
}
.#{$ns}GridNavItem {
position: relative;
box-sizing: border-box;
&--square {
height: 0;
position: relative;
}
&-icon {
width: var(--rv-grid-item-icon-size);
}
&-text {
color: var(--text-color);
font-size: var(--fontSizeSm);
line-height: 1.5;
word-break: break-all;
flex-shrink: 0;
}
&-icon + &-text {
margin-top: px2rem(8px);
}
&-image {
display: inline-block;
svg,
img {
max-width: 100%;
display: block;
width: 60%;
margin: 0 auto;
}
}
&-content {
display: flex;
flex-direction: column;
box-sizing: border-box;
height: 100%;
padding: var(--gap-md) var(--gap-sm);
background-color: var(--white);
position: relative;
.#{$ns}Badge-text {
z-index: 10;
}
&--border::after {
border-width: 0 var(--borderWidth) var(--borderWidth) 0;
}
&--square {
position: absolute;
top: 0;
right: 0;
left: 0;
}
&--center {
align-items: center;
justify-content: center;
}
&--horizontal {
flex-direction: row;
.#{$ns}GridNavItem-text {
margin: 0 0 0 var(--gap-sm);
}
}
&--reverse {
flex-direction: column-reverse;
.#{$ns}GridNavItem-text {
margin: 0 0 var(--gap-sm);
}
}
&--horizontal &--reverse {
flex-direction: row-reverse;
.#{$ns}GridNavItem-text {
margin: 0 var(--gap-sm) 0 0;
}
}
&--surround {
&::after {
border-width: var(--borderWidth);
}
}
&--clickable {
cursor: pointer;
}
}
}

View File

@ -70,6 +70,7 @@
@import '../components/icon';
@import '../components/steps';
@import '../components/portlet';
@import '../components/grid-nav';
@import '../components/form/fieldset';
@import '../components/form/group';

View File

@ -319,6 +319,7 @@ export type SchemaType =
| 'tree-select'
| 'table-view'
| 'portlet'
| 'grid-nav'
// 原生 input 类型
| 'native-date'

233
src/components/GridNav.tsx Normal file
View File

@ -0,0 +1,233 @@
/**
* @file GridNav
* @description react-vant
*/
import React, {useMemo} from 'react';
import {ClassNamesFn} from '../theme';
import {Badge, BadgeProps} from './Badge';
export type GridNavDirection = 'horizontal' | 'vertical';
export interface GridNavProps {
/** 是否将格子固定为正方形 */
square?: boolean;
/** 是否将格子内容居中显示 */
center?: boolean;
/** 是否显示边框 */
border?: boolean;
/** 格子之间的间距,默认单位为`px` */
gutter?: number;
/** 是否调换图标和文本的位置 */
reverse?: boolean;
/** 图标占比,默认单位为`%` */
iconRatio?: number;
/** 格子内容排列的方向,可选值为 `horizontal` */
direction?: GridNavDirection;
/** 列数 */
columnNum?: number;
className?: string;
itemClassName?: string;
classnames: ClassNamesFn;
style?: React.CSSProperties;
}
export interface GridNavItemProps {
/** 图标右上角徽标 */
badge?: BadgeProps;
/** 文字 */
text?: string | React.ReactNode;
/** 图标名称或图片链接 */
icon?: string | React.ReactNode;
className?: string;
style?: React.CSSProperties;
contentClassName?: string;
contentStyle?: React.CSSProperties;
children?: React.ReactNode;
classnames: ClassNamesFn;
onClick?: (event: React.MouseEvent) => void;
}
type InternalProps = {
parent?: GridNavProps;
index?: number;
};
function addUnit(value?: string | number): string | undefined {
if (value === undefined || value === null) {
return undefined;
}
value = String(value);
return /^\d+(\.\d+)?$/.test(value) ? `${value}px` : value;
}
export const GridNavItem: React.FC<GridNavItemProps & InternalProps> = ({
children,
classnames: cx,
className,
style,
...props
}) => {
const {index = 0, parent} = props;
if (!parent) {
if (process.env.NODE_ENV !== 'production') {
// eslint-disable-next-line no-console
console.error(
'[React Vant] <GridNavItem> must be a child component of <GridNav>.'
);
}
return null;
}
const rootStyle = useMemo(() => {
const {square, gutter, columnNum = 4} = parent;
const percent = `${100 / +columnNum}%`;
const internalStyle: React.CSSProperties = {
...style,
flexBasis: percent
};
if (square) {
internalStyle.paddingTop = percent;
} else if (gutter) {
const gutterValue = addUnit(gutter);
internalStyle.paddingRight = gutterValue;
if (index >= columnNum) {
internalStyle.marginTop = gutterValue;
}
}
return internalStyle;
}, [parent.style, parent.gutter, parent.columnNum]);
const contentStyle = useMemo(() => {
const {square, gutter} = parent;
if (square && gutter) {
const gutterValue = addUnit(gutter);
return {
...props.contentStyle,
right: gutterValue,
bottom: gutterValue,
height: 'auto'
};
}
return props.contentStyle;
}, [parent.gutter, parent.columnNum, props.contentStyle]);
const renderIcon = () => {
const ratio = parent.iconRatio || 60;
if (typeof props.icon === 'string') {
if (props.badge) {
return (
<Badge {...props.badge}>
<div className={cx('GridNavItem-image')}>
<img src={props.icon} style={{width: ratio + '%'}} />
</div>
</Badge>
);
}
return (
<div className={cx('GridNavItem-image')}>
<img src={props.icon} style={{width: ratio + '%'}} />
</div>
);
}
if (React.isValidElement(props.icon)) {
return <Badge {...(props.badge as BadgeProps)}>{props.icon}</Badge>;
}
return null;
};
const renderText = () => {
if (React.isValidElement(props.text)) {
return props.text;
}
if (props.text) {
return <span className={cx('GridNavItem-text')}>{props.text}</span>;
}
return null;
};
const renderContent = () => {
if (children) {
return children;
}
return (
<>
{renderIcon()}
{renderText()}
</>
);
};
const {center, border, square, gutter, reverse, direction} = parent;
const prefix = 'GridNavItem-content';
const classes = cx(`${prefix} ${props.contentClassName || ''}`, {
[`${prefix}--${direction}`]: !!direction,
[`${prefix}--center`]: center,
[`${prefix}--square`]: square,
[`${prefix}--reverse`]: reverse,
[`${prefix}--clickable`]: !!props.onClick,
[`${prefix}--surround`]: border && gutter,
[`${prefix}--border u-hairline`]: border
});
return (
<div
className={cx(className, {'GridNavItem--square': square})}
style={rootStyle}
>
<div
role={props.onClick ? 'button' : undefined}
className={classes}
style={contentStyle}
onClick={props.onClick}
>
{renderContent()}
</div>
</div>
);
};
const GridNav: React.FC<GridNavProps> = ({
children,
className,
classnames: cx,
itemClassName,
style,
...props
}) => {
return (
<div
style={{paddingLeft: addUnit(props.gutter), ...style}}
className={cx(`GridNav ${className || ''}`, {
'GridNav-top u-hairline': props.border && !props.gutter
})}
>
{React.Children.toArray(children)
.filter(Boolean)
.map((child: React.ReactElement, index: number) =>
React.cloneElement(child, {
index,
parent: props,
className: itemClassName,
classnames: cx
})
)}
</div>
);
};
GridNav.defaultProps = {
direction: 'vertical',
center: true,
border: true,
columnNum: 4
};
export default GridNav;

View File

@ -168,6 +168,7 @@ import './renderers/Markdown';
import './renderers/TableView';
import './renderers/Code';
import './renderers/WebComponent';
import './renderers/GridNav';
import Scoped, {ScopedContext} from './Scoped';

204
src/renderers/GridNav.tsx Normal file
View File

@ -0,0 +1,204 @@
import React from 'react';
import {Renderer, RendererProps} from '../factory';
import {autobind, getPropValue} from '../utils/helper';
import {isPureVariable, resolveVariableAndFilter} from '../utils/tpl-builtin';
import {
BaseSchema,
SchemaTokenizeableString,
SchemaTpl,
SchemaUrlPath
} from '../Schema';
import {ActionSchema} from './Action';
import GridNav, {GridNavDirection, GridNavItem} from '../components/GridNav';
import {BadgeSchema} from '../components/Badge';
import handleAction from '../utils/handleAction';
import {validations} from '../utils/validations';
export interface ListItemSchema extends Omit<BaseSchema, 'type'> {
/**
*
*/
clickAction?: ActionSchema;
/**
*
*/
link?: string;
/**
*
*/
blank?: string;
/**
*
*/
icon?: SchemaUrlPath;
/**
*
*/
text?: SchemaTpl;
/**
* 0-100
*/
iconRatio?: number;
/**
*
*/
badge?: BadgeSchema;
}
/**
* List
* https://baidu.gitee.io/amis/docs/components/card
*/
export interface ListSchema extends BaseSchema {
/**
* List
*/
type: 'grid-nav';
/**
*
*/
itemClassName?: string;
/**
*
*/
options?: Array<ListItemSchema>;
/**
*
*/
square?: boolean;
/**
*
*/
center?: boolean;
/**
*
*/
border?: boolean;
/**
* px
*/
gutter?: number;
/**
* , 1-100
*/
iconRatio?: number;
/**
* horizontal vertical
*/
direction?: GridNavDirection;
/**
*
*/
columnNum?: number;
/**
* 数据源: 绑定当前环境变量
*
* @default ${items}
*/
source?: SchemaTokenizeableString;
}
export interface Column {
type: string;
[propName: string]: any;
}
export interface ListProps
extends RendererProps,
Omit<ListSchema, 'type' | 'className'> {
handleClick: (item?: ListItemSchema) => void;
}
@Renderer({
type: 'grid-nav'
})
export default class List extends React.Component<ListProps, object> {
@autobind
handleClick(item: ListItemSchema) {
return (e: React.MouseEvent) => {
let action;
if (item.link) {
action = validations.isUrl({}, item.link)
? {
type: 'button',
actionType: 'url',
url: item.link,
blank: item.blank
}
: {
type: 'button',
actionType: 'link',
link: item.link
};
} else {
action = item.clickAction!;
}
handleAction(e, action as ActionSchema, this.props);
};
}
render() {
const {itemClassName, source, data, options, classnames} = this.props;
let value = getPropValue(this.props);
let list: any = [];
if (typeof source === 'string' && isPureVariable(source)) {
list = resolveVariableAndFilter(source, data, '| raw') || undefined;
} else if (Array.isArray(value)) {
list = value;
} else if (Array.isArray(options)) {
list = options;
}
if (list && !Array.isArray(list)) {
list = [list];
}
if (!list?.length) {
return null;
}
return (
<GridNav {...this.props}>
{list.map((item: ListItemSchema, index: number) => (
<GridNavItem
key={index}
onClick={
item.clickAction || item.link ? this.handleClick(item) : undefined
}
className={itemClassName}
text={item.text}
icon={item.icon}
classnames={classnames}
badge={
item.badge
? {
badge: item.badge,
data: data,
classnames
}
: undefined
}
/>
))}
</GridNav>
);
}
}