Browse Source

add cascader

pull/9/head
tjz 7 years ago
parent
commit
6e8b5b9a20
  1. 1
      components/_util/props-util.js
  2. 37
      components/_util/vnode.js
  3. 53
      components/cascader/demo/basic.md
  4. 374
      components/cascader/index.vue
  5. 5
      components/cascader/style/index.js
  6. 214
      components/cascader/style/index.less
  7. 2
      components/index.js
  8. 1
      components/style.js
  9. 2
      examples/routes.js

1
components/_util/props-util.js

@ -149,5 +149,6 @@ export {
getKey,
getAttrs,
getValueByProp,
parseStyleText,
}
export default hasProp

37
components/_util/vnode.js

@ -1,4 +1,4 @@
import { filterEmpty } from './props-util'
import { filterEmpty, parseStyleText } from './props-util'
export function cloneVNode (vnode, deep) {
const componentOptions = vnode.componentOptions
const data = vnode.data
@ -62,18 +62,45 @@ export function cloneElement (n, nodeProps, deep) {
const node = cloneVNode(ele, deep)
const { props = {}, key, on = {}, children } = nodeProps
const data = node.data || {}
const { style = {},
class: cls = {},
let cls = {}
let style = {}
const {
attrs = {},
ref,
domProps = {},
style: tempStyle = {},
class: tempCls = {},
} = nodeProps
if (typeof data.style === 'string') {
style = parseStyleText(data.style)
} else {
style = { ...data.style, ...style }
}
if (typeof tempStyle === 'string') {
style = { ...style, ...parseStyleText(style) }
} else {
style = { ...style, ...tempStyle }
}
if (typeof data.class === 'string') {
cls[data.class] = true
data.class.split(' ').forEach(c => { cls[c.trim()] = true })
} else {
cls = { ...data.class, ...cls }
}
if (typeof tempCls === 'string') {
tempCls.split(' ').forEach(c => { cls[c.trim()] = true })
} else {
cls = { ...cls, ...tempCls }
}
node.data = Object.assign({}, data, {
style: { ...data.style, ...style },
style,
attrs: { ...data.attrs, ...attrs },
class: { ...data.class, ...cls },
class: cls,
domProps: { ...data.domProps, ...domProps },
})
if (node.componentOptions) {
node.componentOptions.propsData = node.componentOptions.propsData || {}
node.componentOptions.listeners = node.componentOptions.listeners || {}

53
components/cascader/demo/basic.md

@ -0,0 +1,53 @@
<cn>
#### 基本
省市区级联。
</cn>
<us>
#### Basic
Cascade selection box for selecting province/city/district.
</us>
```html
<template>
<a-cascader :options="options" @change="onChange" placeholder="Please select" />
</template>
<script>
export default {
data() {
return {
options: [{
value: 'zhejiang',
label: 'Zhejiang',
children: [{
value: 'hangzhou',
label: 'Hangzhou',
children: [{
value: 'xihu',
label: 'West Lake',
}],
}],
}, {
value: 'jiangsu',
label: 'Jiangsu',
children: [{
value: 'nanjing',
label: 'Nanjing',
children: [{
value: 'zhonghuamen',
label: 'Zhong Hua Men',
}],
}],
}]
}
},
methods: {
onChange(value) {
console.log(value);
}
}
}
</script>
```

374
components/cascader/index.vue

@ -0,0 +1,374 @@
<script>
import PropTypes from '../_util/vue-types'
import VcCascader from '../vc-cascader'
import arrayTreeFilter from 'array-tree-filter'
import classNames from 'classnames'
import omit from 'omit.js'
import KeyCode from '../_util/KeyCode'
import Input from '../input'
import Icon from '../icon'
import { hasProp, filterEmpty, getOptionProps } from '../_util/props-util'
import BaseMixin from '../_util/BaseMixin'
const CascaderOptionType = PropTypes.shape({
value: PropTypes.string.isRequired,
label: PropTypes.any.isRequired,
disabled: PropTypes.bool,
children: PropTypes.array,
__IS_FILTERED_OPTION: PropTypes.bool,
}).loose
const CascaderExpandTrigger = PropTypes.oneOf(['click', 'hover'])
const ShowSearchType = PropTypes.shape({
filter: PropTypes.func,
render: PropTypes.func,
sort: PropTypes.func,
matchInputWidth: PropTypes.bool,
}).loose
function noop () {}
const CascaderProps = {
/** 可选项数据源 */
options: PropTypes.arrayOf(CascaderOptionType).def([]),
/** 默认的选中项 */
defaultValue: PropTypes.arrayOf(PropTypes.string),
/** 指定选中项 */
value: PropTypes.arrayOf(PropTypes.string),
/** 选择完成后的回调 */
// onChange?: (value: string[], selectedOptions?: CascaderOptionType[]) => void;
/** 选择后展示的渲染函数 */
displayRender: PropTypes.func,
transitionName: PropTypes.string.def('slide-up'),
popupStyle: PropTypes.object.def({}),
/** 自定义浮层类名 */
popupClassName: PropTypes.string,
/** 浮层预设位置:`bottomLeft` `bottomRight` `topLeft` `topRight` */
popupPlacement: PropTypes.oneOf(['bottomLeft', 'bottomRight', 'topLeft', 'topRight']).def('bottomLeft'),
/** 输入框占位文本*/
placeholder: PropTypes.string.def('Please select'),
/** 输入框大小,可选 `large` `default` `small` */
size: PropTypes.oneOf(['large', 'default', 'small']),
/** 禁用*/
disabled: PropTypes.bool.def(false),
/** 是否支持清除*/
allowClear: PropTypes.bool.def(true),
showSearch: PropTypes.oneOfType([PropTypes.bool, ShowSearchType]),
notFoundContent: PropTypes.any.def('Not Found'),
loadData: PropTypes.func,
/** 次级菜单的展开方式,可选 'click' 和 'hover' */
expandTrigger: CascaderExpandTrigger,
/** 当此项为 true 时,点选每级菜单选项值都会发生变化 */
changeOnSelect: PropTypes.bool,
/** 浮层可见变化时回调 */
// onPopupVisibleChange?: (popupVisible: boolean) => void;
prefixCls: PropTypes.string.def('ant-cascader'),
inputPrefixCls: PropTypes.string.def('ant-input'),
getPopupContainer: PropTypes.func,
popupVisible: PropTypes.bool,
}
function defaultFilterOption (inputValue, path) {
return path.some(option => option.label.indexOf(inputValue) > -1)
}
function defaultSortFilteredOption (a, b, inputValue) {
function callback (elem) {
return elem.label.indexOf(inputValue) > -1
}
return a.findIndex(callback) - b.findIndex(callback)
}
const defaultDisplayRender = (label) => label.join(' / ')
export default {
mixins: [BaseMixin],
props: CascaderProps,
data () {
this.cachedOptions = []
const { value, defaultValue, popupVisible, showSearch, options, changeOnSelect, flattenTree } = this
return {
sValue: value || defaultValue || [],
inputValue: '',
inputFocused: false,
sPopupVisible: popupVisible,
flattenOptions: showSearch && flattenTree(options, changeOnSelect),
}
},
watch: {
value (val) {
this.setState({ sValue: val || [] })
},
popupVisible (val) {
this.setState({ sPopupVisible: val })
},
options (val) {
if (this.showSearch) {
this.setState({ flattenOptions: this.flattenTree(this.options, this.changeOnSelect) })
}
},
},
methods: {
highlightKeyword (str, keyword, prefixCls) {
return str.split(keyword)
.map((node, index) => index === 0 ? node : [
<span class={`${prefixCls}-menu-item-keyword`} key='seperator'>{keyword}</span>,
node,
])
},
defaultRenderFilteredOption (inputValue, path, prefixCls) {
return path.map(({ label }, index) => {
const node = label.indexOf(inputValue) > -1
? this.highlightKeyword(label, inputValue, prefixCls) : label
return index === 0 ? node : [' / ', node]
})
},
handleChange (value, selectedOptions) {
this.setState({ inputValue: '' })
if (selectedOptions[0].__IS_FILTERED_OPTION) {
const unwrappedValue = value[0]
const unwrappedSelectedOptions = selectedOptions[0].path
this.setValue(unwrappedValue, unwrappedSelectedOptions)
return
}
this.setValue(value, selectedOptions)
},
handlePopupVisibleChange (popupVisible) {
if (!hasProp(this, 'popupVisible')) {
this.setState({
sPopupVisible: popupVisible,
inputFocused: popupVisible,
inputValue: popupVisible ? this.inputValue : '',
})
}
this.$emit('popupVisibleChange', popupVisible)
},
handleInputBlur () {
this.setState({
inputFocused: false,
})
},
handleInputClick (e) {
const { inputFocused, sPopupVisible } = this
// Prevent `Trigger` behaviour.
if (inputFocused || sPopupVisible) {
e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
}
},
handleKeyDown (e) {
if (e.keyCode === KeyCode.BACKSPACE) {
e.stopPropagation()
}
},
handleInputChange (e) {
const inputValue = e.target.value
this.setState({ inputValue })
},
setValue (value, selectedOptions) {
if (!hasProp(this, 'value')) {
this.setState({ sValue: value })
}
this.$emit('change', value, selectedOptions)
},
getLabel () {
const { options, displayRender = defaultDisplayRender } = this
const value = this.sValue
const unwrappedValue = Array.isArray(value[0]) ? value[0] : value
const selectedOptions = arrayTreeFilter(options,
(o, level) => o.value === unwrappedValue[level],
)
const label = selectedOptions.map(o => o.label)
return displayRender(label, selectedOptions)
},
clearSelection (e) {
e.preventDefault()
e.stopPropagation()
if (!this.inputValue) {
this.setValue([])
this.handlePopupVisibleChange(false)
} else {
this.setState({ inputValue: '' })
}
},
flattenTree (options, changeOnSelect, ancestor = []) {
let flattenOptions = []
options.forEach((option) => {
const path = ancestor.concat(option)
if (changeOnSelect || !option.children || !option.children.length) {
flattenOptions.push(path)
}
if (option.children) {
flattenOptions = flattenOptions.concat(this.flattenTree(option.children, changeOnSelect, path))
}
})
return flattenOptions
},
generateFilteredOptions (prefixCls) {
const { showSearch, notFoundContent, flattenOptions, inputValue } = this
const {
filter = defaultFilterOption,
render = this.defaultRenderFilteredOption,
sort = defaultSortFilteredOption,
} = showSearch
const filtered = flattenOptions.filter((path) => filter(inputValue, path))
.sort((a, b) => sort(a, b, inputValue))
if (filtered.length > 0) {
return filtered.map((path) => {
return {
__IS_FILTERED_OPTION: true,
path,
label: render(inputValue, path, prefixCls),
value: path.map((o) => o.value),
disabled: path.some((o) => o.disabled),
}
})
}
return [{ label: notFoundContent, value: 'ANT_CASCADER_NOT_FOUND', disabled: true }]
},
focus () {
this.$refs.input.focus()
},
blur () {
this.$refs.input.blur()
},
},
render () {
const { $slots, sValue: value, sPopupVisible, inputValue } = this
const props = getOptionProps(this)
const {
prefixCls, inputPrefixCls, placeholder, size, disabled,
allowClear, showSearch = false, ...otherProps } = props
const sizeCls = classNames({
[`${inputPrefixCls}-lg`]: size === 'large',
[`${inputPrefixCls}-sm`]: size === 'small',
})
const clearIcon = (allowClear && !disabled && value.length > 0) || inputValue ? (
<Icon
type='cross-circle'
class={`${prefixCls}-picker-clear`}
onClick={this.clearSelection}
/>
) : null
const arrowCls = classNames({
[`${prefixCls}-picker-arrow`]: true,
[`${prefixCls}-picker-arrow-expand`]: sPopupVisible,
})
const pickerCls = classNames(
`${prefixCls}-picker`, {
[`${prefixCls}-picker-with-value`]: inputValue,
[`${prefixCls}-picker-disabled`]: disabled,
[`${prefixCls}-picker-${size}`]: !!size,
})
// Fix bug of https://github.com/facebook/react/pull/5004
// and https://fb.me/react-unknown-prop
const tempInputProps = omit(otherProps, [
'options',
'popupPlacement',
'transitionName',
'displayRender',
'changeOnSelect',
'expandTrigger',
'popupVisible',
'getPopupContainer',
'loadData',
'popupClassName',
'filterOption',
'renderFilteredOption',
'sortFilteredOption',
'notFoundContent',
])
let options = this.options
if (inputValue) {
options = this.generateFilteredOptions(prefixCls)
}
// Dropdown menu should keep previous status until it is fully closed.
if (!sPopupVisible) {
options = this.cachedOptions
} else {
this.cachedOptions = options
}
const dropdownMenuColumnStyle = {}
const isNotFound = (options || []).length === 1 && options[0].value === 'ANT_CASCADER_NOT_FOUND'
if (isNotFound) {
dropdownMenuColumnStyle.height = 'auto' // Height of one row.
}
// The default value of `matchInputWidth` is `true`
const resultListMatchInputWidth = showSearch.matchInputWidth !== false
if (resultListMatchInputWidth && inputValue && this.input) {
dropdownMenuColumnStyle.width = this.input.input.offsetWidth
}
const inputProps = {
props: {
...tempInputProps,
prefixCls: inputPrefixCls,
placeholder: value && value.length > 0 ? undefined : placeholder,
value: inputValue,
disabled: disabled,
readOnly: !showSearch,
autoComplete: 'off',
},
class: `${prefixCls}-input ${sizeCls}`,
ref: 'input',
on: {
click: showSearch ? this.handleInputClick : noop,
blur: showSearch ? this.handleInputBlur : noop,
keydown: this.handleKeyDown,
change: showSearch ? this.handleInputChange : noop,
},
}
const children = filterEmpty($slots.default)
const input = children.length ? children : (
<span
class={pickerCls}
>
<span class={`${prefixCls}-picker-label`}>
{this.getLabel()}
</span>
<Input {...inputProps}/>
{clearIcon}
<Icon type='down' class={arrowCls} />
</span>
)
const cascaderProps = {
props: {
...props,
options: options,
value: value,
popupVisible: sPopupVisible,
dropdownMenuColumnStyle: dropdownMenuColumnStyle,
},
on: {
popupVisibleChange: this.handlePopupVisibleChange,
change: this.handleChange,
},
}
return (
<VcCascader {...cascaderProps}>
{input}
</VcCascader>
)
},
}
</script>

5
components/cascader/style/index.js

@ -0,0 +1,5 @@
import '../../style/index.less'
import './index.less'
// style dependencies
import '../../input/style'

214
components/cascader/style/index.less

@ -0,0 +1,214 @@
@import "../../style/themes/default";
@import "../../style/mixins/index";
@import "../../input/style/mixin";
@cascader-prefix-cls: ~"@{ant-prefix}-cascader";
.@{cascader-prefix-cls} {
.reset-component;
&-input.@{ant-prefix}-input {
// Add important to fix https://github.com/ant-design/ant-design/issues/5078
// because input.less will compile after cascader.less
background-color: transparent !important;
cursor: pointer;
width: 100%;
display: block;
}
&-picker {
.reset-component;
position: relative;
display: inline-block;
cursor: pointer;
background-color: @component-background;
border-radius: @border-radius-base;
outline: 0;
&-with-value &-label {
color: transparent;
}
&-disabled {
cursor: not-allowed;
background: @input-disabled-bg;
color: @disabled-color;
.@{cascader-prefix-cls}-input {
cursor: not-allowed;
}
}
&:focus .@{cascader-prefix-cls}-input {
.active;
}
&-label {
position: absolute;
left: 0;
height: 20px;
line-height: 20px;
top: 50%;
margin-top: -10px;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
width: 100%;
padding: 0 @control-padding-horizontal;
}
&-clear {
opacity: 0;
position: absolute;
right: @control-padding-horizontal;
z-index: 2;
background: @component-background;
top: 50%;
font-size: @font-size-sm;
color: @disabled-color;
width: 12px;
height: 12px;
margin-top: -6px;
line-height: 12px;
cursor: pointer;
transition: color 0.3s ease, opacity 0.15s ease;
&:hover {
color: @text-color-secondary;
}
}
&:hover &-clear {
opacity: 1;
}
// arrow
&-arrow {
position: absolute;
z-index: 1;
top: 50%;
right: @control-padding-horizontal;
width: 12px;
height: 12px;
font-size: 12px;
margin-top: -6px;
line-height: 12px;
color: @disabled-color;
&:before {
transition: transform .2s;
}
&&-expand {
&:before {
transform: rotate(180deg);
}
}
}
}
&-picker-small &-picker-clear,
&-picker-small &-picker-arrow {
right: @control-padding-horizontal-sm;
}
&-menus {
font-size: @font-size-base;
background: @component-background;
position: absolute;
z-index: @zindex-dropdown;
border-radius: @border-radius-base;
box-shadow: @box-shadow-base;
white-space: nowrap;
ul,
ol {
list-style: none;
margin: 0;
padding: 0;
}
&-empty,
&-hidden {
display: none;
}
&.slide-up-enter.slide-up-enter-active&-placement-bottomLeft,
&.slide-up-appear.slide-up-appear-active&-placement-bottomLeft {
animation-name: antSlideUpIn;
}
&.slide-up-enter.slide-up-enter-active&-placement-topLeft,
&.slide-up-appear.slide-up-appear-active&-placement-topLeft {
animation-name: antSlideDownIn;
}
&.slide-up-leave.slide-up-leave-active&-placement-bottomLeft {
animation-name: antSlideUpOut;
}
&.slide-up-leave.slide-up-leave-active&-placement-topLeft {
animation-name: antSlideDownOut;
}
}
&-menu {
display: inline-block;
vertical-align: top;
min-width: 111px;
height: 180px;
list-style: none;
margin: 0;
padding: 0;
border-right: @border-width-base @border-style-base @border-color-split;
overflow: auto;
&:first-child {
border-radius: @border-radius-base 0 0 @border-radius-base;
}
&:last-child {
border-right-color: transparent;
margin-right: -1px;
border-radius: 0 @border-radius-base @border-radius-base 0;
}
&:only-child {
border-radius: @border-radius-base;
}
}
&-menu-item {
padding: 5px @control-padding-horizontal;
line-height: 22px;
cursor: pointer;
white-space: nowrap;
transition: all 0.3s;
&:hover {
background: @item-hover-bg;
}
&-disabled {
cursor: not-allowed;
color: @disabled-color;
&:hover {
background: transparent;
}
}
&-active:not(&-disabled) {
&,
&:hover {
background: @background-color-base;
font-weight: 600;
}
}
&-expand {
position: relative;
padding-right: 24px;
&:after {
.iconfont-font("\e61f");
.iconfont-size-under-12px(8px);
color: @text-color-secondary;
position: absolute;
right: @control-padding-horizontal;
}
}
&-loading:after {
.iconfont-font("\e64d");
animation: loadingCircle 1s infinite linear;
}
& &-keyword {
color: @highlight-color;
}
}
}

2
components/index.js

@ -91,3 +91,5 @@ export { default as AutoComplete } from './auto-complete'
export { default as Affix } from './affix'
export { default as Cascader } from './cascader'

1
components/style.js

@ -25,3 +25,4 @@ import './select/style'
import './switch/style'
import './auto-complete/style'
import './affix/style'
import './cascader/style'

2
examples/routes.js

@ -3,7 +3,7 @@ const AsyncComp = () => {
const hashs = window.location.hash.split('/')
const d = hashs[hashs.length - 1]
return {
component: import(`../components/vc-cascader/demo/${d}`),
component: import(`../components/cascader/demo/${d}`),
}
}
export default [

Loading…
Cancel
Save