From 242238e37a8058422edb36e1b44581af048918e8 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Mon, 15 Mar 2021 15:23:34 +0800 Subject: [PATCH] feat(data-table): group header --- .../demos/enUS/group-header.demo.md | 75 +++++++++ src/data-table/demos/enUS/index.demo-entry.md | 2 + .../demos/zhCN/group-header.demo.md | 75 +++++++++ src/data-table/demos/zhCN/index.demo-entry.md | 2 + src/data-table/src/DataTable.tsx | 14 +- src/data-table/src/TableParts/Body.tsx | 15 +- src/data-table/src/TableParts/Header.tsx | 157 +++++++++--------- src/data-table/src/interface.ts | 27 ++- src/data-table/src/styles/index.cssr.ts | 7 +- src/data-table/src/use-group-header.ts | 110 ++++++++++++ src/data-table/src/use-table-data.ts | 32 ++-- src/data-table/src/utils.ts | 19 +-- 12 files changed, 415 insertions(+), 120 deletions(-) create mode 100644 src/data-table/demos/enUS/group-header.demo.md create mode 100644 src/data-table/demos/zhCN/group-header.demo.md create mode 100644 src/data-table/src/use-group-header.ts diff --git a/src/data-table/demos/enUS/group-header.demo.md b/src/data-table/demos/enUS/group-header.demo.md new file mode 100644 index 000000000..506e7bb35 --- /dev/null +++ b/src/data-table/demos/enUS/group-header.demo.md @@ -0,0 +1,75 @@ +# Grouped Header + +```html + +``` + +```js +import { ref } from 'vue' + +function createCols () { + return [ + { + title: 'Name', + key: 'name' + }, + { + title: 'Attrs', + key: 'attrs', + children: [ + { + title: 'Attack', + key: 'attack', + children: [ + { + title: 'Physics Attack', + key: 'physicsAttack' + }, + { + title: 'Magic Attack', + key: 'magicAttack' + } + ] + }, + { + title: 'Defend', + key: 'defend' + }, + { + title: 'Speed', + key: 'speed' + } + ] + } + ] +} + +function createData () { + return Array.apply(null, { length: 50 }).map((_, i) => { + return { + name: `name_${i}`, + physicsAttack: `physicsAttack_${i}`, + magicAttack: `magicAttack_${i}`, + defend: `defend_${i}`, + speed: `speed_${i}` + } + }) +} + +export default { + setup () { + return { + data: ref(createData()), + columns: ref(createCols()), + pagination: ref({ + pageSize: 10 + }) + } + } +} +``` diff --git a/src/data-table/demos/enUS/index.demo-entry.md b/src/data-table/demos/enUS/index.demo-entry.md index 7a9c07290..88fabcd80 100644 --- a/src/data-table/demos/enUS/index.demo-entry.md +++ b/src/data-table/demos/enUS/index.demo-entry.md @@ -17,6 +17,7 @@ border size filter-and-sorter select +group-header controlled-page controlled-filter controlled-sorter @@ -75,6 +76,7 @@ These methods can help you control table in an uncontrolled manner. However, it' | Name | Type | Default | Description | | --- | --- | --- | --- | | align | `'left' \| 'right' \| 'center'` | `'left'` | Text align in column | +| children | `Column[]` | `undefined` | Child nodes of a grouped column | | className | `string` | `undefined` | | | defaultFilterOptionValue | `string \| number \| null` | `null` | The default active filter option value in uncontrolled manner. (works when not using multiple filters) | | defaultFilterOptionValues | `Array` | `[]` | The default active filter option values in uncontrolled manner. (works when there are multiple filters) | diff --git a/src/data-table/demos/zhCN/group-header.demo.md b/src/data-table/demos/zhCN/group-header.demo.md new file mode 100644 index 000000000..a5a1acd33 --- /dev/null +++ b/src/data-table/demos/zhCN/group-header.demo.md @@ -0,0 +1,75 @@ +# 表头分组 + +```html + +``` + +```js +import { ref } from 'vue' + +function createCols () { + return [ + { + title: 'Name', + key: 'name' + }, + { + title: 'Attrs', + key: 'attrs', + children: [ + { + title: 'Attack', + key: 'attack', + children: [ + { + title: 'Physics Attack', + key: 'physicsAttack' + }, + { + title: 'Magic Attack', + key: 'magicAttack' + } + ] + }, + { + title: 'Defend', + key: 'defend' + }, + { + title: 'Speed', + key: 'speed' + } + ] + } + ] +} + +function createData () { + return Array.apply(null, { length: 50 }).map((_, i) => { + return { + name: `name_${i}`, + physicsAttack: `physicsAttack_${i}`, + magicAttack: `magicAttack_${i}`, + defend: `defend_${i}`, + speed: `speed_${i}` + } + }) +} + +export default { + setup () { + return { + data: ref(createData()), + columns: ref(createCols()), + pagination: ref({ + pageSize: 10 + }) + } + } +} +``` diff --git a/src/data-table/demos/zhCN/index.demo-entry.md b/src/data-table/demos/zhCN/index.demo-entry.md index c58355732..f7f008672 100644 --- a/src/data-table/demos/zhCN/index.demo-entry.md +++ b/src/data-table/demos/zhCN/index.demo-entry.md @@ -17,6 +17,7 @@ border size filter-and-sorter select +group-header controlled-page controlled-filter controlled-sorter @@ -75,6 +76,7 @@ custom-filter-menu | 名称 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | | align | `'left' \| 'right' \| 'center'` | `'left'` | 列内的文本排列 | +| children | `Column[]` | `undefined` | 成组列头的子节点 | | className | `string` | `undefined` | | | defaultFilterOptionValue | `string \| number \| null` | `null` | 非受控状态下默认的过滤器选项值(过滤器单选时生效) | | defaultFilterOptionValues | `Array` | `[]` | 非受控状态下默认的过滤器选项值(过滤器多选时生效) | diff --git a/src/data-table/src/DataTable.tsx b/src/data-table/src/DataTable.tsx index 65605ae4b..2a0e0994a 100644 --- a/src/data-table/src/DataTable.tsx +++ b/src/data-table/src/DataTable.tsx @@ -28,15 +28,15 @@ import { OnUpdateCheckedRowKeys, OnUpdateSorter, RowKey, - TableColumnInfo, + TableColumns, TableNode, OnUpdateFilters, MainTableRef, DataTableInjection, - DataTableRef, - SelectionColInfo + DataTableRef } from './interface' import style from './styles/index.cssr' +import { useGroupHeader } from './use-group-header' export const dataTableProps = { ...(useTheme.props as ThemeProps), @@ -47,7 +47,7 @@ export const dataTableProps = { minHeight: Number, maxHeight: Number, columns: { - type: Array as PropType>, + type: Array as PropType, default: () => [] }, data: { @@ -201,6 +201,7 @@ export default defineComponent({ props ) const mainTableInstRef = ref(null) + const { rows, cols, dataRelatedCols } = useGroupHeader(props) const { treeMate: treeMateRef, mergedCurrentPage: mergedCurrentPageRef, @@ -217,7 +218,7 @@ export default defineComponent({ clearFilters, page, sort - } = useTableData(props) + } = useTableData(props, { dataRelatedCols }) const { doCheckAll, doUncheckAll, @@ -251,7 +252,8 @@ export default defineComponent({ treeMate: treeMateRef, mergedTheme: themeRef, scrollX: computed(() => props.scrollX), - columns: toRef(props, 'columns'), + rows, + cols, paginatedData: paginatedDataRef, leftActiveFixedColKey, rightActiveFixedColKey, diff --git a/src/data-table/src/TableParts/Body.tsx b/src/data-table/src/TableParts/Body.tsx index bf2c5e545..1c49ca93f 100644 --- a/src/data-table/src/TableParts/Body.tsx +++ b/src/data-table/src/TableParts/Body.tsx @@ -4,7 +4,7 @@ import { NCheckbox } from '../../../checkbox' import { NScrollbar, ScrollbarRef } from '../../../scrollbar' import { formatLength } from '../../../_utils' import { DataTableInjection, TmNode } from '../interface' -import { createCustomWidthStyle, createRowClassName, getColKey } from '../utils' +import { createRowClassName } from '../utils' import Cell from './Cell' export default defineComponent({ @@ -67,11 +67,8 @@ export default defineComponent({ default: () => ( - {NDataTable.columns.map((column, index) => ( - + {NDataTable.cols.map((col) => ( + ))} @@ -79,7 +76,7 @@ export default defineComponent({ const { rawNode: row } = tmNode const { handleCheckboxUpdateChecked } = this const { - columns, + cols, fixedColumnLeftMap, fixedColumnRightMap, currentPage, @@ -96,8 +93,8 @@ export default defineComponent({ createRowClassName(row, index, rowClassName) ]} > - {columns.map((column) => { - const key = getColKey(column) + {cols.map((col) => { + const { key, column } = col return ( - {columns.map((column, index) => ( - + {cols.map((col) => ( + ))} - - {columns.map((column, index) => { - const key = getColKey(column) - return ( - - ) - })} - + {rows.map((row) => { + return ( + + {row.map(({ column, colSpan, rowSpan, isLast }) => { + const key = getColKey(column) + return ( + + ) + })} + + ) + })}
{ - column.type !== 'selection' && - handleColHeaderClick(e, column) - }} - > - {column.type === 'selection' ? ( - - handleCheckboxUpdateChecked(column) - } - /> - ) : column.ellipsis ? ( -
- {typeof column.title === 'function' - ? column.title(column, index) - : column.title} -
- ) : typeof column.title === 'function' ? ( - column.title(column, index) - ) : ( - column.title - )} - {isColumnSortable(column) ? ( - - ) : null} - {isColumnFilterable(column) ? ( - - ) : null} -
{ + column.type !== 'selection' && + handleColHeaderClick(e, column) + }} + > + {column.type === 'selection' ? ( + + handleCheckboxUpdateChecked(column) + } + /> + ) : column.ellipsis ? ( +
+ {typeof column.title === 'function' + ? column.title(column) + : column.title} +
+ ) : typeof column.title === 'function' ? ( + column.title(column) + ) : ( + column.title + )} + {isColumnSortable(column) ? ( + + ) : null} + {isColumnFilterable(column) ? ( + + ) : null} +
diff --git a/src/data-table/src/interface.ts b/src/data-table/src/interface.ts index d2108ee94..136fd2f8a 100644 --- a/src/data-table/src/interface.ts +++ b/src/data-table/src/interface.ts @@ -3,6 +3,7 @@ import { CSSProperties, VNodeChild } from 'vue' import { NLocale } from '../../locales' import { MergedTheme } from '../../_mixins' import { DataTableTheme } from '../styles' +import { RowItem, ColItem } from './use-group-header' export type FilterOptionValue = string | number export type ColumnKey = string | number @@ -42,8 +43,22 @@ export interface CommonColInfo { ellipsis?: boolean } +export type TableColumnTitle = + | string + | ((column: TableColumnInfo) => VNodeChild) + +export type TableColumnGroup = { + title?: TableColumnTitle + type?: never + key: ColumnKey + children: TableColumnInfo[] + + // to suppress type error in table header + filterOptions?: never +} & CommonColInfo + export type TableColumnInfo = { - title?: string | ((column: TableColumnInfo, index: number) => VNodeChild) + title?: TableColumnTitle // for compat maybe default type?: never key: ColumnKey @@ -80,14 +95,18 @@ export type SelectionColInfo = { filterOptionValue?: never } & CommonColInfo +export type TableColumn = TableColumnGroup | TableColumnInfo | SelectionColInfo +export type TableColumns = TableColumn[] + export interface DataTableInjection { mergedTheme: MergedTheme scrollX?: string | number - columns: Array + rows: RowItem[][] + cols: ColItem[] treeMate: TreeMate paginatedData: TmNode[] - leftFixedColumns: Array - rightFixedColumns: Array + leftFixedColumns: TableColumns + rightFixedColumns: TableColumns leftActiveFixedColKey: ColumnKey | null rightActiveFixedColKey: ColumnKey | null fixedColumnLeftMap: Record diff --git a/src/data-table/src/styles/index.cssr.ts b/src/data-table/src/styles/index.cssr.ts index b7d4fad84..78b0d6bb5 100644 --- a/src/data-table/src/styles/index.cssr.ts +++ b/src/data-table/src/styles/index.cssr.ts @@ -91,7 +91,7 @@ export default c([ cB('data-table-th', { borderRight: '1px solid var(--border-color)' }, [ - c('&:last-child', { + cM('last', { borderRight: '0 solid var(--border-color)' }) ]), @@ -191,6 +191,7 @@ export default c([ box-sizing: border-box; background-color: var(--th-color); border-color: var(--border-color); + border-bottom: 1px solid var(--border-color); color: var(--th-text-color); transition: border-color .3s var(--bezier), @@ -260,10 +261,6 @@ export default c([ width: 0, height: 0 }), - cB('data-table-table', { - borderBottom: '1px solid var(--border-color)', - transition: 'border-color .3s var(--bezier)' - }), cB('data-table-th', [ cB('data-table-sorter', ` height: 14px; diff --git a/src/data-table/src/use-group-header.ts b/src/data-table/src/use-group-header.ts new file mode 100644 index 000000000..1807ed7bc --- /dev/null +++ b/src/data-table/src/use-group-header.ts @@ -0,0 +1,110 @@ +import { CSSProperties, ComputedRef, computed } from 'vue' +import { DataTableProps } from './DataTable' +import type { + SelectionColInfo, + TableColumn, + TableColumnInfo, + TableColumns +} from './interface' +import { getColKey, createCustomWidthStyle } from './utils' + +export interface RowItem { + colSpan: number + rowSpan: number + column: TableColumn + isLast: boolean +} +export interface ColItem { + key: string | number + style: CSSProperties + column: SelectionColInfo | TableColumnInfo +} + +type RowItemMap = WeakMap +function getRowsAndCols ( + columns: TableColumns +): { + rows: RowItem[][] + cols: ColItem[] + dataRelatedCols: Array + } { + const rows: RowItem[][] = [] + const cols: ColItem[] = [] + const dataRelatedCols: Array = [] + const rowItemMap: RowItemMap = new WeakMap() + let maxDepth = -1 + function ensureMaxDepth (columns: TableColumns, currentDepth: number): void { + if (currentDepth > maxDepth) { + rows[currentDepth] = [] + maxDepth = currentDepth + } + for (const column of columns) { + if ('children' in column) { + ensureMaxDepth(column.children, currentDepth + 1) + } else { + cols.push({ + key: getColKey(column), + style: createCustomWidthStyle(column), + column + }) + dataRelatedCols.push(column) + } + } + } + ensureMaxDepth(columns, 0) + function ensureColLayout ( + columns: TableColumns, + currentDepth: number, + parentIsLast: boolean + ): void { + const lastIndex = columns.length - 1 + columns.forEach((column, index) => { + const isLast = parentIsLast && index === lastIndex + if ('children' in column) { + const rowItem: RowItem = { + column, + colSpan: 0, + rowSpan: 1, + isLast + } + ensureColLayout(column.children, currentDepth + 1, isLast) + column.children.forEach((childColumn) => { + rowItem.colSpan += rowItemMap.get(childColumn)?.colSpan ?? 0 + }) + rowItemMap.set(column, rowItem) + rows[currentDepth].push(rowItem) + } else { + const rowItem: RowItem = { + column, + colSpan: 1, + rowSpan: maxDepth - currentDepth + 1, + isLast + } + rowItemMap.set(column, rowItem) + rows[currentDepth].push(rowItem) + } + }) + } + ensureColLayout(columns, 0, true) + + return { + rows, + cols, + dataRelatedCols + } +} + +export function useGroupHeader ( + props: DataTableProps +): { + rows: ComputedRef + cols: ComputedRef + dataRelatedCols: ComputedRef> + } { + const rowsAndCols = computed(() => getRowsAndCols(props.columns)) + return { + rows: computed(() => rowsAndCols.value.rows), + cols: computed(() => rowsAndCols.value.cols), + dataRelatedCols: computed(() => rowsAndCols.value.dataRelatedCols) + } +} diff --git a/src/data-table/src/use-table-data.ts b/src/data-table/src/use-table-data.ts index 4afdb455e..fcaf66aaf 100644 --- a/src/data-table/src/use-table-data.ts +++ b/src/data-table/src/use-table-data.ts @@ -1,4 +1,4 @@ -import { computed, ref } from 'vue' +import { computed, ref, ComputedRef } from 'vue' import { useMergedState } from 'vooks' import { createTreeMate } from 'treemate' import type { DataTableProps } from './DataTable' @@ -10,6 +10,7 @@ import type { SortOrder, SortState, TableColumnInfo, + SelectionColInfo, TableNode, TmNode } from './interface' @@ -20,7 +21,14 @@ import { call, warn } from '../../_utils' // useTableData combines filter, sorter and pagination // eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export function useTableData (props: DataTableProps) { +export function useTableData ( + props: DataTableProps, + { + dataRelatedCols + }: { + dataRelatedCols: ComputedRef> + } +) { const treeMateRef = computed(() => createTreeMate(props.data, { getKey: props.rowKey @@ -32,7 +40,7 @@ export function useTableData (props: DataTableProps) { const uncontrolledCurrentPageRef = ref(1) const uncontrolledPageSizeRef = ref(10) - props.columns.forEach((column) => { + dataRelatedCols.value.forEach((column) => { if (column.sorter !== undefined) { uncontrolledSortStateRef.value = { columnKey: column.key, @@ -90,7 +98,7 @@ export function useTableData (props: DataTableProps) { const mergedSortStateRef = computed(() => { // If one of the columns's sort order is false or 'ascend' or 'descend', // the table's controll functionality should work in controlled manner. - const columnsWithControlledSortOrder = props.columns.filter( + const columnsWithControlledSortOrder = dataRelatedCols.value.filter( (column) => column.type !== 'selection' && column.sorter !== undefined && @@ -120,12 +128,14 @@ export function useTableData (props: DataTableProps) { }) const mergedFilterStateRef = computed(() => { - const columnsWithControlledFilter = props.columns.filter((column) => { - return ( - column.filterOptionValues !== undefined || - column.filterOptionValue !== undefined - ) - }) + const columnsWithControlledFilter = dataRelatedCols.value.filter( + (column) => { + return ( + column.filterOptionValues !== undefined || + column.filterOptionValue !== undefined + ) + } + ) const controlledFilterState: FilterState = {} columnsWithControlledFilter.forEach((column) => { if (column.type === 'selection') return @@ -315,7 +325,7 @@ export function useTableData (props: DataTableProps) { if (!columnKey) { clearSorter() } else { - const columnToSort = props.columns.find( + const columnToSort = dataRelatedCols.value.find( (column) => column.type !== 'selection' && column.key === columnKey ) if (!columnToSort || !columnToSort.sorter) return diff --git a/src/data-table/src/utils.ts b/src/data-table/src/utils.ts index a163a7d08..91c4852ec 100644 --- a/src/data-table/src/utils.ts +++ b/src/data-table/src/utils.ts @@ -7,7 +7,9 @@ import type { SortOrderFlag, SortState, CreateRowClassName, - SelectionColInfo + SelectionColInfo, + TableColumnGroup, + TableColumn } from './interface' export const selectionColWidth = 40 @@ -20,7 +22,7 @@ export function getColWidth ( } export function getColKey ( - col: TableColumnInfo | SelectionColInfo + col: TableColumnInfo | SelectionColInfo | TableColumnGroup ): string | number { if (col.type === 'selection') return '__n_selection__' return col.key @@ -41,8 +43,7 @@ export function getFlagOfOrder (order: SortOrder): SortOrderFlag { } export function createCustomWidthStyle ( - column: TableColumnInfo | SelectionColInfo, - index: number + column: TableColumnInfo | SelectionColInfo ): CSSProperties { return { width: pxfy(getColWidth(column)) @@ -69,15 +70,13 @@ export function shouldUseArrayInSingleMode (column: TableColumnInfo): boolean { ) } -export function isColumnSortable ( - column: TableColumnInfo | SelectionColInfo -): boolean { +export function isColumnSortable (column: TableColumn): boolean { + if ('children' in column) return false return !!column.sorter } -export function isColumnFilterable ( - column: TableColumnInfo | SelectionColInfo -): boolean { +export function isColumnFilterable (column: TableColumn): boolean { + if ('children' in column) return false return ( !!column.filter && (!!column.filterOptions || !!column.renderFilterMenu) )