feat(data-table): group header

This commit is contained in:
07akioni 2021-03-15 15:23:34 +08:00
parent 2da3e06a34
commit 242238e37a
12 changed files with 415 additions and 120 deletions

View File

@ -0,0 +1,75 @@
# Grouped Header
```html
<n-data-table
:data="data"
:columns="columns"
:single-line="false"
:pagination="pagination"
/>
```
```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
})
}
}
}
```

View File

@ -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<string \| number>` | `[]` | The default active filter option values in uncontrolled manner. (works when there are multiple filters) |

View File

@ -0,0 +1,75 @@
# 表头分组
```html
<n-data-table
:data="data"
:columns="columns"
:single-line="false"
:pagination="pagination"
/>
```
```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
})
}
}
}
```

View File

@ -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<string \| number>` | `[]` | 非受控状态下默认的过滤器选项值(过滤器多选时生效) |

View File

@ -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<DataTableTheme>),
@ -47,7 +47,7 @@ export const dataTableProps = {
minHeight: Number,
maxHeight: Number,
columns: {
type: Array as PropType<Array<TableColumnInfo | SelectionColInfo>>,
type: Array as PropType<TableColumns>,
default: () => []
},
data: {
@ -201,6 +201,7 @@ export default defineComponent({
props
)
const mainTableInstRef = ref<MainTableRef | null>(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,

View File

@ -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: () => (
<table ref="body" class="n-data-table-table">
<colgroup>
{NDataTable.columns.map((column, index) => (
<col
key={getColKey(column)}
style={createCustomWidthStyle(column, index)}
></col>
{NDataTable.cols.map((col) => (
<col key={col.key} style={col.style}></col>
))}
</colgroup>
<tbody ref="tbody" class="n-data-table-tbody">
@ -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 (
<td
key={key}

View File

@ -7,7 +7,6 @@ import FilterButton from '../HeaderButton/FilterButton'
import {
isColumnSortable,
isColumnFilterable,
createCustomWidthStyle,
createNextSorter,
getColKey
} from '../utils'
@ -56,14 +55,15 @@ export default defineComponent({
const {
NDataTable: {
scrollX,
columns,
fixedColumnLeftMap,
fixedColumnRightMap,
currentPage,
allRowsChecked,
someRowsChecked,
leftActiveFixedColKey,
rightActiveFixedColKey
rightActiveFixedColKey,
rows,
cols
},
headerStyle,
handleColHeaderClick,
@ -81,81 +81,88 @@ export default defineComponent({
style={{ width: formatLength(scrollX) }}
>
<colgroup>
{columns.map((column, index) => (
<col
key={getColKey(column)}
style={createCustomWidthStyle(column, index)}
/>
{cols.map((col) => (
<col key={col.key} style={col.style} />
))}
</colgroup>
<thead class="n-data-table-thead">
<tr class="n-data-table-tr">
{columns.map((column, index) => {
const key = getColKey(column)
return (
<th
key={key}
style={{
textAlign: column.align,
left: pxfy(fixedColumnLeftMap[key]),
right: pxfy(fixedColumnRightMap[key])
}}
class={[
'n-data-table-th',
column.fixed && `n-data-table-th--fixed-${column.fixed}`,
{
'n-data-table-th--filterable': isColumnFilterable(
column
),
'n-data-table-th--sortable': isColumnSortable(column),
'n-data-table-th--shadow-after':
leftActiveFixedColKey === key,
'n-data-table-th--shadow-before':
rightActiveFixedColKey === key,
'n-data-table-th--selection':
column.type === 'selection'
},
column.className
]}
onClick={(e) => {
column.type !== 'selection' &&
handleColHeaderClick(e, column)
}}
>
{column.type === 'selection' ? (
<NCheckbox
key={currentPage}
tableHeader
checked={allRowsChecked}
indeterminate={someRowsChecked}
onUpdateChecked={() =>
handleCheckboxUpdateChecked(column)
}
/>
) : column.ellipsis ? (
<div class="n-data-table-th__ellipsis">
{typeof column.title === 'function'
? column.title(column, index)
: column.title}
</div>
) : typeof column.title === 'function' ? (
column.title(column, index)
) : (
column.title
)}
{isColumnSortable(column) ? (
<SortButton column={column as TableColumnInfo} />
) : null}
{isColumnFilterable(column) ? (
<FilterButton
column={column as TableColumnInfo}
options={column.filterOptions}
/>
) : null}
</th>
)
})}
</tr>
{rows.map((row) => {
return (
<tr class="n-data-table-tr">
{row.map(({ column, colSpan, rowSpan, isLast }) => {
const key = getColKey(column)
return (
<th
key={key}
style={{
textAlign: column.align,
left: pxfy(fixedColumnLeftMap[key]),
right: pxfy(fixedColumnRightMap[key])
}}
colspan={colSpan}
rowspan={rowSpan}
class={[
'n-data-table-th',
column.fixed &&
`n-data-table-th--fixed-${column.fixed}`,
{
'n-data-table-th--filterable': isColumnFilterable(
column
),
'n-data-table-th--sortable': isColumnSortable(
column
),
'n-data-table-th--shadow-after':
leftActiveFixedColKey === key,
'n-data-table-th--shadow-before':
rightActiveFixedColKey === key,
'n-data-table-th--selection':
column.type === 'selection',
'n-data-table-th--last': isLast
},
column.className
]}
onClick={(e) => {
column.type !== 'selection' &&
handleColHeaderClick(e, column)
}}
>
{column.type === 'selection' ? (
<NCheckbox
key={currentPage}
tableHeader
checked={allRowsChecked}
indeterminate={someRowsChecked}
onUpdateChecked={() =>
handleCheckboxUpdateChecked(column)
}
/>
) : column.ellipsis ? (
<div class="n-data-table-th__ellipsis">
{typeof column.title === 'function'
? column.title(column)
: column.title}
</div>
) : typeof column.title === 'function' ? (
column.title(column)
) : (
column.title
)}
{isColumnSortable(column) ? (
<SortButton column={column as TableColumnInfo} />
) : null}
{isColumnFilterable(column) ? (
<FilterButton
column={column as TableColumnInfo}
options={column.filterOptions}
/>
) : null}
</th>
)
})}
</tr>
)
})}
</thead>
</table>
</div>

View File

@ -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<DataTableTheme>
scrollX?: string | number
columns: Array<TableColumnInfo | SelectionColInfo>
rows: RowItem[][]
cols: ColItem[]
treeMate: TreeMate<TableNode>
paginatedData: TmNode[]
leftFixedColumns: Array<TableColumnInfo | SelectionColInfo>
rightFixedColumns: Array<TableColumnInfo | SelectionColInfo>
leftFixedColumns: TableColumns
rightFixedColumns: TableColumns
leftActiveFixedColKey: ColumnKey | null
rightActiveFixedColKey: ColumnKey | null
fixedColumnLeftMap: Record<ColumnKey, number | undefined>

View File

@ -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;

View File

@ -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<TableColumn, RowItem>
function getRowsAndCols (
columns: TableColumns
): {
rows: RowItem[][]
cols: ColItem[]
dataRelatedCols: Array<SelectionColInfo | TableColumnInfo>
} {
const rows: RowItem[][] = []
const cols: ColItem[] = []
const dataRelatedCols: Array<SelectionColInfo | TableColumnInfo> = []
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<RowItem[][]>
cols: ComputedRef<ColItem[]>
dataRelatedCols: ComputedRef<Array<SelectionColInfo | TableColumnInfo>>
} {
const rowsAndCols = computed(() => getRowsAndCols(props.columns))
return {
rows: computed(() => rowsAndCols.value.rows),
cols: computed(() => rowsAndCols.value.cols),
dataRelatedCols: computed(() => rowsAndCols.value.dataRelatedCols)
}
}

View File

@ -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<Array<SelectionColInfo | TableColumnInfo>>
}
) {
const treeMateRef = computed(() =>
createTreeMate<TableNode>(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<SortState | null>(() => {
// 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<FilterState>(() => {
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

View File

@ -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)
)