diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cfa4ed63..5f9146229 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - 修复 Switch 的 width 属性无效的问题 - Table 增加 rowClassName 属性 - TableColumn 增加 fixed 属性,可选值:true, false, left, right +- TableColumn 增加属性:filters、filterMultiple、filterMethod、filteredValue - TableColumn[type="selection"] 增加 selectable 属性 - 修复 Input textarea 在动态赋值时 autosize 没有触发的问题 - 修复 Input Number min max 属性设置后点击加减出现的崩溃的bug diff --git a/examples/docs/zh-cn/table.md b/examples/docs/zh-cn/table.md index e8bdd8fe4..a7e222b91 100644 --- a/examples/docs/zh-cn/table.md +++ b/examples/docs/zh-cn/table.md @@ -9,28 +9,32 @@ province: '上海', city: '普陀区', address: '上海市普陀区金沙江路 1518 弄', - zip: 200333 + zip: 200333, + tag: '家' }, { date: '2016-05-02', name: '王小虎', province: '上海', city: '普陀区', address: '上海市普陀区金沙江路 1518 弄', - zip: 200333 + zip: 200333, + tag: '公司' }, { date: '2016-05-04', name: '王小虎', province: '上海', city: '普陀区', address: '上海市普陀区金沙江路 1518 弄', - zip: 200333 + zip: 200333, + tag: '家' }, { date: '2016-05-01', name: '王小虎', province: '上海', city: '普陀区', address: '上海市普陀区金沙江路 1518 弄', - zip: 200333 + zip: 200333, + tag: '公司' }], tableData2: [{ date: '2016-05-02', @@ -119,6 +123,10 @@ return row.address; }, + filterTag(value, row) { + return row.tag === value; + }, + tableRowClassName(row, index) { if (index === 1) { return 'info-row'; @@ -810,6 +818,85 @@ ``` ::: +### 筛选 + +对表格进行筛选,可快速查找到自己想看的数据。 + +:::demo 在列中设置`filters``filter-method`属性即可开启该列的筛选,filters 是一个数组,`filter-method`是一个方法,它用于决定某些数据是否显示,会传入两个参数:`value`和`row`。 +```html + + + +``` +::: + ### Table Attributes | 参数 | 说明 | 类型 | 可选值 | 默认值 | |---------- |-------------- |---------- |-------------------------------- |-------- | @@ -852,4 +939,8 @@ | inline-template | 指定该属性后可以自定义 column 模板,参考多选的时间列,通过 row 获取行信息,JSX 里通过 _self 获取当前上下文。此时不需要配置 prop 属性 | — | — | | align | 对齐方式 | String | left, center, right | left | | selectable | 仅对 type=selection 的列有效,类型为 Function,Function 的返回值用来决定这一行的 CheckBox 是否可以勾选 | Function(row, index) | - | - | -| reserve-selection | 仅对 type=selection 的列有效,类型为 Boolean,为 true 则代表会保留之前数据的选项,需要配合 Table 的 clearSelection 方法使用。 | Boolean | - | false | \ No newline at end of file +| reserve-selection | 仅对 type=selection 的列有效,类型为 Boolean,为 true 则代表会保留之前数据的选项,需要配合 Table 的 clearSelection 方法使用。 | Boolean | - | false | +| filters | 数据过滤的选项,数组格式,数组中的元素需要有 text 和 value 属性。 | Array[{ text, value }] | — | — | +| filter-multiple | 数据过滤的选项是否多选 | Boolean | — | true | +| filter-method | 数据过滤使用的方法,如果是多选的筛选项,对每一条数据会执行多次,任意一次返回 true 就会显示。 | Function(value, row) | — | — | +| filteredValue | 选中的数据过滤项,如果需要自定义表头过滤的渲染方式,可能会需要此属性。 | Array | — | — | \ No newline at end of file diff --git a/packages/table/src/dropdown.js b/packages/table/src/dropdown.js new file mode 100644 index 000000000..dd2bec9df --- /dev/null +++ b/packages/table/src/dropdown.js @@ -0,0 +1,27 @@ +var dropdowns = []; + +document.addEventListener('click', function(event) { + dropdowns.forEach(function(dropdown) { + var target = event.target; + if (!dropdown || !dropdown.$el) return; + if (target === dropdown.$el || dropdown.$el.contains(target)) { + return; + } + dropdown.handleOutsideClick && dropdown.handleOutsideClick(event); + }); +}); + +export default { + open(instance) { + if (instance) { + dropdowns.push(instance); + } + }, + + close(instance) { + var index = dropdowns.indexOf(instance); + if (index !== -1) { + dropdowns.splice(instance, 1); + } + } +}; diff --git a/packages/table/src/filter-panel.vue b/packages/table/src/filter-panel.vue new file mode 100644 index 000000000..820d82e2e --- /dev/null +++ b/packages/table/src/filter-panel.vue @@ -0,0 +1,180 @@ + + + diff --git a/packages/table/src/table-body.js b/packages/table/src/table-body.js index d9079bd51..36e5919c2 100644 --- a/packages/table/src/table-body.js +++ b/packages/table/src/table-body.js @@ -1,22 +1,4 @@ -import { getValueByPath, getCell } from './util'; - -const getColumnById = function(table, columnId) { - let column = null; - table.columns.forEach(function(item) { - if (item.id === columnId) { - column = item; - } - }); - return column; -}; - -const getColumnByCell = function(table, cell) { - const matches = (cell.className || '').match(/el-table_[^\s]+/gm); - if (matches) { - return getColumnById(table, matches[0]); - } - return null; -}; +import { getValueByPath, getCell, getColumnById, getColumnByCell } from './util'; export default { props: { diff --git a/packages/table/src/table-column.js b/packages/table/src/table-column.js index d2a3567b9..7ebc2792e 100644 --- a/packages/table/src/table-column.js +++ b/packages/table/src/table-column.js @@ -19,21 +19,16 @@ const defaults = { minWidth: 48, realWidth: 48, direction: '' - }, - filter: { - headerTemplate: function(h) { return filter header; }, - direction: '' } }; const forced = { selection: { headerTemplate: function(h) { - return
{ this.$emit('allselectedchange', value); } } /> -
; + on-input={ (value) => { this.$emit('allselectedchange', value); } } />; }, template: function(h, { row, column, store, $index }) { return #; }, headerTemplate: function(h, label) { - return
{ label || '#' }
; + return label || '#'; }, template: function(h, { $index }) { return
{ $index + 1 }
; @@ -56,7 +51,7 @@ const forced = { }, filter: { headerTemplate: function(h) { - return
#
; + return '#'; }, template: function(h, { row, column }) { return { row[column.property] }; @@ -118,7 +113,13 @@ export default { fixed: [Boolean, String], formatter: Function, selectable: Function, - reserveSelection: Boolean + reserveSelection: Boolean, + filterMethod: Function, + filters: Array, + filterMultiple: { + type: Boolean, + default: true + } }, render() {}, @@ -205,7 +206,13 @@ export default { formatter: this.formatter, selectable: this.selectable, reserveSelection: this.reserveSelection, - fixed: this.fixed + fixed: this.fixed, + filterMethod: this.filterMethod, + filters: this.filters, + filterable: this.filters || this.filterMethod, + filterMultiple: this.filterMultiple, + filterOpened: false, + filteredValue: [] }); objectAssign(column, forced[type] || {}); diff --git a/packages/table/src/table-header.js b/packages/table/src/table-header.js index bc13dc1c0..d827de61e 100644 --- a/packages/table/src/table-header.js +++ b/packages/table/src/table-header.js @@ -1,5 +1,7 @@ import ElCheckbox from 'element-ui/packages/checkbox'; import ElTag from 'element-ui/packages/tag'; +import Vue from 'vue'; +import FilterPanel from './filter-panel.vue'; export default { name: 'el-table-header', @@ -31,21 +33,27 @@ export default { on-mousemove={ ($event) => this.handleMouseMove($event, column) } on-mouseout={ this.handleMouseOut } on-mousedown={ ($event) => this.handleMouseDown($event, column) } - on-click={ ($event) => this.handleHeaderClick($event, column) } class={ [column.id, column.direction, column.align, this.isCellHidden(cellIndex) ? 'hidden' : ''] }> +
0 ? 'highlight' : ''] }> { - [ - column.headerTemplate - ? column.headerTemplate.call(this._renderProxy, h, column.label) - :
{ column.label }
, - column.sortable - ?
- - -
- : '' - ] + column.headerTemplate + ? column.headerTemplate.call(this._renderProxy, h, column.label) + : column.label } + { + column.sortable + ? this.handleHeaderClick($event, column) }> + + + + : '' + } + { + column.filterable + ? this.handleFilterClick($event, column) }> + : '' + } +
) } @@ -61,7 +69,6 @@ export default { }, props: { - columns: {}, fixed: String, store: { required: true @@ -99,6 +106,19 @@ export default { } }, + created() { + this.filterPanels = {}; + }, + + beforeDestroy() { + const panels = this.filterPanels; + for (let prop in panels) { + if (panels.hasOwnProperty(prop) && panels[prop]) { + panels[prop].$destroy(true); + } + } + }, + methods: { isCellHidden(index) { if (this.fixed === true || this.fixed === 'left') { @@ -114,6 +134,34 @@ export default { this.store.commit('toggleAllSelection'); }, + handleFilterClick(event, column) { + event.stopPropagation(); + const target = event.target; + const cell = target.parentNode; + const table = this.$parent; + + let filterPanel = this.filterPanels[column.id]; + + if (filterPanel && column.filterOpened) { + filterPanel.showPopper = false; + return; + } + + if (!filterPanel) { + filterPanel = new Vue(FilterPanel); + this.filterPanels[column.id] = filterPanel; + + filterPanel.table = table; + filterPanel.cell = cell; + filterPanel.column = column; + filterPanel.$mount(document.createElement('div')); + } + + setTimeout(() => { + filterPanel.showPopper = true; + }, 16); + }, + handleMouseDown(event, column) { if (this.draggingColumn && this.border) { this.dragging = true; @@ -180,7 +228,10 @@ export default { }, handleMouseMove(event, column) { - const target = event.target; + let target = event.target; + while (target && target.tagName !== 'TH') { + target = target.parentNode; + } if (!column || !column.resizable) return; @@ -194,7 +245,6 @@ export default { } else if (!this.dragging) { bodyStyle.cursor = ''; this.draggingColumn = null; - if (column.sortable) bodyStyle.cursor = 'pointer'; } } }, diff --git a/packages/table/src/table-store.js b/packages/table/src/table-store.js index 9db6e6fa8..e844b0478 100644 --- a/packages/table/src/table-store.js +++ b/packages/table/src/table-store.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import debounce from 'throttle-debounce/debounce'; -import { orderBy } from './util'; +import { orderBy, getColumnById } from './util'; const getRowIdentity = (row, rowKey) => { if (!row) throw new Error('row is required when get row identity'); @@ -24,6 +24,7 @@ const TableStore = function(table, initialState = {}) { fixedColumns: [], rightFixedColumns: [], _data: null, + filteredData: null, data: null, sortCondition: { column: null, @@ -34,7 +35,8 @@ const TableStore = function(table, initialState = {}) { selection: [], reserveSelection: false, selectable: null, - hoverRow: null + hoverRow: null, + filters: {} }; for (let prop in initialState) { @@ -80,7 +82,38 @@ TableStore.prototype.mutations = { }, changeSortCondition(states) { - states.data = orderBy((states._data || []), states.sortCondition.property, states.sortCondition.direction); + states.data = orderBy((states.filteredData || states._data || []), states.sortCondition.property, states.sortCondition.direction); + + Vue.nextTick(() => this.table.updateScrollY()); + }, + + filterChange(states, options) { + let { column, values } = options; + if (values && !Array.isArray(values)) { + values = [values]; + } + + const prop = column.property; + if (prop) { + states.filters[column.id] = values; + } + + let data = states._data; + const filters = states.filters; + + Object.keys(filters).forEach((columnId) => { + const values = filters[columnId]; + if (!values || values.length === 0) return; + const column = getColumnById(this.states, columnId); + if (column && column.filterMethod) { + data = data.filter((row) => { + return values.some(value => column.filterMethod.call(null, value, row)); + }); + } + }); + + states.filteredData = data; + states.data = orderBy(data, states.sortCondition.property, states.sortCondition.direction); Vue.nextTick(() => this.table.updateScrollY()); }, diff --git a/packages/table/src/table.vue b/packages/table/src/table.vue index f53c976a9..825903d1d 100644 --- a/packages/table/src/table.vue +++ b/packages/table/src/table.vue @@ -91,6 +91,7 @@ import throttle from 'throttle-debounce/throttle'; import debounce from 'throttle-debounce/debounce'; import { addResizeListener, removeResizeListener } from 'element-ui/src/utils/resize-event'; + import { $t } from 'element-ui/src/locale'; import TableStore from './table-store'; import TableLayout from './table-layout'; import TableBody from './table-body'; @@ -130,7 +131,7 @@ emptyText: { type: String, - default: '暂无数据' + default: $t('el.table.emptyText') } }, diff --git a/packages/table/src/util.js b/packages/table/src/util.js index 896f6c67c..d7e3e1c2d 100644 --- a/packages/table/src/util.js +++ b/packages/table/src/util.js @@ -75,3 +75,21 @@ export const orderBy = function(array, sortKey, reverse) { return a === b ? 0 : a > b ? order : -order; }); }; + +export const getColumnById = function(table, columnId) { + let column = null; + table.columns.forEach(function(item) { + if (item.id === columnId) { + column = item; + } + }); + return column; +}; + +export const getColumnByCell = function(table, cell) { + const matches = (cell.className || '').match(/el-table_[^\s]+/gm); + if (matches) { + return getColumnById(table, matches[0]); + } + return null; +}; diff --git a/packages/theme-default/src/index.css b/packages/theme-default/src/index.css index a2c8792b2..df2aac229 100644 --- a/packages/theme-default/src/index.css +++ b/packages/theme-default/src/index.css @@ -13,6 +13,7 @@ @import "./loading.css"; @import "./dialog.css"; @import "./table.css"; +@import "./table-column.css"; @import "./pagination.css"; @import "./popover.css"; @import "./tooltip.css"; diff --git a/packages/theme-default/src/table-column.css b/packages/theme-default/src/table-column.css index e69de29bb..f367c8f3e 100644 --- a/packages/theme-default/src/table-column.css +++ b/packages/theme-default/src/table-column.css @@ -0,0 +1,85 @@ +@charset "UTF-8"; +@import "./checkbox.css"; +@import "./tag.css"; +@import "./common/var.css"; + +@component-namespace el { + @b table-filter { + border: solid 1px #d3dce6; + border-radius: 2px; + background-color: #fff; + box-shadow: var(--dropdown-menu-box-shadow); + box-sizing: border-box; + margin: 2px 0; + + /** used for dropdown mode */ + @e list { + padding: 5px 0; + margin: 0; + list-style: none; + min-width: 100px; + } + + @e list-item { + line-height: 36px; + padding: 0 10px; + cursor: pointer; + font-size: var(--font-size-base); + + &:hover { + background-color: var(--dropdown-menuItem-hover-fill); + color: var(--dropdown-menuItem-hover-color); + } + + @when active { + background-color: #20a0ff; + color: #fff; + } + } + + @e content { + min-width: 100px; + } + + @e bottom { + border-top: 1px solid #d3dce6; + padding: 8px; + + button { + background: transparent; + border: none; + color: #8492a6; + cursor: pointer; + font-size: var(--font-size-base); + padding: 0 3px; + + &:hover { + color: #20a0ff; + } + + &:focus { + outline: none; + } + + &.is-disabled { + color: #c0ccda; + cursor: not-allowed; + } + } + } + + @e checkbox-group { + padding: 10px; + + .el-checkbox { + display: block; + margin-bottom: 8px; + margin-left: 5px; + } + + .el-checkbox:last-child { + margin-bottom: 0; + } + } + } +} \ No newline at end of file diff --git a/packages/theme-default/src/table.css b/packages/theme-default/src/table.css index 9c1d4e9e9..ba995d47e 100644 --- a/packages/theme-default/src/table.css +++ b/packages/theme-default/src/table.css @@ -213,16 +213,21 @@ vertical-align: middle; width: 100%; box-sizing: border-box; + + &.highlight { + color: #20a0ff; + } } - & div.caret-wrapper { - position: absolute; - top: 50%; - transform: translateY(-50%); - right: 10px; - width: 10px; - height: 12px; - padding: 0; + & .caret-wrapper { + position: relative; + cursor: pointer; + display: inline-block; + vertical-align: middle; + margin-left: 5px; + margin-top: -2px; + width: 16px; + height: 34px; overflow: initial; } @@ -233,19 +238,20 @@ border: 0; content: ""; position: absolute; + left: 3px; z-index: 2; &.ascending { - top: 0; + top: 11px; border-top: none; border-right: 5px solid transparent; - border-bottom: 5px solid #99A9BF; + border-bottom: 5px solid #99a9bf; border-left: 5px solid transparent; } &.descending { - bottom: 0; - border-top: 5px solid #99A9BF; + bottom: 11px; + border-top: 5px solid #99a9bf; border-right: 5px solid transparent; border-bottom: none; border-left: 5px solid transparent; @@ -333,7 +339,12 @@ z-index: -1; } - @e column-filter-label { + @e column-filter-trigger { + display: inline-block; + line-height: 34px; + margin-left: 5px; + cursor: pointer; + & i { color: #99a9bf; } diff --git a/src/locale/lang/en.js b/src/locale/lang/en.js index 6e75278e3..2e1d78e91 100644 --- a/src/locale/lang/en.js +++ b/src/locale/lang/en.js @@ -68,6 +68,12 @@ export default { delete: 'Delete', preview: 'Preview', continue: 'Continue' + }, + table: { + emptyText: 'No Data', + confirmFilter: 'Confirm', + resetFilter: 'Reset', + clearFilter: 'All' } } }; diff --git a/src/locale/lang/zh-cn.js b/src/locale/lang/zh-cn.js index 7b4b5cbf3..9b4dafb8b 100644 --- a/src/locale/lang/zh-cn.js +++ b/src/locale/lang/zh-cn.js @@ -68,6 +68,12 @@ export default { delete: '删除', preview: '查看图片', continue: '继续上传' + }, + table: { + emptyText: '暂无数据', + confirmFilter: '筛选', + resetFilter: '重置', + clearFilter: '全部' } } }; diff --git a/test/unit/specs/table.spec.js b/test/unit/specs/table.spec.js index 470e2887d..0f4d304c7 100644 --- a/test/unit/specs/table.spec.js +++ b/test/unit/specs/table.spec.js @@ -54,7 +54,7 @@ describe('Table', () => { }); it('row data', () => { - const cells = toArray(vm.$el.querySelectorAll('.cell')) + const cells = toArray(vm.$el.querySelectorAll('td .cell')) .map(node => node.textContent); expect(cells).to.eql(testDataArr); @@ -591,7 +591,7 @@ describe('Table', () => { it('ascending', done => { const elm = vm.$el.querySelector('.caret-wrapper'); - elm.parentNode.click(); + elm.click(); setTimeout(_ => { const lastCells = vm.$el.querySelectorAll('.el-table__body-wrapper tbody tr td:last-child'); expect(toArray(lastCells).map(node => node.textContent)) @@ -603,7 +603,7 @@ describe('Table', () => { it('descending', done => { const elm = vm.$el.querySelector('.caret-wrapper'); - elm.parentNode.click(); + elm.click(); setTimeout(_ => { const lastCells = vm.$el.querySelectorAll('.el-table__body-wrapper tbody tr td:last-child'); expect(toArray(lastCells).map(node => node.textContent))