├── .eslintignore
├── .prettierignore
├── babel.config.js
├── dist
├── favicon.ico
├── fonts
│ ├── element-icons.535877f5.woff
│ └── element-icons.732389de.ttf
├── index.html
└── css
│ └── index.ea19c4a4.css
├── public
├── favicon.ico
└── index.html
├── example
├── assets
│ └── logo.png
├── components
│ ├── virtual-table
│ │ ├── README.md
│ │ ├── Column.jsx
│ │ ├── ColumnGroup.jsx
│ │ ├── constants.js
│ │ ├── helpers
│ │ │ ├── store
│ │ │ │ ├── createStore.js
│ │ │ │ └── connect.js
│ │ │ ├── dom
│ │ │ │ ├── scrollbar-width.js
│ │ │ │ └── index.js
│ │ │ ├── withStoreConnect.js
│ │ │ ├── ColumnManager.js
│ │ │ ├── TreeDataAdapter.js
│ │ │ └── utils
│ │ │ │ └── index.js
│ │ ├── TableBody.jsx
│ │ ├── ColGroup.jsx
│ │ ├── Table.jsx
│ │ ├── TableHeader.jsx
│ │ ├── TableHeaderRow.jsx
│ │ ├── interface.js
│ │ ├── TableBodyRow.jsx
│ │ └── table.css
│ ├── TableRowExpansionItem
│ │ ├── styles.css
│ │ └── index.jsx
│ ├── FeatureList.vue
│ ├── DescPannel.vue
│ └── TableExpandAction
│ │ └── index.vue
├── main.js
└── App.vue
├── src
├── Column.jsx
├── ColumnGroup.jsx
├── utils
│ ├── isValidElement.js
│ ├── dom
│ │ ├── scroll.js
│ │ └── getScrollBarSize.js
│ ├── debounce.js
│ ├── rowKey.js
│ ├── useRaf.js
│ ├── type.js
│ ├── expand.js
│ └── column.js
├── ColGroup.jsx
├── types
│ └── index.d.ts
├── mixins
│ ├── withSelection.js
│ ├── withVirtualization.js
│ └── withExpansion.js
├── TableBody
│ ├── ExpandedRow.jsx
│ ├── index.jsx
│ └── BodyRow.jsx
├── TableHeader
│ ├── HeaderRow.jsx
│ ├── FixedHeader.jsx
│ └── index.jsx
├── TableCell
│ └── index.jsx
├── interface.js
└── table.css
├── vue.config.js
├── .gitignore
├── .prettierrc.js
├── .eslintrc.js
├── package.json
├── CHANGELOG.md
├── stylelint.config.js
└── README.md
/.eslintignore:
--------------------------------------------------------------------------------
1 | /dist/
2 | /tests/unit/coverage/
3 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | **/*.md
2 | **/*.svg
3 | **/*.ejs
4 | **/*.html
5 | package.json
6 |
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: ['@vue/cli-plugin-babel/preset']
3 | }
4 |
--------------------------------------------------------------------------------
/dist/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/givingwu/vue-virtualized-table/HEAD/dist/favicon.ico
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/givingwu/vue-virtualized-table/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/example/assets/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/givingwu/vue-virtualized-table/HEAD/example/assets/logo.png
--------------------------------------------------------------------------------
/dist/fonts/element-icons.535877f5.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/givingwu/vue-virtualized-table/HEAD/dist/fonts/element-icons.535877f5.woff
--------------------------------------------------------------------------------
/dist/fonts/element-icons.732389de.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/givingwu/vue-virtualized-table/HEAD/dist/fonts/element-icons.732389de.ttf
--------------------------------------------------------------------------------
/example/components/virtual-table/README.md:
--------------------------------------------------------------------------------
1 | # ``
2 |
3 | ## Features
4 |
5 | + 支持自定义单元格
6 | + 支持虚拟化滚动(暂不支持变量高度,仅支持固定高度)
7 |
8 | ## Usage
9 |
10 |
--------------------------------------------------------------------------------
/example/main.js:
--------------------------------------------------------------------------------
1 | import Vue from 'vue'
2 | import App from './App.vue'
3 |
4 | Vue.config.productionTip = false
5 |
6 | new Vue({
7 | render: (h) => h(App)
8 | }).$mount('#app')
9 |
--------------------------------------------------------------------------------
/src/Column.jsx:
--------------------------------------------------------------------------------
1 | /* Forked from ant-design-vue 💖 */
2 | /* eslint-disable vue/require-default-prop */
3 | import { ColumnProps } from './interface'
4 |
5 | export default {
6 | name: 'TableColumn',
7 | props: ColumnProps
8 | }
9 |
--------------------------------------------------------------------------------
/example/components/virtual-table/Column.jsx:
--------------------------------------------------------------------------------
1 | /* Forked from ant-design-vue 💖 */
2 | /* eslint-disable vue/require-default-prop */
3 | import { ColumnProps } from './interface'
4 |
5 | export default {
6 | name: 'TableColumn',
7 | props: ColumnProps
8 | }
9 |
--------------------------------------------------------------------------------
/vue.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | publicPath: process.env.NODE_ENV === 'production' ? './' : '/',
3 | pages: {
4 | index: {
5 | entry: './example/main.js',
6 | template: './public/index.html',
7 | title: 'vue-virtualized-table example'
8 | }
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/src/ColumnGroup.jsx:
--------------------------------------------------------------------------------
1 | /* Forked from ant-design-vue 💖 */
2 | /* eslint-disable vue/require-default-prop */
3 | export default {
4 | name: "TableColumnGroup",
5 | props: {
6 | title: {
7 | type: String,
8 | default: "",
9 | },
10 | },
11 | isTableColumnGroup: true,
12 | __TABLE_COLUMN_GROUP: true,
13 | }
14 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 |
4 | # local env files
5 | .env.local
6 | .env.*.local
7 |
8 | # Log files
9 | npm-debug.log*
10 | yarn-debug.log*
11 | yarn-error.log*
12 | package-lock.json
13 |
14 | # Editor directories and files
15 | .idea
16 | .vscode
17 | *.suo
18 | *.ntvs*
19 | *.njsproj
20 | *.sln
21 | *.sw?
22 | *.lock
23 |
--------------------------------------------------------------------------------
/example/components/virtual-table/ColumnGroup.jsx:
--------------------------------------------------------------------------------
1 | /* Forked from ant-design-vue 💖 */
2 | /* eslint-disable vue/require-default-prop */
3 | import PropTypes from 'vue-types'
4 |
5 | export default {
6 | name: 'TableColumnGroup',
7 | props: {
8 | title: PropTypes.any
9 | },
10 | isTableColumnGroup: true,
11 | __TABLE_COLUMN_GROUP: true
12 | }
13 |
--------------------------------------------------------------------------------
/src/utils/isValidElement.js:
--------------------------------------------------------------------------------
1 | /**
2 | * isValidElement
3 | * @param {Vue.VNode} element
4 | */
5 | export function isValidElement(element) {
6 | return (
7 | element &&
8 | typeof element === "object" &&
9 | "componentOptions" in element &&
10 | "context" in element &&
11 | element.tag !== undefined
12 | ) // remove text node
13 | }
14 |
--------------------------------------------------------------------------------
/.prettierrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | arrowParens: 'always',
3 | bracketSpacing: true,
4 | htmlWhitespaceSensitivity: 'css',
5 | insertPragma: false,
6 | jsxBracketSameLine: false,
7 | jsxSingleQuote: false,
8 | printWidth: 80,
9 | proseWrap: 'never',
10 | quoteProps: 'as-needed',
11 | requirePragma: false,
12 | semi: false,
13 | singleQuote: true,
14 | tabWidth: 2,
15 | trailingComma: 'none',
16 | useTabs: false,
17 | vueIndentScriptAndStyle: true
18 | }
19 |
--------------------------------------------------------------------------------
/example/components/TableRowExpansionItem/styles.css:
--------------------------------------------------------------------------------
1 | .expand-icon {
2 | display: flex;
3 | align-items: center;
4 | justify-content: center;
5 | min-width: 14px;
6 | min-height: 14px;
7 | width: 14px;
8 | height: 14px;
9 | margin-right: 8px;
10 | user-select: none;
11 | cursor: pointer;
12 | border: 1px solid #909399;
13 | }
14 |
15 | .expand-icon:hover {
16 | border: 1px solid #3d82e1;
17 | }
18 |
19 | .expand-icon > i.icon {
20 | color: #393939;
21 | font-size: 9px;
22 | }
23 |
24 | .expand-icon:hover > i.icon {
25 | color: #3d82e1;
26 | }
27 |
--------------------------------------------------------------------------------
/src/utils/dom/scroll.js:
--------------------------------------------------------------------------------
1 | /**
2 | * forceScroll
3 | * @param {number} scrollLeft
4 | * @param {HTMLDivElement} target
5 | */
6 | export function forceScroll(scrollLeft, target) {
7 | if (target && target.scrollLeft !== scrollLeft) {
8 | target.scrollLeft = scrollLeft
9 | }
10 | }
11 |
12 | /**
13 | * forceScrollTop
14 | * @param {number} scrollTop
15 | * @param {HTMLDivElement} target
16 | */
17 | export function forceScrollTop(scrollTop, target) {
18 | if (target && target.scrollTop !== scrollTop) {
19 | target.scrollTop = scrollTop
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/example/components/FeatureList.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
{{ title }}
4 |
5 | - {{text}}
6 |
7 |
8 |
9 |
10 |
18 |
19 |
34 |
35 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | <%= htmlWebpackPlugin.options.title %>
10 |
11 |
12 |
13 |
17 |
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/src/utils/debounce.js:
--------------------------------------------------------------------------------
1 | export function debounce(func, wait, immediate) {
2 | let timeout
3 | function debounceFunc(...args) {
4 | const context = this
5 |
6 | // https://fb.me/react-event-pooling
7 | if (args[0] && args[0].persist) {
8 | args[0].persist()
9 | }
10 |
11 | const later = () => {
12 | timeout = null
13 |
14 | if (!immediate) {
15 | func.apply(context, args)
16 | }
17 | }
18 |
19 | const callNow = immediate && !timeout
20 |
21 | clearTimeout(timeout)
22 |
23 | timeout = setTimeout(later, wait)
24 |
25 | if (callNow) {
26 | func.apply(context, args)
27 | }
28 | }
29 |
30 | debounceFunc.cancel = function cancel() {
31 | if (timeout) {
32 | clearTimeout(timeout)
33 | timeout = null
34 | }
35 | }
36 |
37 | return debounceFunc
38 | }
39 |
--------------------------------------------------------------------------------
/dist/index.html:
--------------------------------------------------------------------------------
1 | vue-virtualized-table example
--------------------------------------------------------------------------------
/example/components/DescPannel.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
{{title}}
4 |
{{desc}}
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
36 |
37 |
38 |
53 |
54 |
--------------------------------------------------------------------------------
/src/utils/rowKey.js:
--------------------------------------------------------------------------------
1 | /**
2 | * getRowKeyFn
3 | * @param {import('../types/index').RowModel} row
4 | * @param {number|undefined} index
5 | * @param {string} rowKey
6 | * @returns {string|number}
7 | */
8 | export const getRowKey = (row, index, rowKey = '') => {
9 | let key
10 |
11 | if (!rowKey) {
12 | throw new Error('rowKey is required when get row identity')
13 | }
14 |
15 | if (!row) throw new Error('row is required when get row identity')
16 |
17 | if (typeof rowKey === 'string') {
18 | if (rowKey.indexOf('.') < 0) {
19 | key = row[rowKey]
20 | key = +key || key // when number type
21 | }
22 |
23 | let keyStr = rowKey.split('.')
24 | let current = row
25 |
26 | for (let i = 0; i < keyStr.length; i++) {
27 | current = current[keyStr[i]]
28 | }
29 |
30 | key = current
31 | } else if (typeof rowKey === 'function') {
32 | key = rowKey(row, index)
33 | }
34 |
35 | return key === undefined ? index : key
36 | }
37 |
--------------------------------------------------------------------------------
/example/components/virtual-table/constants.js:
--------------------------------------------------------------------------------
1 | export const SIDE_EFFECTS = [
2 | 'expandDepth',
3 | 'expandedRowKeys',
4 | 'defaultExpandAllRows',
5 | 'rowSelectionType',
6 | 'selectedRowKeys'
7 | ]
8 |
9 | export const DEFAULT_COMPONENTS = {
10 | wrapper: 'div',
11 | table: 'table',
12 | header: {
13 | wrapper: 'thead',
14 | row: 'tr',
15 | cell: 'th'
16 | },
17 | body: {
18 | wrapper: 'tbody',
19 | row: 'tr',
20 | cell: 'td'
21 | },
22 | select: 'select',
23 | radio: 'radio',
24 | checkbox: 'checkbox',
25 | dropdown: 'select'
26 | }
27 |
28 | export const DEFAULT_LOCALE = {
29 | filterTitle: '过滤器',
30 | filterConfirm: '确定',
31 | filterReset: '重置',
32 | emptyText: '暂无数据',
33 | selectAll: '选择全部',
34 | selectInvert: '反选',
35 | sortTitle: '排序'
36 | }
37 |
38 | export const STYLE_WRAPPER = {
39 | position: 'relative'
40 | }
41 |
42 | export const STYLE_INNER = {
43 | position: 'relative',
44 | width: '100%',
45 | willChange: 'transform',
46 | WebkitOverflowScrolling: 'touch'
47 | }
48 |
--------------------------------------------------------------------------------
/example/components/virtual-table/helpers/store/createStore.js:
--------------------------------------------------------------------------------
1 | /* Forked from `ant-design` */
2 |
3 | /**
4 | * createStore
5 | * @param {{}} initialState
6 | */
7 | export default function createStore(initialState) {
8 | let state = initialState
9 | const listeners = []
10 |
11 | /**
12 | * setState
13 | * @param {{}} partial
14 | */
15 | function setState(partial) {
16 | state = { ...state, ...partial }
17 |
18 | for (let i = 0; i < listeners.length; i++) {
19 | listeners[i]()
20 | }
21 | }
22 |
23 | /**
24 | * getState
25 | * @returns {{}}
26 | */
27 | function getState() {
28 | return state
29 | }
30 |
31 | /**
32 | * subscribe
33 | * @param {(state: {}) => ()} listener
34 | * @returns {() => void}
35 | */
36 | function subscribe(listener) {
37 | listeners.push(listener)
38 |
39 | return function unsubscribe() {
40 | const index = listeners.indexOf(listener)
41 | listeners.splice(index, 1)
42 | }
43 | }
44 |
45 | return {
46 | setState,
47 | getState,
48 | subscribe
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/utils/useRaf.js:
--------------------------------------------------------------------------------
1 | // import raf from "raf"
2 |
3 | /**
4 | * @see {@link https://www.npmjs.com/package/raf}
5 | * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame}
6 | *
7 | * The method takes a callback as an argument to be invoked before the repaint.
8 | *
9 | * @param {Function} callback callback is the function to invoke in the next frame.
10 | * @returns {Function}
11 | *
12 | * @usage
13 | * ```js
14 | var raf = require('raf')
15 |
16 | raf(function tick() {
17 | // Animation logic
18 | raf(tick)
19 | })
20 | * ```
21 | */
22 | export function useRaf(callback) {
23 | /**
24 | * handle is a long integer value that uniquely identifies the entry in the callback list.
25 | * This is a non-zero value, but you may not make any other assumptions about its value.
26 | */
27 | const handle = requestAnimationFrame(callback)
28 |
29 | /**
30 | * handle is the entry identifier returned by raf().
31 | * Removes the queued animation frame callback
32 | * (other queued callbacks will still be invoked unless cancelled).
33 | */
34 | return function cancelUseRef() {
35 | return cancelAnimationFrame(handle)
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/utils/dom/getScrollBarSize.js:
--------------------------------------------------------------------------------
1 | /* Forked from `https://github.com/react-component/util/edit/master/src/getScrollBarSize.js` */
2 | let cached
3 |
4 | export default function getScrollBarSize(fresh) {
5 | if (typeof document === "undefined") {
6 | return 0
7 | }
8 |
9 | if (fresh || cached === undefined) {
10 | const inner = document.createElement("div")
11 | inner.style.width = "100%"
12 | inner.style.height = "200px"
13 |
14 | const outer = document.createElement("div")
15 | const outerStyle = outer.style
16 |
17 | outerStyle.position = "absolute"
18 | outerStyle.top = 0
19 | outerStyle.left = 0
20 | outerStyle.pointerEvents = "none"
21 | outerStyle.visibility = "hidden"
22 | outerStyle.width = "200px"
23 | outerStyle.height = "150px"
24 | outerStyle.overflow = "hidden"
25 |
26 | outer.appendChild(inner)
27 |
28 | document.body.appendChild(outer)
29 |
30 | const widthContained = inner.offsetWidth
31 | outer.style.overflow = "scroll"
32 | let widthScroll = inner.offsetWidth
33 |
34 | if (widthContained === widthScroll) {
35 | widthScroll = outer.clientWidth
36 | }
37 |
38 | document.body.removeChild(outer)
39 |
40 | cached = widthContained - widthScroll
41 | }
42 |
43 | return cached
44 | }
45 |
--------------------------------------------------------------------------------
/src/utils/type.js:
--------------------------------------------------------------------------------
1 | import { isValidElement } from './isValidElement'
2 | /**
3 | * isFunction
4 | * @param {Function|any} fn
5 | */
6 | export const isFunction = (fn) => typeof fn === 'function'
7 |
8 | /**
9 | * isObject
10 | * @param {any} obj
11 | */
12 | export const isObject = (obj) => obj && typeof obj === 'object'
13 |
14 | export const isArray = Array.isArray
15 |
16 | /**
17 | * isValidArray
18 | * @param {Array|any} array
19 | */
20 | export const isValidArray = (array) => isArray(array) && !!array.length
21 |
22 | /**
23 | * isString
24 | * @param {string|any}} string
25 | */
26 | export const isString = (string) => typeof string === 'string'
27 |
28 | /**
29 | * isNumber
30 | * @param {number|any} number
31 | */
32 | export const isNumber = (number) => typeof number === 'number'
33 |
34 | /**
35 | * isValidValue
36 | * @param {any} value
37 | */
38 | export const isValidValue = (value) =>
39 | value !== null && value !== undefined && value !== ''
40 |
41 | export const noop = () => {}
42 |
43 | export const toArray = (children) => {
44 | let ret = []
45 |
46 | children.forEach((child) => {
47 | if (Array.isArray(child)) {
48 | ret = ret.concat(toArray(child))
49 | } else if (isValidElement(child) && child.props) {
50 | ret = ret.concat(toArray(child.props.children))
51 | } else {
52 | ret.push(child)
53 | }
54 | })
55 |
56 | return ret
57 | }
58 |
--------------------------------------------------------------------------------
/src/ColGroup.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable vue/require-default-prop */
2 | // Forked form https://github.com/react-component/table/blob/master/src/ColGroup.tsx
3 | import { isNumber } from './utils/type'
4 | import { TableProps } from './interface'
5 |
6 | export default {
7 | name: 'ColGroup',
8 | functional: true,
9 | props: {
10 | colWidths: Array,
11 | columns: TableProps.columns
12 | },
13 |
14 | render(h, { props }) {
15 | const { colWidths = [], columns = [] } = props
16 | const cols = []
17 | const len = columns.length
18 |
19 | // Only insert col with width & additional props
20 | // Skip if rest col do not have any useful info
21 | let mustInsert = false
22 | for (let i = len - 1; i >= 0; i -= 1) {
23 | let width = colWidths[i] || (columns[i] || {}).width
24 | width = isNumber(width) || isNumber(+width) ? width + 'px' : width
25 |
26 | const column = columns && columns[i]
27 | // const additionalProps = column && column[INTERNAL_COL_DEFINE]
28 |
29 | if (width || /* additionalProps || */ mustInsert) {
30 | cols.unshift(
31 |
37 | )
38 | mustInsert = true
39 | }
40 | }
41 |
42 | return {cols}
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/example/components/virtual-table/TableBody.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable vue/require-default-prop */
2 | import PropTypes from 'vue-types'
3 | import TableBodyRow from './TableBodyRow'
4 | import { ColumnProps, TableComponents, TableProps } from './interface'
5 |
6 | export default {
7 | name: 'TableBody',
8 |
9 | inheritAttrs: false,
10 |
11 | props: {
12 | rows: TableProps.dataSource,
13 | fixed: ColumnProps.fixed.def(false),
14 | columns: PropTypes.arrayOf(ColumnProps),
15 | getRowKey: PropTypes.func.isRequired,
16 | prefixCls: TableProps.prefixCls,
17 | components: TableComponents
18 | },
19 |
20 | render() {
21 | const { fixed, columns, prefixCls, components } = this.$props
22 | const TableBody = components.body.wrapper
23 |
24 | return (
25 |
26 | {this.rows.map((row, index) => {
27 | const rowKey = this.getRowKey(row, index)
28 | const attrs = {
29 | inheritAttrs: false,
30 | key: rowKey,
31 | props: {
32 | fixed,
33 | row,
34 | rowKey,
35 | index,
36 | prefixCls,
37 | columns,
38 | components
39 | },
40 | on: this.$listeners
41 | }
42 |
43 | return
44 | })}
45 |
46 | )
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/types/index.d.ts:
--------------------------------------------------------------------------------
1 | import { VNode } from "vue";
2 |
3 | interface ColumnConfig {
4 | /* 列配置 */
5 | label?: string
6 | prop?: string
7 | key: string | number
8 | width: number | string /* % */
9 | render: (
10 | h: Function,
11 | param: ColumnParam
12 | ) => VNode | null /* must return a vnode instance */
13 | // filter(h: Function, param: ColumnParam)?: void; /* 暂未实现 */
14 | // sortBy(h: Function, param: ColumnParam)?: void; /* 暂未实现 */
15 | }
16 |
17 | interface ColumnParam {
18 | /* 列参数对象 */
19 | row: RowModel // 当前数据项 对应后端传入 list 的当前项的 model
20 | rows: Array
21 | index: number
22 | rowIndex: number
23 | column: ColumnConfig
24 | columnIndex: number
25 | store: TableStore
26 | }
27 |
28 | interface TableStore {
29 | isRowExpanded(row: RowModel): void /* 判断当前行是否是展开状态 */
30 | toggleRowExpansion(
31 | row: RowModel,
32 | expanded?: boolean
33 | ) /* 切换当前行展开收起状态 */
34 | isRowSelected(row: RowModel) /* 判断当前行是否选中状态 */
35 | toggleRowSelection(
36 | row: RowModel,
37 | selected?: boolean
38 | ) /* 切换当前行是否选中状态 */
39 | // filter /* 暂未实现 */
40 | // sortBy /* 暂未实现 */
41 | }
42 |
43 | interface TableProps {
44 | }
45 |
46 | type RowKey = number | string
47 |
48 | interface RowModel {
49 | rowKey: RowKey /* [RowModel]rowKey 的值必须在 Array 中唯一 */
50 | children?: RowModel[]
51 | __parent?: RowModel | null /* 父节点 */
52 | __index: number
53 | __depth: number
54 | [prop as string]: any
55 | }
56 |
--------------------------------------------------------------------------------
/example/components/TableRowExpansionItem/index.jsx:
--------------------------------------------------------------------------------
1 | import './styles.css'
2 |
3 | export default {
4 | name: 'TableRowExpandItem',
5 |
6 | props: {
7 | store: {
8 | type: Object,
9 | required: true
10 | },
11 | row: {
12 | type: Object,
13 | required: true
14 | },
15 | column: {
16 | type: Object,
17 | required: true
18 | },
19 | depth: {
20 | type: Number,
21 | required: true
22 | },
23 | indentSize: {
24 | type: Number,
25 | default: 20,
26 | required: true
27 | }
28 | },
29 |
30 | computed: {
31 | className() {
32 | return this.expanded ? 'el-icon-minus' : 'el-icon-plus'
33 | },
34 | expanded() {
35 | return this.store.isRowExpanded(this.row)
36 | },
37 | title() {
38 | return '点击' + (this.expanded ? '收起' : '展开')
39 | },
40 | style() {
41 | return {
42 | marginLeft: this.indentSize * this.depth + 'px'
43 | }
44 | }
45 | },
46 |
47 | methods: {
48 | handleExpand(e) {
49 | e.preventDefault()
50 | e.stopPropagation()
51 |
52 | this.store.toggleRowExpansion(this.row)
53 | }
54 | },
55 |
56 | render() {
57 | return (
58 |
64 |
65 |
66 | )
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/example/components/virtual-table/helpers/dom/scrollbar-width.js:
--------------------------------------------------------------------------------
1 | let scrollbarVerticalSize
2 | let scrollbarHorizontalSize
3 |
4 | // Measure scrollbar width for padding body during modal show/hide
5 | const scrollbarMeasure = {
6 | position: 'absolute',
7 | top: '-9999px',
8 | width: '50px',
9 | height: '50px'
10 | }
11 |
12 | export function measureScrollbar(direction = 'vertical') {
13 | if (typeof document === 'undefined' || typeof window === 'undefined') {
14 | return 0
15 | }
16 |
17 | const isVertical = direction === 'vertical'
18 |
19 | if (isVertical && scrollbarVerticalSize) {
20 | return scrollbarVerticalSize
21 | } else if (!isVertical && scrollbarHorizontalSize) {
22 | return scrollbarHorizontalSize
23 | }
24 |
25 | const scrollDiv = document.createElement('div')
26 |
27 | Object.keys(scrollbarMeasure).forEach((scrollProp) => {
28 | scrollDiv.style[scrollProp] = scrollbarMeasure[scrollProp]
29 | })
30 |
31 | // Append related overflow style
32 | if (isVertical) {
33 | scrollDiv.style.overflowY = 'scroll'
34 | } else {
35 | scrollDiv.style.overflowX = 'scroll'
36 | }
37 |
38 | document.body.appendChild(scrollDiv)
39 |
40 | let size = 0
41 |
42 | if (isVertical) {
43 | size = scrollDiv.offsetWidth - scrollDiv.clientWidth
44 | scrollbarVerticalSize = size
45 | } else if (!isVertical) {
46 | size = scrollDiv.offsetHeight - scrollDiv.clientHeight
47 | scrollbarHorizontalSize = size
48 | }
49 |
50 | document.body.removeChild(scrollDiv)
51 |
52 | return size
53 | }
54 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | parserOptions: {
4 | sourceType: 'module'
5 | },
6 | extends: [
7 | // https://github.com/vuejs/eslint-plugin-vue#bulb-rules
8 | 'plugin:vue/recommended',
9 | // https://github.com/standard/standard/blob/master/docs/RULES-en.md
10 | 'standard',
11 | // https://github.com/prettier/eslint-config-prettier
12 | 'prettier',
13 | 'prettier/standard',
14 | 'prettier/vue'
15 | ],
16 | rules: {
17 | // Only allow debugger in development
18 | 'no-debugger': process.env.PRE_COMMIT ? 'error' : 'off',
19 | // Only allow `console.log` in development
20 | 'no-console': process.env.PRE_COMMIT
21 | ? ['error', { allow: ['warn', 'error'] }]
22 | : 'off',
23 | 'vue/component-name-in-template-casing': [
24 | 'error',
25 | 'PascalCase',
26 | {
27 | ignores: [
28 | 'component',
29 | 'template',
30 | 'transition',
31 | 'transition-group',
32 | 'keep-alive',
33 | 'slot'
34 | ]
35 | }
36 | ]
37 | },
38 | overrides: [
39 | {
40 | files: ['src/**/*', 'tests/unit/**/*', 'tests/e2e/**/*'],
41 | excludedFiles: 'app.config.js',
42 | parserOptions: {
43 | parser: 'babel-eslint',
44 | sourceType: 'module'
45 | },
46 | env: {
47 | browser: true
48 | }
49 | },
50 | {
51 | files: ['**/*.unit.js'],
52 | parserOptions: {
53 | parser: 'babel-eslint',
54 | sourceType: 'module'
55 | },
56 | env: { jest: true },
57 | globals: {
58 | mount: false,
59 | shallowMount: false,
60 | shallowMountView: false,
61 | createComponentMocks: false,
62 | createModuleStore: false
63 | }
64 | }
65 | ]
66 | }
67 |
--------------------------------------------------------------------------------
/example/components/virtual-table/helpers/withStoreConnect.js:
--------------------------------------------------------------------------------
1 | import { getOptionProps } from './utils'
2 | // import fastDeepEqual from 'fast-deep-equal'
3 |
4 | /**
5 | * Connect in Vue.js
6 | * @see
7 | * {@link https://gist.github.com/gaearon/1d19088790e70ac32ea636c025ba424e connect.js}
8 | * {@link https://github.com/vueComponent/ant-design-vue/blob/e63f9ea671/components/_util/store/connect.jsx ant-design-vue/connect.jsx}
9 | */
10 | export const withStoreConnect = (mapStateToProps) => {
11 | const shouldSubscribe = !!mapStateToProps
12 |
13 | return {
14 | data() {
15 | this.preProps = getOptionProps(this)
16 | const state = mapStateToProps(this.store.getState(), this.$props)
17 |
18 | return {
19 | state
20 | }
21 | },
22 |
23 | created() {
24 | this.trySubscribe()
25 | },
26 |
27 | beforeDestroy() {
28 | this.tryUnsubscribe()
29 | },
30 |
31 | methods: {
32 | handleChange() {
33 | if (!this.unsubscribe) {
34 | return
35 | }
36 |
37 | const props = getOptionProps(this)
38 | const nextState = mapStateToProps(this.store.getState(), props)
39 |
40 | /**
41 | * fix: 没有必要比对,比对反而降低性能?因为 Vue.js 本身 Reactive 的实现已经做了脏检测
42 | */
43 | /* if (!this._q(this.preProps, props) || !this._q(this.state, nextState)) {
44 | this.state = nextState
45 | } */
46 | this.state = nextState
47 | },
48 |
49 | trySubscribe() {
50 | if (shouldSubscribe) {
51 | this.unsubscribe = this.store.subscribe(this.handleChange)
52 | this.handleChange()
53 | }
54 | },
55 |
56 | tryUnsubscribe() {
57 | if (this.unsubscribe) {
58 | this.unsubscribe()
59 | this.unsubscribe = null
60 | }
61 | }
62 | }
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/example/components/virtual-table/ColGroup.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable vue/require-default-prop */
2 | import PropTypes from 'vue-types'
3 | import { ColumnProps } from './interface'
4 |
5 | export default {
6 | name: 'ColGroup',
7 |
8 | props: {
9 | fixed: ColumnProps.fixed,
10 | columns: PropTypes.arrayOf(ColumnProps).isRequired
11 | },
12 |
13 | inject: {
14 | table: { default: () => ({}) }
15 | },
16 |
17 | watch: {
18 | // FIXME: columns 属性变化未触发 render,所以使用了 $forceUpdate,应该有更好的方法。
19 | // 比如说使用 createStore
20 | columns(val, oldVal) {
21 | if (oldVal !== val || oldVal.length !== val.length) {
22 | this.$forceUpdate()
23 | }
24 | }
25 | },
26 |
27 | render() {
28 | const { fixed, table } = this
29 | const { prefixCls, expandIconAsCell, columnManager } = table
30 |
31 | let cols = []
32 |
33 | if (expandIconAsCell && fixed !== 'right') {
34 | cols.push(
35 |
39 | )
40 | }
41 |
42 | let leafColumns
43 |
44 | if (fixed === 'left') {
45 | leafColumns = columnManager.leftLeafColumns()
46 | } else if (fixed === 'right') {
47 | leafColumns = columnManager.rightLeafColumns()
48 | } else {
49 | leafColumns = columnManager.leafColumns()
50 | }
51 |
52 | cols = cols.concat(
53 | leafColumns.map((c) => {
54 | const width = typeof c.width === 'number' ? `${c.width}px` : c.width
55 |
56 | return (
57 |
63 | )
64 | })
65 | )
66 |
67 | return {cols}
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/src/mixins/withSelection.js:
--------------------------------------------------------------------------------
1 | import { SINGLE_SELECTION_MODE } from '../interface'
2 |
3 | export function data() {
4 | if (this.rowSelection) {
5 | const { selectedRowKeys = [] } = this.rowSelection || {}
6 |
7 | // The default select mode is `radio`
8 | if (!this.rowSelection.type) {
9 | this.rowSelection.type = SINGLE_SELECTION_MODE
10 | }
11 |
12 | return {
13 | // The below data will be passed in form component props
14 | selectedRowKeys
15 | }
16 | }
17 | }
18 |
19 | export const methods = {
20 | /**
21 | * 判断当前行是否是选中状态
22 | * @param {RowKey} rowKey
23 | */
24 | isRowSelected(rowKey) {
25 | return this.selectedRowKeys.includes(this.adaptRowKey(rowKey))
26 | },
27 |
28 | /**
29 | * 当前行是否选中状态
30 | * @param {RowKey} rowKey
31 | */
32 | toggleRowSelection(rowKey, nativeEvent) {
33 | if (!rowKey) return
34 |
35 | let record = this.adaptKeyToRow(rowKey)
36 | rowKey = this.adaptRowKey(record)
37 |
38 | if (rowKey) {
39 | const { type, selectedRowKeys = [] } = this.rowSelection || {}
40 | let isSelected = false
41 |
42 | if (type === 'radio') {
43 | isSelected = this.isRowSelected(rowKey)
44 |
45 | if (isSelected) {
46 | this.selectedRowKeys = []
47 | } else {
48 | this.selectedRowKeys = [rowKey]
49 | }
50 | }
51 |
52 | if (type === 'checkbox') {
53 | isSelected = selectedRowKeys.includes(rowKey)
54 |
55 | if (isSelected) {
56 | selectedRowKeys.splice(selectedRowKeys.indexOf(rowKey), 1)
57 | } else {
58 | selectedRowKeys.push(rowKey)
59 | }
60 |
61 | this.selectedRowKeys = selectedRowKeys
62 | }
63 |
64 | const selected = !isSelected
65 |
66 | this.$emit(
67 | 'select-change',
68 | record,
69 | selected,
70 | this.selectedRowKeys,
71 | nativeEvent
72 | )
73 | }
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/src/TableBody/ExpandedRow.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable vue/require-default-prop */
2 | import Cell from '../TableCell/index'
3 |
4 | export default {
5 | name: 'ExpandedRow',
6 |
7 | functional: true,
8 |
9 | inheritAttrs: false,
10 |
11 | inject: ['scrollbarSize'],
12 |
13 | props: {
14 | prefixCls: String,
15 | component: String,
16 | cellComponent: String,
17 | fixHeader: Boolean,
18 | fixColumn: Boolean,
19 | horizonScroll: Boolean,
20 | componentWidth: Number,
21 | className: String,
22 | expanded: Boolean,
23 | children: [Object, Array],
24 | colSpan: Number
25 | },
26 |
27 | render(h, { props, children, injections }) {
28 | const {
29 | prefixCls,
30 | children: propsChildren,
31 | component: Component,
32 | cellComponent,
33 | fixHeader,
34 | fixColumn,
35 | className,
36 | expanded,
37 | colSpan,
38 | scrollbarSize,
39 | componentWidth
40 | } = {
41 | ...props,
42 | ...injections
43 | }
44 | let contentNode = children || propsChildren
45 |
46 | if (fixColumn && componentWidth) {
47 | contentNode = (
48 |
57 | {contentNode}
58 |
59 | )
60 | }
61 |
62 | return (
63 |
69 |
75 | {contentNode}
76 | |
77 |
78 | )
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/src/TableHeader/HeaderRow.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable vue/require-default-prop */
2 | import { getColumnsKey, getCellFixedInfo } from '../utils/column'
3 | import { TableProps, STRING_PROP } from '../interface'
4 | import Cell from '../TableCell/index'
5 |
6 | export default {
7 | name: 'HeaderRow',
8 |
9 | functional: true,
10 |
11 | inheritAttrs: false,
12 |
13 | inject: {
14 | prefixCls: TableProps.prefixCls,
15 | direction: STRING_PROP
16 | },
17 |
18 | props: {
19 | cells: {
20 | type: Array,
21 | default: () => []
22 | },
23 | columns: Array,
24 | stickyOffsets: Object,
25 | flattenColumns: Array,
26 | rowComponent: String,
27 | cellComponent: String,
28 | index: Number
29 | },
30 |
31 | render(h, ctx) {
32 | const { props, injections } = ctx
33 | const { prefixCls, direction } = injections
34 | const {
35 | cells,
36 | stickyOffsets,
37 | flattenColumns,
38 | rowComponent: RowComponent,
39 | cellComponent: CellComponent
40 | } = props
41 |
42 | const columnsKey = getColumnsKey(cells.map((cell) => cell.column))
43 |
44 | return (
45 |
46 | {cells.map((cell, cellIndex) => {
47 | const { column } = cell
48 | const fixedInfo = getCellFixedInfo(
49 | cell.colStart,
50 | cell.colEnd,
51 | flattenColumns,
52 | stickyOffsets,
53 | direction
54 | )
55 |
56 | // fix: TableHeaderRow 不渲染 className 否则样式可能引起 table 渲染异常
57 | cell.column = {
58 | ...column,
59 | class: '',
60 | className: ''
61 | }
62 |
63 | const options = {
64 | props: { ...cell, ...fixedInfo, children: [column.label] }
65 | }
66 |
67 | return (
68 | |
77 | )
78 | })}
79 |
80 | )
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/example/components/virtual-table/Table.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable vue/require-default-prop */
2 | import PropTypes from 'vue-types'
3 | import ColGroup from './ColGroup'
4 | import TableHeader from './TableHeader'
5 | import TableBody from './TableBody'
6 | import { TableComponents, TableProps } from './interface'
7 |
8 | export default {
9 | name: 'Table',
10 |
11 | props: {
12 | fixed: TableProps.fixed,
13 | prefixCls: TableProps.prefixCls,
14 | showHeader: PropTypes.bool.def(true),
15 | components: TableComponents.isRequired,
16 | hasHead: PropTypes.bool.def(true),
17 | hasBody: PropTypes.bool.def(true),
18 | scroll: TableProps.scroll.isRequired,
19 | columns: TableProps.columns.isRequired
20 | },
21 |
22 | render() {
23 | const {
24 | prefixCls,
25 | showHeader,
26 | components,
27 | hasHead,
28 | hasBody,
29 | fixed,
30 | scroll,
31 | columns
32 | } = this.$props
33 | const tableStyle = {}
34 |
35 | if (!fixed && scroll.x) {
36 | // not set width, then use content fixed width
37 | if (scroll.x === true) {
38 | tableStyle.tableLayout = 'fixed'
39 | } else {
40 | tableStyle.width =
41 | typeof scroll.x === 'number' ? `${scroll.x}px` : scroll.x
42 | }
43 | }
44 |
45 | const Table = hasBody ? components.table : 'table'
46 |
47 | return (
48 |
49 |
50 |
51 | {hasHead && (
52 |
60 | )}
61 |
62 | {hasBody && (
63 |
73 | )}
74 |
75 | )
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "vue-virtualized-table",
3 | "version": "0.0.3",
4 | "private": false,
5 | "scripts": {
6 | "serve": "vue-cli-service serve",
7 | "build": "vue-cli-service build",
8 | "lint": "vue-cli-service lint"
9 | },
10 | "files": [
11 | "src"
12 | ],
13 | "dependencies": {
14 | "vue-size-observer": "^0.0.3"
15 | },
16 | "devDependencies": {
17 | "@vue/cli-plugin-babel": "^4.3.0",
18 | "@vue/cli-plugin-eslint": "^4.3.0",
19 | "@vue/cli-service": "^4.3.0",
20 | "@vue/eslint-config-prettier": "^5.0.0",
21 | "@vue/eslint-config-standard": "^4.0.0",
22 | "babel-eslint": "^10.1.0",
23 | "element-ui": "^2.13.2",
24 | "eslint": "^6.7.2",
25 | "eslint-plugin-prettier": "^3.1.3",
26 | "eslint-plugin-vue": "^6.2.2",
27 | "normalize.css": "^8.0.1",
28 | "vue": "^2.6.12",
29 | "vue-template-compiler": "^2.6.12"
30 | },
31 | "eslintConfig": {
32 | "root": true,
33 | "env": {
34 | "node": true
35 | },
36 | "extends": [
37 | "plugin:vue/essential",
38 | "eslint:recommended"
39 | ],
40 | "parserOptions": {
41 | "parser": "babel-eslint"
42 | },
43 | "rules": {}
44 | },
45 | "browserslist": [
46 | "> 1%",
47 | "last 2 versions",
48 | "not dead"
49 | ],
50 | "description": "The second version of implementation of `vue-virtual-table` component, it was inspired from [rc-table](https://github.com/react-component/table) and [ant-table](https://ant.design/components/table), API design is 60%+ consistent. Or you could think I translated them from React to Vue and added *virtualize scroll* feature.",
51 | "main": "src/index.jsx",
52 | "directories": {
53 | "example": "example"
54 | },
55 | "repository": {
56 | "type": "git",
57 | "url": "git+https://github.com/givingwu/vue-virtualized-table.git"
58 | },
59 | "keywords": [
60 | "tree-table",
61 | "vue-tree-table",
62 | "vue-table-tree",
63 | "virtual-table",
64 | "vue-virtual-table",
65 | "vue-virtualized-table"
66 | ],
67 | "author": " ",
68 | "license": "MIT",
69 | "bugs": {
70 | "url": "https://github.com/givingwu/vue-virtualized-table/issues"
71 | },
72 | "homepage": "https://github.com/givingwu/vue-virtualized-table#readme"
73 | }
74 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # vue-virtual-table
2 |
3 | ## Real virtualized table
4 |
5 | 初代版的 TableTree,因为当时使用 element-ui 的 table 组件其功能扩展能力有限,无法满足需求。于是我们自己实现了 TableTree 组件内置了多项扩展并支持自定义渲染 `{ render: ( store: TableStore, row: RowModel, column: ColumnConfig, value: any )}`,而这个 API 也一致延续至今。
6 |
7 | 而后在编写工期项目的时候,因为工期进度记录数据需要渲染的数据量非常大,当渲染上几百条数据时,网页开始变得卡顿。于是实现了第二版的 VirtualTable,即支持了 **virtual-scroll** 的 table 组件。
8 |
9 | 起初 VirtualTable V1 可能会渲染三个 table,即左固定列和右固定列会被独立渲染成 Table 以实现固定效果。出现了内存泄漏和性能问题,显得*虚拟滚动并不虚拟*。
10 |
11 | 而后看到了 antd V4 版本使用 `position: sticky` 实现列固定的效果,才想明白之前分开渲染 左固定列 和 右固定列 的目的是为了兼容 IE11 [CSS position:sticky](https://caniuse.com/#search=sticky) 。本来以为是渲染3个table造成的性能和内存问题,从算法角度看是 O(3),改用 sticky 后是应该变成 O(1) 才是,但是并不理想。
12 |
13 | 反思自己,从一开始的思路就出现了错误,不应该使用 元素来实现复杂的功能。理由如下:
14 |
15 | + table 灵活性限制
16 | + table 学习成本
17 | + table 扩展性差
18 |
19 | 但是在 TableTree V1 到 VirtualTable V1,VirtualTable V2 的过程,发现几点共性:
20 |
21 | 1. TableStateManager 用于维护 Table 本身公共状态,比如在渲染三个 table 时,FixedLeftTable, Table, FixedRightTable 的 hover 状态: currentHoveredRow.
22 | 2. ExpansionManager 用于维护 Table 扩展状态。在普通的 table 中渲染使用递归 `expandRender(item.children)` 渲染子元素,这也是第一版 TableTree 的做法。但是这对于**虚拟滚动**是行不通的。因为虚拟滚动的展开/收起状态更像是 *分段插入* 而非 *递归渲染*。
23 | 3. VirtualizeManager 用于维护 Table 虚拟化的相关状态,比如:virtualizedData = [], currentScrollTop, wrapperHeight 等等。
24 |
25 |
26 | ## Technologies
27 |
28 | 在调试和开发 VirtualTable 组件时,发现的问题:
29 |
30 | 1. 滚动速度过快时,超过了 VirtualDOM Diff + Patch 的执行时间,会明显掉帧,白屏。使用 rAF 也是一样的,因为即使当前 VirtualizeManager 中仅有 5 条 virtualizedData 数据,在每次滚动时执行 Diff + Patch 的时间依赖 TableCell 每个单元格内被渲染组件的复杂度。对于简单的 textNode 还将就,如果是复杂的组件,在我们项目中是 TableFormItem,即 O(n) * (diff + patch), n = TableFormItem 组件的数量。这种情况下 rAF 的 16ms 大于了 16ms。最终加上 debounce + chunk 分段更新,同样无法解决问题。
31 | 2. 后来看了 React-Virtualized 的实现,以及我之前实现的 vue-virtual-list 组件。才想通最好的做法是使用 div 模拟 table,用浏览器的 Repaint 代替 Vue 的 n * (createComponent + Diff + Patch),n = visibleRows.length。加上 GPU 加速和 CSS 分层,性能是最好的。在更新的时候使用 rAF 求出 diffIndex,然后更新 model 再 patch 当前 diffIndex 的 item,实现性能和体验的最优解。换言之,就是每次找到需要被更新的 index 执行 updateComponent 更新 CSS, Component,复杂度由原来的 O(n) 变成 O(1),即当前元素 virtualizedData[diffIndex]。
32 |
33 | ## Features
34 |
35 | 1. **高性能**的*虚拟滚动*
36 | 2. 支持**动态高度**
37 | 3. 支持**树形展开**
38 | 4. 支持**自定义渲染**
39 | 5. 不同实现
40 |
41 |
42 | # V2.0
43 |
44 | 由于依然存在内存泄漏问题,计划使用 div 重写。
45 |
46 | + 如何用 table 实现 table 那样的布局。
47 | + 左侧固定
48 | + 右侧固定
49 | + 头部固定
50 |
--------------------------------------------------------------------------------
/example/components/virtual-table/TableHeader.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable vue/require-default-prop */
2 | import PropTypes from 'vue-types'
3 | import TableHeaderRow from './TableHeaderRow'
4 | import { ColumnProps, TableComponents, TableProps } from './interface'
5 |
6 | function getHeaderRows(columns, currentRow = 0, rows) {
7 | rows = rows || []
8 | rows[currentRow] = rows[currentRow] || []
9 |
10 | columns.forEach((column) => {
11 | if (column.rowSpan && rows.length < column.rowSpan) {
12 | while (rows.length < column.rowSpan) {
13 | rows.push([])
14 | }
15 | }
16 |
17 | const cell = {
18 | key: column.key,
19 | className: column.className || column.class || '',
20 | children: column.label || column.title,
21 | column
22 | }
23 |
24 | if (column.children) {
25 | getHeaderRows(column.children, currentRow + 1, rows)
26 | }
27 |
28 | if ('colSpan' in column) {
29 | cell.colSpan = column.colSpan
30 | }
31 |
32 | if ('rowSpan' in column) {
33 | cell.rowSpan = column.rowSpan
34 | }
35 |
36 | if (cell.colSpan !== 0) {
37 | rows[currentRow].push(cell)
38 | }
39 | })
40 |
41 | return rows.filter((row) => row.length > 0)
42 | }
43 |
44 | export default {
45 | name: 'TableHeader',
46 |
47 | inheritAttrs: false,
48 |
49 | props: {
50 | fixed: ColumnProps.fixed.def(false),
51 | columns: PropTypes.arrayOf(ColumnProps).isRequired,
52 | prefixCls: TableProps.prefixCls,
53 | components: TableComponents
54 | },
55 |
56 | render() {
57 | const { fixed, columns, prefixCls, components } = this
58 | const TableHeaderWrapper = components.header.wrapper
59 | const rows = getHeaderRows(columns)
60 |
61 | return (
62 |
63 | {rows.map((row, index) => {
64 | const attrs = {
65 | key: index,
66 | props: {
67 | ...this.$attrs,
68 | row,
69 | index,
70 | prefixCls,
71 | components,
72 | fixed,
73 | columns,
74 | rows
75 | },
76 | on: {
77 | ...this.$listeners
78 | },
79 | attrs: {
80 | ...this.$attrs
81 | }
82 | }
83 |
84 | return
85 | })}
86 |
87 | )
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/src/TableHeader/FixedHeader.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable vue/require-default-prop */
2 | import Header from './index'
3 | import ColGroup from '../ColGroup'
4 | import { TableProps, NUMBER_PROP } from '../interface'
5 |
6 | export default {
7 | name: 'FixedHeader',
8 |
9 | functional: true,
10 |
11 | inheritAttrs: false,
12 |
13 | inject: {
14 | prefixCls: TableProps.prefixCls,
15 | scrollbarSize: NUMBER_PROP
16 | },
17 |
18 | props: {
19 | colWidths: Array,
20 | columCount: Number,
21 | direction: String,
22 | ...Header.props
23 | },
24 |
25 | render(h, ctx) {
26 | const { props, injections } = ctx
27 | const { prefixCls, scrollbarSize } = injections
28 | const {
29 | columns,
30 | flattenColumns,
31 | colWidths,
32 | columCount,
33 | stickyOffsets,
34 | direction
35 | } = props
36 |
37 | // Add scrollbar column
38 | const lastColumn = flattenColumns[flattenColumns.length - 1]
39 | const ScrollBarColumn = {
40 | fixed: lastColumn ? lastColumn.fixed : null,
41 | class: `${prefixCls}-cell-scrollbar`
42 | }
43 |
44 | const columnsWithScrollbar = scrollbarSize
45 | ? [...columns, ScrollBarColumn]
46 | : columns
47 | const flattenColumnsWithScrollbar = scrollbarSize
48 | ? [...flattenColumns, ScrollBarColumn]
49 | : flattenColumns
50 |
51 | // Calculate the sticky offsets
52 | const { right, left } = stickyOffsets
53 | const headerStickyOffsets = {
54 | ...stickyOffsets,
55 | left:
56 | direction === 'rtl'
57 | ? [...left.map((width) => width + scrollbarSize), 0]
58 | : left,
59 | right:
60 | direction === 'rtl'
61 | ? right
62 | : [...right.map((width) => width + scrollbarSize), 0]
63 | }
64 |
65 | const cloneWidths = []
66 | for (let i = 0; i < columCount; i += 1) {
67 | cloneWidths[i] = colWidths[i]
68 | }
69 | const columnWidthsReady = !colWidths.every((width) => !width)
70 |
71 | return (
72 |
90 | )
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/stylelint.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | extends: [
3 | // Use the Standard config as the base
4 | // https://github.com/stylelint/stylelint-config-standard
5 | 'stylelint-config-standard',
6 | // Enforce a standard order for CSS properties
7 | // https://github.com/stormwarning/stylelint-config-recess-order
8 | 'stylelint-config-recess-order',
9 | // Override rules that would interfere with Prettier
10 | // https://github.com/shannonmoeller/stylelint-config-prettier
11 | 'stylelint-config-prettier',
12 | // Override rules to allow linting of CSS modules
13 | // https://github.com/pascalduez/stylelint-config-css-modules
14 | 'stylelint-config-css-modules',
15 | ],
16 | plugins: [
17 | // Bring in some extra rules for SCSS
18 | 'stylelint-scss',
19 | ],
20 | // Rule lists:
21 | // - https://stylelint.io/user-guide/rules/
22 | // - https://github.com/kristerkari/stylelint-scss#list-of-rules
23 | rules: {
24 | // Allow newlines inside class attribute values
25 | 'string-no-newline': null,
26 | // Enforce camelCase for classes and ids, to work better
27 | // with CSS modules
28 | 'selector-class-pattern': /^[a-z][a-zA-Z]*(-(enter|leave)(-(active|to))?)?$/,
29 | 'selector-id-pattern': /^[a-z][a-zA-Z]*$/,
30 | // Limit the number of universal selectors in a selector,
31 | // to avoid very slow selectors
32 | 'selector-max-universal': 1,
33 | // Disallow allow global element/type selectors in scoped modules
34 | 'selector-max-type': [0, { ignore: ['child', 'descendant', 'compounded'] }],
35 | // ===
36 | // PRETTIER
37 | // ===
38 | // HACK: to compensate for https://github.com/shannonmoeller/stylelint-config-prettier/issues/4
39 | // Modifying setting from Standard: https://github.com/stylelint/stylelint-config-standard/blob/7b76d7d0060f2e13a331806a09c2096c7536b0a6/index.js#L6
40 | 'at-rule-empty-line-before': [
41 | 'always',
42 | {
43 | except: ['blockless-after-same-name-blockless', 'first-nested'],
44 | ignore: ['after-comment'],
45 | ignoreAtRules: ['else'],
46 | },
47 | ],
48 | // ===
49 | // SCSS
50 | // ===
51 | 'scss/dollar-variable-colon-space-after': 'always',
52 | 'scss/dollar-variable-colon-space-before': 'never',
53 | 'scss/dollar-variable-no-missing-interpolation': true,
54 | 'scss/dollar-variable-pattern': /^[a-z-]+$/,
55 | 'scss/double-slash-comment-whitespace-inside': 'always',
56 | 'scss/operator-no-newline-before': true,
57 | 'scss/operator-no-unspaced': true,
58 | 'scss/selector-no-redundant-nesting-selector': true,
59 | // Allow SCSS and CSS module keywords beginning with `@`
60 | 'at-rule-no-unknown': null,
61 | 'scss/at-rule-no-unknown': true,
62 | },
63 | }
64 |
--------------------------------------------------------------------------------
/example/components/TableExpandAction/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | 收起{{label.includes('至') ? label.slice(label.indexOf('至') + 1) : label}}
8 | 展开{{label}}
9 |
10 |
11 |
12 |
13 |
14 |
109 |
110 |
112 |
--------------------------------------------------------------------------------
/src/TableHeader/index.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable vue/require-default-prop */
2 | import { TableProps } from '../interface'
3 | import { isNumber } from '../utils/type'
4 | import HeaderRow from './HeaderRow'
5 |
6 | function parseHeaderRows(rootColumns) {
7 | const rows = []
8 |
9 | function fillRowCells(columns = [], colIndex, rowIndex = 0) {
10 | // Init rows
11 | rows[rowIndex] = rows[rowIndex] || []
12 |
13 | let currentColIndex = colIndex
14 | const colSpans = columns.map((column) => {
15 | const cell = {
16 | key: column.key,
17 | className: column.className || '',
18 | children: column.title,
19 | column,
20 | colStart: currentColIndex
21 | }
22 |
23 | let colSpan = 1
24 |
25 | const subColumns = column.children
26 | if (subColumns && subColumns.length > 0) {
27 | colSpan = fillRowCells(
28 | subColumns,
29 | currentColIndex,
30 | rowIndex + 1
31 | ).reduce((total, count) => total + count, 0)
32 | cell.hasSubColumns = true
33 | }
34 |
35 | if ('colSpan' in column && isNumber(column.colSpan)) {
36 | // eslint-disable-next-line no-extra-semi
37 | ;({ colSpan } = column)
38 | }
39 |
40 | if ('rowSpan' in column && isNumber(column.rowSpan)) {
41 | cell.rowSpan = column.rowSpan
42 | }
43 |
44 | cell.colSpan = colSpan
45 | cell.colEnd = cell.colStart + colSpan - 1
46 | rows[rowIndex].push(cell)
47 |
48 | currentColIndex += colSpan
49 |
50 | return colSpan
51 | })
52 |
53 | return colSpans
54 | }
55 |
56 | // Generate `rows` cell data
57 | fillRowCells(rootColumns, 0)
58 |
59 | // Handle `rowSpan`
60 | const rowCount = rows.length
61 |
62 | for (let rowIndex = 0; rowIndex < rowCount; rowIndex += 1) {
63 | rows[rowIndex].forEach((cell) => {
64 | if (!('rowSpan' in cell) && !cell.hasSubColumns) {
65 | // eslint-disable-next-line no-param-reassign
66 | cell.rowSpan = rowCount - rowIndex
67 | }
68 | })
69 | }
70 |
71 | return rows
72 | }
73 |
74 | export default {
75 | name: 'Header',
76 |
77 | functional: true,
78 |
79 | inheritAttrs: false,
80 |
81 | inject: {
82 | prefixCls: TableProps.prefixCls,
83 | components: TableProps.components
84 | },
85 |
86 | props: {
87 | columns: TableProps.columns,
88 | flattenColumns: TableProps.columns,
89 | stickyOffsets: Object
90 | },
91 |
92 | render(h, ctx) {
93 | const { props, injections } = ctx
94 | const { prefixCls, components } = injections
95 | const { columns, flattenColumns, stickyOffsets } = props
96 |
97 | const rows = parseHeaderRows(columns)
98 | const {
99 | wrapper: WrapperComponent,
100 | row: trComponent,
101 | cell: thComponent
102 | } = components.header
103 |
104 | return (
105 |
106 | {rows.map((row, rowIndex) => {
107 | const rowNode = (
108 |
117 | )
118 |
119 | return rowNode
120 | })}
121 |
122 | )
123 | }
124 | }
125 |
--------------------------------------------------------------------------------
/example/components/virtual-table/helpers/store/connect.js:
--------------------------------------------------------------------------------
1 | import Vue from 'vue'
2 | import fastDeepEqual from 'fast-deep-equal'
3 | import { getOptionProps } from '../utils/index'
4 |
5 | /**
6 | * Connect in Vue.js
7 | * @see
8 | *
9 | * {@link https://gist.github.com/gaearon/1d19088790e70ac32ea636c025ba424e connect.js}
10 | * {@link https://github.com/vueComponent/ant-design-vue/blob/e63f9ea671/components/_util/store/connect.jsx ant-design-vue/connect.jsx}
11 | */
12 | export default function connect(mapStateToProps = () => ({})) {
13 | const shouldSubscribe = !!mapStateToProps
14 |
15 | return function withConnectHOC(WrappedComponent) {
16 | const props = {}
17 | const tempProps = WrappedComponent.props
18 |
19 | /**
20 | * 在 connect 这层,取消 required 配置,阻止双重警告
21 | * ConnectComponent > Component
22 | */
23 | Object.keys(tempProps).forEach((k) => {
24 | props[k] = { ...tempProps[k], required: false }
25 | })
26 |
27 | return Vue.extend({
28 | name: `Connect${WrappedComponent.name || 'Component'}`,
29 |
30 | inject: {
31 | table: { default: () => ({}) }
32 | },
33 |
34 | props,
35 |
36 | data() {
37 | this.store = this.table.store
38 | this.preProps = getOptionProps(this)
39 |
40 | return {
41 | subscribed: mapStateToProps(this.store.getState(), this.$props)
42 | }
43 | },
44 |
45 | mounted() {
46 | this.trySubscribe()
47 | },
48 |
49 | beforeDestroy() {
50 | this.tryUnsubscribe()
51 | },
52 |
53 | methods: {
54 | handleChange() {
55 | if (!this.unsubscribe) {
56 | return
57 | }
58 |
59 | const props = getOptionProps(this)
60 | const nextSubscribed = mapStateToProps(this.store.getState(), props)
61 |
62 | if (
63 | !fastDeepEqual(this.preProps, props) ||
64 | !fastDeepEqual(this.subscribed, nextSubscribed)
65 | ) {
66 | this.subscribed = nextSubscribed
67 | }
68 | },
69 |
70 | trySubscribe() {
71 | if (shouldSubscribe) {
72 | this.unsubscribe = this.store.subscribe(this.handleChange)
73 | this.handleChange()
74 | }
75 | },
76 |
77 | tryUnsubscribe() {
78 | if (this.unsubscribe) {
79 | this.unsubscribe()
80 | this.unsubscribe = null
81 | }
82 | },
83 |
84 | getWrappedInstance() {
85 | return this.$refs.wrappedInstance
86 | }
87 | },
88 |
89 | render() {
90 | const props = getOptionProps(this)
91 | const {
92 | $listeners,
93 | $slots = {},
94 | $attrs,
95 | $scopedSlots,
96 | subscribed,
97 | store
98 | } = this
99 |
100 | this.preProps = { ...this.$props, ...props }
101 |
102 | const wrapProps = {
103 | props: {
104 | ...props,
105 | store,
106 | ...subscribed
107 | },
108 | on: $listeners,
109 | attrs: {
110 | ...$attrs
111 | },
112 | scopedSlots: $scopedSlots
113 | }
114 |
115 | return (
116 |
117 | {Object.keys($slots).map((name) => {
118 | return {$slots[name]}
119 | })}
120 |
121 | )
122 | }
123 | })
124 | }
125 | }
126 |
--------------------------------------------------------------------------------
/example/components/virtual-table/TableHeaderRow.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable vue/require-default-prop */
2 | import PropTypes from "vue-types"
3 | import { TableComponents, Store, TableProps } from "./interface"
4 | import { withStoreConnect } from "./helpers/withStoreConnect"
5 |
6 | function getRowHeight(state, props) {
7 | const { fixedColumnsHeadRowsHeight } = state
8 | const { columns, rows, fixed } = props
9 | const headerHeight = fixedColumnsHeadRowsHeight[0]
10 |
11 | if (!fixed) {
12 | return null
13 | }
14 |
15 | if (headerHeight && columns) {
16 | if (headerHeight === "auto") {
17 | return "auto"
18 | }
19 |
20 | return `${headerHeight / rows.length}px`
21 | }
22 |
23 | return null
24 | }
25 |
26 | function connect(state, props) {
27 | return {
28 | height: getRowHeight(state, props),
29 | }
30 | }
31 |
32 | export default {
33 | name: "TableHeaderRow",
34 |
35 | mixins: [withStoreConnect(connect)],
36 |
37 | inheritAttrs: false,
38 |
39 | inject: {
40 | table: { default: () => ({}) },
41 | },
42 |
43 | props: {
44 | fixed: TableProps.fixed,
45 | store: Store.isRequired,
46 | rows: TableProps.columns,
47 | columns: TableProps.columns,
48 | row: PropTypes.array,
49 | prefixCls: PropTypes.string.isRequired,
50 | components: TableComponents,
51 | },
52 |
53 | data() {
54 | const { stateManager } = this.table
55 |
56 | if (stateManager) {
57 | this.stateManager = stateManager
58 | }
59 |
60 | return {}
61 | },
62 |
63 | render(h) {
64 | const { row, fixed, prefixCls, components } = this.$props
65 | const { row: TableHeaderRow, cell: TableHeaderRowCell } = components.header
66 | const { height } = this.state
67 | const style = { height }
68 |
69 | return (
70 |
71 | {row.map((cell, index) => {
72 | // eslint-disable-next-line no-unused-vars
73 | const { column, children, className, ...cellProps } = cell
74 | const headerCellProps = {
75 | inheritAttrs: false,
76 | attrs: {
77 | ...cellProps,
78 | },
79 | style,
80 | class: [
81 | column.fixed && !fixed
82 | ? [`${prefixCls}-fixed-columns-in-body`]
83 | : "",
84 | ],
85 | }
86 |
87 | headerCellProps.class = [
88 | ...headerCellProps.class,
89 | column.ellipsis
90 | ? `${prefixCls}-row-cell-ellipsis`
91 | : `${prefixCls}-row-cell-break-word`,
92 | ]
93 |
94 | if (column.align) {
95 | headerCellProps.style = {
96 | ...headerCellProps.style,
97 | textAlign: column.align,
98 | }
99 | }
100 |
101 | if (typeof TableHeaderRowCell === "function") {
102 | return TableHeaderRowCell(h, headerCellProps, children)
103 | }
104 |
105 | return (
106 |
110 |
111 | {typeof column.renderHeader === "function"
112 | ? column.renderHeader(h, {
113 | store: this.stateManager,
114 | row,
115 | column,
116 | index,
117 | })
118 | : children}
119 |
120 |
121 | )
122 | })}
123 |
124 | )
125 | },
126 | }
127 |
--------------------------------------------------------------------------------
/src/TableBody/index.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable vue/require-default-prop */
2 | import {
3 | TableProps,
4 | FUNC_PROP,
5 | ARRAY_PROP,
6 | NUMBER_PROP,
7 | BOOLEAN_FALSE_PROP
8 | } from '../interface'
9 | import BodyRow from './BodyRow'
10 | import ExpandedRow from './ExpandedRow'
11 | import ResizeObserver from 'vue-size-observer'
12 |
13 | export default {
14 | name: 'Body',
15 |
16 | functional: true,
17 |
18 | inheritAttrs: false,
19 |
20 | inject: {
21 | getRowKey: FUNC_PROP,
22 | prefixCls: TableProps.prefixCls,
23 | components: TableProps.components,
24 | fixHeader: BOOLEAN_FALSE_PROP,
25 | horizonScroll: BOOLEAN_FALSE_PROP,
26 | onColumnResize: FUNC_PROP
27 | },
28 |
29 | props: {
30 | data: TableProps.dataSource,
31 | emptyNode: Object,
32 | columnsKey: ARRAY_PROP,
33 | expandedKeys: ARRAY_PROP,
34 | fixedInfoList: ARRAY_PROP,
35 | flattenColumns: TableProps.columns,
36 | componentWidth: NUMBER_PROP,
37 | measureColumnWidth: Boolean,
38 | childrenColumnName: String
39 | },
40 |
41 | render(h, { props, injections }) {
42 | const {
43 | data = [],
44 | emptyNode,
45 | columnsKey = [],
46 | expandedKeys,
47 | fixedInfoList,
48 | flattenColumns,
49 | componentWidth,
50 | measureColumnWidth,
51 | childrenColumnName
52 | } = props
53 | const {
54 | getRowKey,
55 | prefixCls,
56 | components,
57 | fixHeader,
58 | horizonScroll,
59 | onColumnResize
60 | } = injections
61 |
62 | const {
63 | wrapper: WrapperComponent,
64 | row: trComponent,
65 | cell: tdComponent
66 | } = components.body
67 | let rows = []
68 |
69 | if (data.length) {
70 | rows = data.map((record, index) => {
71 | const key = getRowKey(record, index)
72 |
73 | return [
74 |
89 | ]
90 | })
91 | } else {
92 | rows = (
93 |
106 | {emptyNode}
107 |
108 | )
109 | }
110 |
111 | return (
112 |
113 | {/* Measure body column width with additional hidden col */}
114 | {measureColumnWidth && (
115 |
120 | {columnsKey.map((columnKey) => (
121 | {
124 | requestAnimationFrame(() => {
125 | onColumnResize(columnKey, offsetWidth)
126 | })
127 | }}
128 | >
129 | |
130 |
131 | ))}
132 |
133 | )}
134 |
135 | {rows}
136 |
137 | )
138 | }
139 | }
140 |
--------------------------------------------------------------------------------
/example/components/virtual-table/helpers/ColumnManager.js:
--------------------------------------------------------------------------------
1 | /* Forked from ant-design 💖 */
2 | export default class ColumnManager {
3 | constructor(columns) {
4 | this.columns = columns
5 | this._cached = {}
6 | }
7 |
8 | isAnyColumnsFixed() {
9 | return this._cache('isAnyColumnsFixed', () => {
10 | return this.columns.some((column) => !!column.fixed)
11 | })
12 | }
13 |
14 | isAnyColumnsLeftFixed() {
15 | return this._cache('isAnyColumnsLeftFixed', () => {
16 | return this.columns.some(
17 | (column) => column.fixed === 'left' || column.fixed === true
18 | )
19 | })
20 | }
21 |
22 | isAnyColumnsRightFixed() {
23 | return this._cache('isAnyColumnsRightFixed', () => {
24 | return this.columns.some((column) => column.fixed === 'right')
25 | })
26 | }
27 |
28 | leftColumns() {
29 | return this._cache('leftColumns', () => {
30 | return this.groupedColumns().filter(
31 | (column) => column.fixed === 'left' || column.fixed === true
32 | )
33 | })
34 | }
35 |
36 | rightColumns() {
37 | return this._cache('rightColumns', () => {
38 | return this.groupedColumns().filter((column) => column.fixed === 'right')
39 | })
40 | }
41 |
42 | leafColumns() {
43 | return this._cache('leafColumns', () => this._leafColumns(this.columns))
44 | }
45 |
46 | leftLeafColumns() {
47 | return this._cache('leftLeafColumns', () =>
48 | this._leafColumns(this.leftColumns())
49 | )
50 | }
51 |
52 | rightLeafColumns() {
53 | return this._cache('rightLeafColumns', () =>
54 | this._leafColumns(this.rightColumns())
55 | )
56 | }
57 |
58 | // add appropriate rowspan and colspan to column
59 | groupedColumns() {
60 | return this._cache('groupedColumns', () => {
61 | const _groupColumns = (
62 | columns,
63 | currentRow = 0,
64 | parentColumn = {},
65 | rows = []
66 | ) => {
67 | // track how many rows we got
68 | rows[currentRow] = rows[currentRow] || []
69 | const grouped = []
70 | const setRowSpan = (column) => {
71 | const rowSpan = rows.length - currentRow
72 | if (
73 | column &&
74 | !column.children && // parent columns are supposed to be one row
75 | rowSpan > 1 &&
76 | (!column.rowSpan || column.rowSpan < rowSpan)
77 | ) {
78 | column.rowSpan = rowSpan
79 | }
80 | }
81 | columns.forEach((column, index) => {
82 | const newColumn = { ...column }
83 | rows[currentRow].push(newColumn)
84 | parentColumn.colSpan = parentColumn.colSpan || 0
85 | if (newColumn.children && newColumn.children.length > 0) {
86 | newColumn.children = _groupColumns(
87 | newColumn.children,
88 | currentRow + 1,
89 | newColumn,
90 | rows
91 | )
92 | parentColumn.colSpan += newColumn.colSpan
93 | } else {
94 | parentColumn.colSpan++
95 | }
96 | // update rowspan to all same row columns
97 | for (let i = 0; i < rows[currentRow].length - 1; ++i) {
98 | setRowSpan(rows[currentRow][i])
99 | }
100 | // last column, update rowspan immediately
101 | if (index + 1 === columns.length) {
102 | setRowSpan(newColumn)
103 | }
104 | grouped.push(newColumn)
105 | })
106 | return grouped
107 | }
108 |
109 | return _groupColumns(this.columns)
110 | })
111 | }
112 |
113 | reset(columns) {
114 | this.columns = columns
115 | this._cached = {}
116 | }
117 |
118 | _cache(name, fn) {
119 | if (name in this._cached) {
120 | return this._cached[name]
121 | }
122 |
123 | this._cached[name] = fn()
124 |
125 | return this._cached[name]
126 | }
127 |
128 | _leafColumns(columns) {
129 | const leafColumns = []
130 |
131 | columns.forEach((column) => {
132 | if (!column.children) {
133 | leafColumns.push(column)
134 | } else {
135 | leafColumns.push(...this._leafColumns(column.children))
136 | }
137 | })
138 |
139 | return leafColumns
140 | }
141 | }
142 |
--------------------------------------------------------------------------------
/example/components/virtual-table/interface.js:
--------------------------------------------------------------------------------
1 | /* Forked from ant-design-vue */
2 | import PropTypes from 'vue-types'
3 | import { DEFAULT_LOCALE, DEFAULT_COMPONENTS } from './constants'
4 |
5 | const LEFT = 'left'
6 | const CENTER = 'center'
7 | const RIGHT = 'right'
8 |
9 | export const ColumnProps = {
10 | label: PropTypes.string.isRequired,
11 | prop: PropTypes.string.isRequired,
12 | key: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
13 |
14 | width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
15 | colSpan: PropTypes.number,
16 | className: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
17 |
18 | fixed: PropTypes.oneOfType([PropTypes.bool, PropTypes.oneOf([LEFT, RIGHT])]),
19 | align: PropTypes.oneOf([LEFT, CENTER, RIGHT]),
20 | ellipsis: PropTypes.bool.def(false),
21 |
22 | renderHeader: PropTypes.func.def(null), // VNode|({ store, row, column }) => VNode
23 |
24 | sortable: PropTypes.bool.def(false), // TODO
25 | resizable: PropTypes.bool.def(false), // TODO
26 | expandable: PropTypes.bool.def(false)
27 |
28 | // TODO: filter
29 | // filterMultiple: PropTypes.bool,
30 | // filterDropdown: PropTypes.any,
31 | // filterDropdownVisible: PropTypes.bool,
32 | // filterIcon: PropTypes.any,
33 | // filteredValue: PropTypes.array,
34 |
35 | // TODO: sorter
36 | // sorter: PropTypes.oneOfType([PropTypes.boolean, PropTypes.func]),
37 | // defaultSortOrder: PropTypes.oneOf(['ascend', 'descend']),
38 | // sortOrder: PropTypes.oneOfType([
39 | // PropTypes.bool,
40 | // PropTypes.oneOf(['ascend', 'descend'])
41 | // ]),
42 | // sortDirections: PropTypes.array
43 | }
44 |
45 | export const Store = PropTypes.shape({
46 | setState: PropTypes.func.isRequired,
47 | getState: PropTypes.func.isRequired,
48 | subscribe: PropTypes.func.isRequired
49 | })
50 |
51 | export const TableComponents = PropTypes.shape({
52 | wrapper: PropTypes.string.def('table'),
53 | header: {
54 | wrapper: PropTypes.string.def('thead'),
55 | row: PropTypes.string.def('tr'),
56 | cell: PropTypes.string.def('th')
57 | },
58 | body: {
59 | wrapper: PropTypes.string.def('tbody'),
60 | row: PropTypes.string.def('tr'),
61 | cell: PropTypes.string.def('td')
62 | },
63 |
64 | // if use other UI framework, could instead of the following components
65 | // the select => ElSelect
66 | // the check-box => ElCheckBox
67 | // the radio => ElRadio
68 | // the scroll-bar => ElScrollbar
69 | select: PropTypes.string.def('select'),
70 | checkbox: PropTypes.string.def('checkbox'),
71 | radio: PropTypes.string.def('radio'),
72 | scrollbar: PropTypes.string
73 | }).loose
74 |
75 | export const TableLocale = PropTypes.shape({
76 | filterTitle: PropTypes.string.def('过滤器'),
77 | filterConfirm: PropTypes.any.def('确定'),
78 | filterReset: PropTypes.any.def('重置'),
79 | emptyText: PropTypes.any.def('暂无数据'),
80 | selectAll: PropTypes.any.def('选择全部'),
81 | selectInvert: PropTypes.any.def('反选'),
82 | sortTitle: PropTypes.string.def('排序')
83 | }).loose
84 |
85 | export const TableProps = {
86 | size: PropTypes.oneOf(['default', 'large', 'middle', 'small']).def('default'),
87 | rowKey: PropTypes.oneOfType([PropTypes.string, PropTypes.func]).isRequired,
88 | prefixCls: PropTypes.string.def('vc-table'),
89 |
90 | // Fixed Columns
91 | scroll: PropTypes.object.def({}),
92 |
93 | // Customize
94 | locale: TableLocale.def(DEFAULT_LOCALE),
95 | bordered: PropTypes.bool.def(false),
96 | bodyStyle: PropTypes.any,
97 | showHeader: PropTypes.bool.def(true),
98 | rowClassName: PropTypes.func, // (record: RowModel, index: number) => string
99 | components: TableComponents.def(DEFAULT_COMPONENTS),
100 |
101 | // virtual-scroll
102 | useVirtual: PropTypes.bool.def(true),
103 | rowHeight: PropTypes.oneOfType([PropTypes.number, PropTypes.func]),
104 | overscanCount: PropTypes.number.def(3),
105 | scrollToRowIndex: PropTypes.number.def(0),
106 |
107 | // Data
108 | columns: PropTypes.arrayOf(ColumnProps).isRequired,
109 | dataSource: PropTypes.array.isRequired,
110 |
111 | // Expandable
112 | indentSize: PropTypes.number.def(20),
113 | expandIcon: PropTypes.oneOfType([PropTypes.func, PropTypes.string]),
114 | expandDepth: PropTypes.number,
115 | expandedRowKeys: PropTypes.array,
116 | defaultExpandAllRows: PropTypes.bool,
117 |
118 | // Selection
119 | selectedRowKeys: PropTypes.array,
120 | rowSelectionType: PropTypes.oneOf(['checkbox', 'radio']),
121 |
122 | // Additional Part
123 | title: PropTypes.func,
124 | footer: PropTypes.func
125 | }
126 |
--------------------------------------------------------------------------------
/src/mixins/withVirtualization.js:
--------------------------------------------------------------------------------
1 | import { isObject, isValidArray } from '../utils/type'
2 | import { forceScrollTop } from '../utils/dom/scroll'
3 |
4 | export function data() {
5 | return {
6 | scrollToRowIndex: this.scrollToRow,
7 | virtualizedData: []
8 | }
9 | }
10 |
11 | export function created() {
12 | const { useVirtual, rowHeight, scroll = {} } = this
13 |
14 | if (useVirtual && !scroll.y) {
15 | throw new ReferenceError(
16 | `When open 'useVirtual' mode the property 'scroll.y' must be set as number to fixed header and calculates how many items should be render in table!`
17 | )
18 | }
19 |
20 | if (useVirtual && !rowHeight) {
21 | throw new ReferenceError(
22 | `When open 'useVirtual' mode the property 'rowHeight' must be set as number to fix the height of per table row!`
23 | )
24 | }
25 |
26 | this.__prevStartIndex = 0
27 | }
28 |
29 | export const computed = {
30 | virtualized() {
31 | return !!(
32 | this.useVirtual &&
33 | this.rowHeight &&
34 | this.scroll &&
35 | this.scroll.y &&
36 | typeof this.scroll.y === 'number'
37 | )
38 | },
39 |
40 | currentDataSource() {
41 | if (isObject(this.expandable)) {
42 | return this.entireDataSource || []
43 | }
44 |
45 | return this.dataSource
46 | },
47 |
48 | currentDataLength() {
49 | return isValidArray(this.currentDataSource)
50 | ? this.currentDataSource.length
51 | : 0
52 | },
53 |
54 | wrapperSize() {
55 | return this.virtualized ? this.currentDataLength * this.rowHeight : 0
56 | },
57 |
58 | virtualVisibleItemsSize() {
59 | return this.virtualized ? Math.ceil(this.scroll.y / this.rowHeight) : 0
60 | },
61 |
62 | wrapperScrollTop() {
63 | return this.virtualized ? this.scrollToRowIndex * this.rowHeight : 0
64 | },
65 |
66 | maxSliceBlockStep() {
67 | return this.virtualized
68 | ? Math.ceil(
69 | this.wrapperSize / (this.rowHeight * this.virtualVisibleItemsSize)
70 | )
71 | : 0
72 | }
73 | }
74 |
75 | export const watch = {
76 | dataSource: {
77 | immediate: true,
78 | handler(data) {
79 | if (data && data.length) {
80 | if (~this.scrollToRow) {
81 | this.updateScrollToRowIndex()
82 | }
83 |
84 | this.updateVirtualizedData(true)
85 | } else {
86 | this.virtualizedData = []
87 | this.entireDataSource = []
88 | }
89 | }
90 | },
91 |
92 | entireDataSource(data) {
93 | if (data && data.length) {
94 | this.updateVirtualizedData(true)
95 | } else {
96 | this.virtualizedData = []
97 | }
98 | },
99 |
100 | scrollToRow() {
101 | this.updateScrollToRowIndex()
102 | }
103 | }
104 |
105 | export const methods = {
106 | getVisibleRange(offset) {
107 | let start = Math.floor(offset / this.rowHeight) || 0
108 | const N = this.virtualVisibleItemsSize || 0
109 | const MIN_INDEX = 0
110 | let end = start + N || 0
111 |
112 | this.__CURRENT_N_STEP = Math.ceil(
113 | offset / (this.rowHeight * this.virtualVisibleItemsSize)
114 | )
115 |
116 | if (start < N) {
117 | start = MIN_INDEX
118 | end += N
119 | } else {
120 | start = start - N
121 | end += N
122 | }
123 |
124 | return {
125 | start,
126 | end
127 | }
128 | },
129 |
130 | updateVirtualizedData(scrollTop = 0, forceUpdate) {
131 | if (scrollTop === true) {
132 | scrollTop = this.__PREV_SCROLL_TOP
133 | forceUpdate = true
134 | }
135 |
136 | const { start, end } = this.getVisibleRange(scrollTop)
137 | const shouldUpdate =
138 | // this.__PREV_START_INDEX !== start ||
139 | this.__CURRENT_N_STEP !== this.__PREV__CURRENT_N_STEP
140 |
141 | if (!shouldUpdate && !forceUpdate) {
142 | return
143 | }
144 |
145 | this.__PREV__CURRENT_N_STEP = this.__CURRENT_N_STEP
146 | this.__PREV_START_INDEX = start
147 | this.__PREV_SCROLL_TOP = scrollTop
148 |
149 | this.scrollToRowIndex = start
150 | this.virtualizedData = this.currentDataSource.slice(start, end)
151 | },
152 |
153 | updateScrollToRowIndex() {
154 | this.$nextTick(() => {
155 | if (this.$ready && ~this.scrollToRow && this.rowHeight) {
156 | const { scrollBodyRef } = this.$refs
157 |
158 | if (scrollBodyRef) {
159 | forceScrollTop(this.scrollToRow * this.rowHeight, scrollBodyRef)
160 | }
161 | }
162 | })
163 | },
164 |
165 | renderVirtualizedWrapper(baseTable) {
166 | return (
167 |
174 |
183 | {baseTable}
184 |
185 |
186 | )
187 | }
188 | }
189 |
--------------------------------------------------------------------------------
/src/TableCell/index.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable vue/require-default-prop */
2 | import { OBJECT_PROP } from '../interface'
3 | import {
4 | isArray,
5 | isString,
6 | isNumber,
7 | isObject,
8 | isFunction
9 | } from '../utils/type'
10 |
11 | export default {
12 | name: 'Cell',
13 |
14 | functional: true,
15 |
16 | inject: {
17 | store: OBJECT_PROP,
18 | rowHeight: {
19 | type: [String, Number]
20 | }
21 | },
22 |
23 | props: {
24 | prefixCls: String,
25 | record: Object,
26 | column: Object,
27 | /** `record` index. Not `column` index. */
28 | index: Number,
29 | prop: String,
30 | component: String,
31 | children: [Array, Object],
32 | colSpan: Number,
33 | rowSpan: Number,
34 | ellipsis: Boolean,
35 | align: String,
36 |
37 | shouldCellUpdate: Function,
38 |
39 | // Fixed
40 | fixLeft: [Number, Boolean],
41 | fixRight: [Number, Boolean],
42 | firstFixLeft: Boolean,
43 | lastFixLeft: Boolean,
44 | firstFixRight: Boolean,
45 | lastFixRight: Boolean,
46 |
47 | // Additional
48 | /** @private Used for `expandable` with nest tree */
49 | appendNode: [Object, Array],
50 | rowType: String // 'header' | 'body' | 'footer',
51 | },
52 |
53 | render(h, { props, injections }) {
54 | const {
55 | index,
56 | column,
57 | prefixCls,
58 | record = {},
59 | prop = '',
60 | children,
61 | component: Component = 'td',
62 | colSpan,
63 | rowSpan,
64 | fixLeft,
65 | fixRight,
66 | firstFixLeft,
67 | lastFixLeft,
68 | firstFixRight,
69 | lastFixRight,
70 | appendNode,
71 | additionalProps = {},
72 | ellipsis,
73 | align,
74 | rowType
75 | } = props
76 | const { store, rowHeight } = injections
77 | const isHeader = rowType === 'header'
78 | const cellPrefixCls = `${prefixCls}-cell`
79 | const value = record[prop]
80 | let childNode = children || value || ''
81 |
82 | if (isHeader) {
83 | if (isObject(column) && isFunction(column.renderHeader)) {
84 | childNode = column.renderHeader(h, {
85 | store: store,
86 | row: record,
87 | column,
88 | value,
89 | index
90 | })
91 | }
92 | } else {
93 | if (isObject(column) && isFunction(column.render)) {
94 | childNode = column.render(h, {
95 | store: store,
96 | value,
97 | row: record,
98 | column,
99 | index
100 | })
101 | }
102 | }
103 |
104 | if (ellipsis && (lastFixLeft || firstFixRight)) {
105 | childNode = {childNode}
106 | }
107 |
108 | const fixedStyle = {}
109 | const isFixLeft = typeof fixLeft === 'number'
110 | const isFixRight = typeof fixRight === 'number'
111 |
112 | if (isFixLeft) {
113 | fixedStyle.position = 'sticky'
114 | fixedStyle.left = fixLeft + 'px'
115 | }
116 |
117 | if (isFixRight) {
118 | fixedStyle.position = 'sticky'
119 | fixedStyle.right = fixRight + 'px'
120 | }
121 |
122 | const alignStyle = {}
123 | if (align) {
124 | alignStyle.textAlign = align
125 | }
126 |
127 | let title
128 | const ellipsisConfig = ellipsis === true ? { showTitle: true } : ellipsis
129 |
130 | if (ellipsisConfig && (ellipsisConfig.showTitle || isHeader)) {
131 | if (childNode && isArray(childNode) && childNode.length === 1) {
132 | childNode = childNode[0]
133 | }
134 |
135 | if (typeof childNode === 'string' || typeof childNode === 'number') {
136 | title = childNode.toString()
137 | }
138 |
139 | if (!isString('' + title)) {
140 | title = record[prop] || ''
141 | }
142 | }
143 |
144 | if (colSpan === 0 || rowSpan === 0) {
145 | return null
146 | }
147 |
148 | const componentProps = {
149 | attrs: {
150 | title,
151 | ...additionalProps,
152 | colSpan: colSpan !== 1 ? colSpan : null,
153 | rowSpan: rowSpan !== 1 ? rowSpan : null
154 | },
155 | class: [
156 | cellPrefixCls,
157 | column ? [column.class, column.className] : [],
158 | {
159 | [`${cellPrefixCls}-fix-left`]: isFixLeft,
160 | [`${cellPrefixCls}-fix-left-first`]: firstFixLeft,
161 | [`${cellPrefixCls}-fix-left-last`]: lastFixLeft,
162 | [`${cellPrefixCls}-fix-right`]: isFixRight,
163 | [`${cellPrefixCls}-fix-right-first`]: firstFixRight,
164 | [`${cellPrefixCls}-fix-right-last`]: lastFixRight,
165 | [`${cellPrefixCls}-ellipsis`]: ellipsis,
166 | [`${cellPrefixCls}-with-append`]: appendNode
167 | }
168 | ],
169 | style: {
170 | ...additionalProps.style,
171 | ...alignStyle,
172 | ...fixedStyle,
173 | height: !isHeader && isNumber(+rowHeight) ? rowHeight + 'px' : rowHeight
174 | },
175 | ref: null
176 | }
177 |
178 | return (
179 |
180 | {appendNode}
181 | {childNode}
182 |
183 | )
184 | }
185 | }
186 |
--------------------------------------------------------------------------------
/src/TableBody/BodyRow.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable vue/require-default-prop */
2 | import {
3 | noop,
4 | isObject,
5 | isNumber,
6 | isFunction,
7 | isValidArray
8 | } from '../utils/type'
9 | import TableCell from '../TableCell/index'
10 | import { renderExpandIcon } from '../utils/expand'
11 | import { TableProps, ARRAY_PROP, OBJECT_PROP } from '../interface'
12 |
13 | function getSpan(row, index, span) {
14 | if (isFunction(span)) {
15 | span = span(row, index)
16 | }
17 |
18 | if (isNumber(span)) {
19 | return span
20 | }
21 |
22 | return 1
23 | }
24 |
25 | export default {
26 | name: 'BodyRow',
27 |
28 | // functional: true,
29 |
30 | inheritAttrs: false,
31 |
32 | inject: {
33 | store: OBJECT_PROP,
34 | prefixCls: TableProps.prefixCls,
35 | expandable: OBJECT_PROP,
36 | rowSelection: OBJECT_PROP,
37 | rowClassName: TableProps.rowClassName
38 | // isSelectionMode: Boolean,
39 | // isExpansionMode: Boolean,
40 | },
41 |
42 | props: {
43 | index: Number,
44 | rowKey: [String, Number],
45 | record: Object,
46 | columnsKey: ARRAY_PROP,
47 | recordKey: [String, Number],
48 | expandedKeys: Array,
49 | rowComponent: String,
50 | cellComponent: String,
51 | fixedInfoList: ARRAY_PROP,
52 | flattenColumns: TableProps.columns,
53 | childrenColumnName: String
54 | },
55 |
56 | render(h /* { props, injections } */) {
57 | const {
58 | record,
59 | index,
60 | rowKey,
61 | columnsKey,
62 | expandedKeys,
63 | rowComponent: RowComponent,
64 | cellComponent,
65 | fixedInfoList,
66 | flattenColumns,
67 | childrenColumnName
68 | } = this.$props
69 |
70 | const { store, prefixCls, expandable, rowClassName, rowSelection } = this // .injections
71 |
72 | const hasNestChildren =
73 | childrenColumnName && record && isValidArray(record[childrenColumnName])
74 | let expanded = false
75 | let onExpand = noop
76 | let indent = 0
77 |
78 | if (isObject(expandable)) {
79 | indent = record.__depth || 0
80 | expanded = expandedKeys.includes(rowKey)
81 | onExpand = (record, event) => {
82 | store.toggleRowExpansion(record, undefined, event)
83 | }
84 | }
85 |
86 | let selected = null
87 | let onClick = noop
88 |
89 | if (rowSelection) {
90 | selected = store.isRowSelected(rowKey)
91 | onClick = (event) => store.toggleRowSelection(record, event)
92 | }
93 |
94 | let computeRowClassName
95 |
96 | if (typeof rowClassName === 'string') {
97 | computeRowClassName = rowClassName
98 | } else if (typeof rowClassName === 'function') {
99 | computeRowClassName = rowClassName(record, index, indent)
100 | }
101 |
102 | return (
103 |
113 | {flattenColumns.map((column, colIndex) => {
114 | const { prop, className: columnClassName } = column
115 |
116 | const key = (columnsKey || [])[colIndex]
117 | const fixedInfo = fixedInfoList[colIndex]
118 |
119 | // ============= Used for nest expandable =============
120 | let appendCellNode
121 |
122 | if (
123 | column.expandable &&
124 | isObject(expandable) &&
125 | isFunction(onExpand)
126 | ) {
127 | appendCellNode = [
128 | ,
134 | (isFunction(expandable.expandIcon)
135 | ? expandable.expandIcon
136 | : renderExpandIcon)(h, {
137 | prefixCls,
138 | expanded,
139 | expandable: isFunction(expandable.rowExpandable)
140 | ? expandable.rowExpandable(record, index, rowKey)
141 | : record.hasChildren || hasNestChildren,
142 | record,
143 | onExpand
144 | })
145 | ]
146 | }
147 |
148 | return (
149 |
166 | )
167 | })}
168 |
169 | )
170 | }
171 | }
172 |
--------------------------------------------------------------------------------
/src/utils/expand.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-undef */
2 | import { isArray, isValidArray, isObject } from './type'
3 |
4 | /**
5 | * findAllChildrenKeys
6 | * @param {Tree[]} data
7 | * @param {GetRowKey} getRowKey
8 | * @param {string} childrenColumnName
9 | * @returns {string[]}
10 | */
11 | export function findValidChildrenKeys(data, getRowKey, childrenColumnName) {
12 | return data.reduce((keys, item, index) => {
13 | const key = getRowKey(item, index)
14 | const children = item[childrenColumnName]
15 |
16 | if (isValidArray(children)) {
17 | return [
18 | key,
19 | ...findValidChildrenKeys(children, getRowKey, childrenColumnName),
20 | ...keys
21 | ]
22 | }
23 |
24 | return keys
25 | }, [])
26 | }
27 |
28 | /**
29 | * flatten tree data 展开树形数据
30 | *
31 | * @typedef {import("../types/index").RowModel} RowModel
32 | * @typedef {(item: object, index: number)=>string} GetRowKey
33 | * @typedef Tree
34 | * @property {string|number} [key]
35 | * @property {Tree[]} [children]
36 | *
37 | * @param {Tree[]} tree
38 | * @param {string} childrenColumnName
39 | * @param {number} [depth]
40 | * @param {RowModel[]} [flatten]
41 | * @param {RowModel} [parent]
42 | */
43 | export function flattenData(
44 | tree,
45 | childrenColumnName,
46 | depth = 0,
47 | flatten = [],
48 | parent = null
49 | ) {
50 | for (let index = 0; index < tree.length; index++) {
51 | const item = tree[index]
52 | const children = item[childrenColumnName]
53 | const hasNestChildren = isValidArray(children)
54 |
55 | // record the depth number
56 | if (!item.__depth || item.__depth !== depth) {
57 | item.__depth = depth
58 | }
59 |
60 | // record the index number
61 | if (!item.__index || item.__index !== index) {
62 | item.__index = index
63 | }
64 |
65 | // record the parent ref
66 | if (parent && (!item.__parent || item.__parent !== parent)) {
67 | item.__parent = parent
68 | }
69 |
70 | flatten.push(item)
71 |
72 | if (hasNestChildren) {
73 | flattenData(children, childrenColumnName, depth + 1, flatten, item)
74 | }
75 | }
76 |
77 | return flatten
78 | }
79 |
80 | /**
81 | * flattenMap 记录当前 rowKey 的 tree-paths
82 | *
83 | * @param {Tree[]} tree
84 | * @param {(item: RowModel, index: number)=> string} getRowKey
85 | * @param {string} childrenColumnName
86 | * @param {string[]} prefix
87 | * @param {Object} preset
88 | */
89 | export function flattenMap(
90 | tree = [],
91 | getRowKey,
92 | childrenColumnName,
93 | prefix = [],
94 | preset = {}
95 | ) {
96 | return tree.reduce((prev, item, index) => {
97 | const children = item[childrenColumnName]
98 | const hasNestChildren = isValidArray(children)
99 |
100 | if (hasNestChildren) {
101 | const key = getRowKey(item, index)
102 | const path = prefix.length ? [...prefix, key] : [key]
103 |
104 | prev[key] = path
105 |
106 | return flattenMap(children, getRowKey, childrenColumnName, path, prev)
107 | }
108 |
109 | return prev
110 | }, preset)
111 | }
112 |
113 | /**
114 | * genExpandedKeyPaths
115 | * @param {RowModel} record
116 | * @param {(row: object, index: number)=>string} getRowKey
117 | * @param {string} childrenColumnName
118 | * @param {*} rowKey
119 | */
120 | export function genExpandedKeyPaths(record, getRowKey, childrenColumnName) {
121 | let parent = record.__parent
122 | let results = isValidArray(record[childrenColumnName])
123 | ? [getRowKey(item)]
124 | : []
125 |
126 | while (isObject(parent)) {
127 | results = [getRowKey(parent), ...results]
128 | parent = parent.__parent
129 | }
130 |
131 | return results
132 | }
133 |
134 | /**
135 | * insertDataFromStart
136 | * @param {[]} data
137 | * @param {number} startIndex
138 | * @param {[]} middle
139 | */
140 | export function insertDataFromStart(
141 | data, // any[]
142 | startIndex = -1, // number
143 | middle = [] // any[]
144 | ) {
145 | if (!isArray(data) || !isArray(middle)) throw new TypeError()
146 |
147 | const left = data.slice(0, startIndex + 1)
148 | const right = data.slice(startIndex + 1)
149 |
150 | return [...left, ...middle, ...right]
151 | }
152 |
153 | /**
154 | * renderExpandIcon
155 | * @typedef {{
156 | * prefixCls: string,
157 | record: object,
158 | onExpand: function,
159 | expanded: boolean,
160 | expandable: boolean,
161 | * }} ExpandConfig
162 | * @param {import("@/components/virtualized-table/utils/vue").CreateElement} h
163 | * @param {ExpandConfig} expandConfig
164 | */
165 | export const renderExpandIcon = (
166 | h,
167 | { prefixCls, record, onExpand, expanded, expandable }
168 | ) => {
169 | const iconPrefix = `${prefixCls}-row-expand-icon`
170 |
171 | return (
172 |