From c9db47533fddcb14e0b23e19494da8ee005504b4 Mon Sep 17 00:00:00 2001 From: tangjinzhou <415800467@qq.com> Date: Thu, 2 Sep 2021 23:06:52 +0800 Subject: [PATCH] refactor: table --- components/_util/reactivePick.ts | 14 ++ components/new-table/Cell/index.tsx | 214 ++++++++++++++++ components/new-table/ColGroup.tsx | 37 +++ components/new-table/Header/Header.tsx | 127 ++++++++++ components/new-table/Header/HeaderRow.tsx | 96 +++++++ components/new-table/Panel/index.tsx | 7 + components/new-table/context/BodyContext.tsx | 43 ++++ .../new-table/context/ResizeContext.tsx | 16 ++ components/new-table/context/TableContext.tsx | 28 +++ components/new-table/hooks/useColumns.tsx | 238 ++++++++++++++++++ .../new-table/hooks/useFlattenRecords.ts | 85 +++++++ components/new-table/hooks/useFrame.ts | 79 ++++++ components/new-table/hooks/useSticky.ts | 40 +++ .../new-table/hooks/useStickyOffsets.ts | 55 ++++ components/new-table/index.ts | 10 + components/new-table/interface.ts | 226 +++++++++++++++++ components/new-table/sugar/Column.tsx | 13 + components/new-table/sugar/ColumnGroup.tsx | 13 + components/new-table/utils/expandUtil.tsx | 51 ++++ components/new-table/utils/fixUtil.ts | 69 +++++ components/new-table/utils/legacyUtil.ts | 55 ++++ components/new-table/utils/valueUtil.tsx | 91 +++++++ tsconfig.json | 2 +- 23 files changed, 1608 insertions(+), 1 deletion(-) create mode 100644 components/_util/reactivePick.ts create mode 100644 components/new-table/Cell/index.tsx create mode 100644 components/new-table/ColGroup.tsx create mode 100644 components/new-table/Header/Header.tsx create mode 100644 components/new-table/Header/HeaderRow.tsx create mode 100644 components/new-table/Panel/index.tsx create mode 100644 components/new-table/context/BodyContext.tsx create mode 100644 components/new-table/context/ResizeContext.tsx create mode 100644 components/new-table/context/TableContext.tsx create mode 100644 components/new-table/hooks/useColumns.tsx create mode 100644 components/new-table/hooks/useFlattenRecords.ts create mode 100644 components/new-table/hooks/useFrame.ts create mode 100644 components/new-table/hooks/useSticky.ts create mode 100644 components/new-table/hooks/useStickyOffsets.ts create mode 100644 components/new-table/index.ts create mode 100644 components/new-table/interface.ts create mode 100644 components/new-table/sugar/Column.tsx create mode 100644 components/new-table/sugar/ColumnGroup.tsx create mode 100644 components/new-table/utils/expandUtil.tsx create mode 100644 components/new-table/utils/fixUtil.ts create mode 100644 components/new-table/utils/legacyUtil.ts create mode 100644 components/new-table/utils/valueUtil.tsx diff --git a/components/_util/reactivePick.ts b/components/_util/reactivePick.ts new file mode 100644 index 000000000..4911106cc --- /dev/null +++ b/components/_util/reactivePick.ts @@ -0,0 +1,14 @@ +import type { UnwrapRef } from 'vue'; +import { reactive, toRef } from 'vue'; + +/** + * Reactively pick fields from a reactive object + * + * @see https://vueuse.js.org/reactivePick + */ +export function reactivePick( + obj: T, + ...keys: K[] +): { [S in K]: UnwrapRef } { + return reactive(Object.fromEntries(keys.map(k => [k, toRef(obj, k)]))) as any; +} diff --git a/components/new-table/Cell/index.tsx b/components/new-table/Cell/index.tsx new file mode 100644 index 000000000..a7632be47 --- /dev/null +++ b/components/new-table/Cell/index.tsx @@ -0,0 +1,214 @@ +import classNames from 'ant-design-vue/es/_util/classNames'; +import { isValidElement } from 'ant-design-vue/es/_util/props-util'; +import { CSSProperties, defineComponent, HTMLAttributes } from 'vue'; + +import type { + DataIndex, + ColumnType, + RenderedCell, + CustomizeComponent, + CellType, + DefaultRecordType, + AlignType, + CellEllipsisType, +} from '../interface'; +import { getPathValue, validateValue } from '../utils/valueUtil'; + +function isRenderCell( + data: RenderedCell, +): data is RenderedCell { + return data && typeof data === 'object' && !Array.isArray(data) && !isValidElement(data); +} + +export interface CellProps { + prefixCls?: string; + className?: string; + record?: RecordType; + /** `record` index. Not `column` index. */ + index?: number; + dataIndex?: DataIndex; + customRender?: ColumnType['customRender']; + component?: CustomizeComponent; + children?: any; + colSpan?: number; + rowSpan?: number; + ellipsis?: CellEllipsisType; + align?: AlignType; + + // Fixed + fixLeft?: number | false; + fixRight?: number | false; + firstFixLeft?: boolean; + lastFixLeft?: boolean; + firstFixRight?: boolean; + lastFixRight?: boolean; + + // Additional + /** @private Used for `expandable` with nest tree */ + appendNode?: any; + additionalProps?: Omit & { style?: CSSProperties }; + + rowType?: 'header' | 'body' | 'footer'; + + isSticky?: boolean; + + column?: ColumnType; +} +export default defineComponent({ + name: 'Cell', + props: [] as any, + slots: ['appendNode'], + setup(props) { + return () => { + const { + prefixCls, + className, + record, + index, + dataIndex, + customRender, + children, + component: Component = 'td', + colSpan, + rowSpan, + fixLeft, + fixRight, + firstFixLeft, + lastFixLeft, + firstFixRight, + lastFixRight, + appendNode, + additionalProps = {}, + ellipsis, + align, + rowType, + isSticky, + column, + } = props; + const cellPrefixCls = `${prefixCls}-cell`; + + // ==================== Child Node ==================== + let cellProps: CellType; + let childNode; + + if (validateValue(children)) { + childNode = children; + } else { + const value = getPathValue(record, dataIndex); + + // Customize render node + childNode = value; + if (customRender) { + const renderData = customRender({ text: value, value, record, index, column }); + + if (isRenderCell(renderData)) { + childNode = renderData.children; + cellProps = renderData.props; + } else { + childNode = renderData; + } + } + } + + // Not crash if final `childNode` is not validate ReactNode + if ( + typeof childNode === 'object' && + !Array.isArray(childNode) && + !isValidElement(childNode) + ) { + childNode = null; + } + + if (ellipsis && (lastFixLeft || firstFixRight)) { + childNode = {childNode}; + } + + const { + colSpan: cellColSpan, + rowSpan: cellRowSpan, + style: cellStyle, + className: cellClassName, + class: cellClass, + ...restCellProps + } = cellProps || {}; + const mergedColSpan = cellColSpan !== undefined ? cellColSpan : colSpan; + const mergedRowSpan = cellRowSpan !== undefined ? cellRowSpan : rowSpan; + + if (mergedColSpan === 0 || mergedRowSpan === 0) { + return null; + } + + // ====================== Fixed ======================= + const fixedStyle: CSSProperties = {}; + const isFixLeft = typeof fixLeft === 'number'; + const isFixRight = typeof fixRight === 'number'; + + if (isFixLeft) { + fixedStyle.position = 'sticky'; + fixedStyle.left = `${fixLeft}px`; + } + if (isFixRight) { + fixedStyle.position = 'sticky'; + + fixedStyle.right = `${fixRight}px`; + } + + // ====================== Align ======================= + const alignStyle: CSSProperties = {}; + if (align) { + alignStyle.textAlign = align; + } + + // ====================== Render ====================== + let title: string; + const ellipsisConfig: CellEllipsisType = ellipsis === true ? { showTitle: true } : ellipsis; + if (ellipsisConfig && (ellipsisConfig.showTitle || rowType === 'header')) { + debugger; + if (typeof childNode === 'string' || typeof childNode === 'number') { + title = childNode.toString(); + } else if (isValidElement(childNode) && typeof childNode.props.children === 'string') { + title = childNode.props.children; + } + } + + const componentProps = { + title, + ...restCellProps, + ...additionalProps, + colSpan: mergedColSpan && mergedColSpan !== 1 ? mergedColSpan : null, + rowSpan: mergedRowSpan && mergedRowSpan !== 1 ? mergedRowSpan : null, + class: classNames( + cellPrefixCls, + className, + { + [`${cellPrefixCls}-fix-left`]: isFixLeft, + [`${cellPrefixCls}-fix-left-first`]: firstFixLeft, + [`${cellPrefixCls}-fix-left-last`]: lastFixLeft, + [`${cellPrefixCls}-fix-right`]: isFixRight, + [`${cellPrefixCls}-fix-right-first`]: firstFixRight, + [`${cellPrefixCls}-fix-right-last`]: lastFixRight, + [`${cellPrefixCls}-ellipsis`]: ellipsis, + [`${cellPrefixCls}-with-append`]: appendNode, + [`${cellPrefixCls}-fix-sticky`]: (isFixLeft || isFixRight) && isSticky, + }, + additionalProps.class, + cellClassName, + cellClass, + ), + style: { + ...additionalProps.style, + ...alignStyle, + ...fixedStyle, + ...cellStyle, + }, + }; + + return ( + + {appendNode} + {childNode} + + ); + }; + }, +}); diff --git a/components/new-table/ColGroup.tsx b/components/new-table/ColGroup.tsx new file mode 100644 index 000000000..bb72b3859 --- /dev/null +++ b/components/new-table/ColGroup.tsx @@ -0,0 +1,37 @@ +import type { ColumnType } from './interface'; +import { INTERNAL_COL_DEFINE } from './utils/legacyUtil'; + +export interface ColGroupProps { + colWidths: readonly (number | string)[]; + columns?: readonly ColumnType[]; + columCount?: number; +} + +function ColGroup({ colWidths, columns, columCount }: ColGroupProps) { + const cols = []; + const len = columCount || columns.length; + + // Only insert col with width & additional props + // Skip if rest col do not have any useful info + let mustInsert = false; + for (let i = len - 1; i >= 0; i -= 1) { + const width = colWidths[i]; + const column = columns && columns[i]; + const additionalProps = column && column[INTERNAL_COL_DEFINE]; + + if (width || additionalProps || mustInsert) { + cols.unshift( + , + ); + mustInsert = true; + } + } + + return {cols}; +} + +export default ColGroup; diff --git a/components/new-table/Header/Header.tsx b/components/new-table/Header/Header.tsx new file mode 100644 index 000000000..a723aa72a --- /dev/null +++ b/components/new-table/Header/Header.tsx @@ -0,0 +1,127 @@ +import classNames from 'ant-design-vue/es/_util/classNames'; +import { computed, defineComponent } from 'vue'; +import { useInjectTable } from '../context/TableContext'; +import type { + ColumnsType, + CellType, + StickyOffsets, + ColumnType, + GetComponentProps, + ColumnGroupType, + DefaultRecordType, +} from '../interface'; +import HeaderRow from './HeaderRow'; + +function parseHeaderRows( + rootColumns: ColumnsType, +): CellType[][] { + const rows: CellType[][] = []; + + function fillRowCells( + columns: ColumnsType, + colIndex: number, + rowIndex: number = 0, + ): number[] { + // Init rows + rows[rowIndex] = rows[rowIndex] || []; + + let currentColIndex = colIndex; + const colSpans: number[] = columns.filter(Boolean).map(column => { + const cell: CellType = { + key: column.key, + className: classNames(column.className, column.class), + children: column.title, + column, + colStart: currentColIndex, + }; + + let colSpan: number = 1; + + const subColumns = (column as ColumnGroupType).children; + if (subColumns && subColumns.length > 0) { + colSpan = fillRowCells(subColumns, currentColIndex, rowIndex + 1).reduce( + (total, count) => total + count, + 0, + ); + cell.hasSubColumns = true; + } + + if ('colSpan' in column) { + ({ colSpan } = column); + } + + if ('rowSpan' in column) { + cell.rowSpan = column.rowSpan; + } + + cell.colSpan = colSpan; + cell.colEnd = cell.colStart + colSpan - 1; + rows[rowIndex].push(cell); + + currentColIndex += colSpan; + + return colSpan; + }); + + return colSpans; + } + + // Generate `rows` cell data + fillRowCells(rootColumns, 0); + + // Handle `rowSpan` + const rowCount = rows.length; + for (let rowIndex = 0; rowIndex < rowCount; rowIndex += 1) { + rows[rowIndex].forEach(cell => { + if (!('rowSpan' in cell) && !cell.hasSubColumns) { + // eslint-disable-next-line no-param-reassign + cell.rowSpan = rowCount - rowIndex; + } + }); + } + + return rows; +} + +export interface HeaderProps { + columns: ColumnsType; + flattenColumns: readonly ColumnType[]; + stickyOffsets: StickyOffsets; + onHeaderRow: GetComponentProps[]>; +} + +export default defineComponent({ + name: 'Header', + props: ['columns', 'flattenColumns', 'stickyOffsets', 'onHeaderRow'] as any, + setup(props) { + const tableContext = useInjectTable(); + const rows = computed(() => parseHeaderRows(props.columns)); + return () => { + const { prefixCls, getComponent } = tableContext; + const { stickyOffsets, flattenColumns, onHeaderRow } = props; + const WrapperComponent = getComponent(['header', 'wrapper'], 'thead'); + const trComponent = getComponent(['header', 'row'], 'tr'); + const thComponent = getComponent(['header', 'cell'], 'th'); + return ( + + {rows.value.map((row, rowIndex) => { + const rowNode = ( + + ); + + return rowNode; + })} + + ); + }; + }, +}); diff --git a/components/new-table/Header/HeaderRow.tsx b/components/new-table/Header/HeaderRow.tsx new file mode 100644 index 000000000..af1b4e845 --- /dev/null +++ b/components/new-table/Header/HeaderRow.tsx @@ -0,0 +1,96 @@ +import { defineComponent } from 'vue'; +import Cell from '../Cell'; +import { useInjectTable } from '../context/TableContext'; +import { + CellType, + StickyOffsets, + ColumnType, + CustomizeComponent, + GetComponentProps, + DefaultRecordType, +} from '../interface'; +import { getCellFixedInfo } from '../utils/fixUtil'; +import { getColumnsKey } from '../utils/valueUtil'; + +export interface RowProps { + cells: readonly CellType[]; + stickyOffsets: StickyOffsets; + flattenColumns: readonly ColumnType[]; + rowComponent: CustomizeComponent; + cellComponent: CustomizeComponent; + onHeaderRow: GetComponentProps[]>; + index: number; +} + +export default defineComponent({ + name: 'HeaderRow', + props: [ + 'cells', + 'stickyOffsets', + 'flattenColumns', + 'rowComponent', + 'cellComponent', + 'index', + 'onHeaderRow', + ] as any, + setup(props: RowProps) { + const tableContext = useInjectTable(); + return () => { + const { prefixCls, direction } = tableContext; + const { + cells, + stickyOffsets, + flattenColumns, + rowComponent: RowComponent, + cellComponent: CellComponent, + onHeaderRow, + index, + } = props; + + let rowProps; + if (onHeaderRow) { + rowProps = onHeaderRow( + cells.map(cell => cell.column), + index, + ); + } + + const columnsKey = getColumnsKey(cells.map(cell => cell.column)); + + return ( + + {cells.map((cell: CellType, cellIndex) => { + const { column } = cell; + const fixedInfo = getCellFixedInfo( + cell.colStart, + cell.colEnd, + flattenColumns, + stickyOffsets, + direction, + ); + + let additionalProps; + if (column && column.onHeaderCell) { + additionalProps = cell.column.onHeaderCell(column); + } + + return ( + + ); + })} + + ); + }; + }, +}); diff --git a/components/new-table/Panel/index.tsx b/components/new-table/Panel/index.tsx new file mode 100644 index 000000000..3e8800a17 --- /dev/null +++ b/components/new-table/Panel/index.tsx @@ -0,0 +1,7 @@ +function Panel(_, { slots }) { + return
{slots.default?.()}
; +} + +Panel.displayName = 'Panel'; + +export default Panel; diff --git a/components/new-table/context/BodyContext.tsx b/components/new-table/context/BodyContext.tsx new file mode 100644 index 000000000..2f0973d06 --- /dev/null +++ b/components/new-table/context/BodyContext.tsx @@ -0,0 +1,43 @@ +import { + ColumnType, + DefaultRecordType, + ColumnsType, + TableLayout, + RenderExpandIcon, + ExpandableType, + RowClassName, + TriggerEventHandler, + ExpandedRowRender, +} from '../interface'; +import { inject, InjectionKey, provide } from 'vue'; + +export interface BodyContextProps { + rowClassName: string | RowClassName; + expandedRowClassName: RowClassName; + + columns: ColumnsType; + flattenColumns: readonly ColumnType[]; + + componentWidth: number; + tableLayout: TableLayout; + fixHeader: boolean; + fixColumn: boolean; + horizonScroll: boolean; + + indentSize: number; + expandableType: ExpandableType; + expandRowByClick: boolean; + expandedRowRender: ExpandedRowRender; + expandIcon: RenderExpandIcon; + onTriggerExpand: TriggerEventHandler; + expandIconColumnIndex: number; +} +export const BodyContextKey: InjectionKey = Symbol('BodyContextProps'); + +export const useProvideBody = (props: BodyContextProps) => { + provide(BodyContextKey, props); +}; + +export const useInjectBody = () => { + return inject(BodyContextKey, {} as BodyContextProps); +}; diff --git a/components/new-table/context/ResizeContext.tsx b/components/new-table/context/ResizeContext.tsx new file mode 100644 index 000000000..292035c1a --- /dev/null +++ b/components/new-table/context/ResizeContext.tsx @@ -0,0 +1,16 @@ +import { inject, InjectionKey, provide } from 'vue'; +import { Key } from '../interface'; + +interface ResizeContextProps { + onColumnResize: (columnKey: Key, width: number) => void; +} + +export const ResizeContextKey: InjectionKey = Symbol('ResizeContextProps'); + +export const useProvideResize = (props: ResizeContextProps) => { + provide(ResizeContextKey, props); +}; + +export const useInjectResize = () => { + return inject(ResizeContextKey, { onColumnResize: () => {} }); +}; diff --git a/components/new-table/context/TableContext.tsx b/components/new-table/context/TableContext.tsx new file mode 100644 index 000000000..557007418 --- /dev/null +++ b/components/new-table/context/TableContext.tsx @@ -0,0 +1,28 @@ +import { inject, InjectionKey, provide } from 'vue'; +import { GetComponent } from '../interface'; +import { FixedInfo } from '../utils/fixUtil'; + +export interface TableContextProps { + // Table context + prefixCls: string; + + getComponent: GetComponent; + + scrollbarSize: number; + + direction: 'ltr' | 'rtl'; + + fixedInfoList: readonly FixedInfo[]; + + isSticky: boolean; +} + +export const BodyContextKey: InjectionKey = Symbol('TableContextProps'); + +export const useProvideTable = (props: TableContextProps) => { + provide(BodyContextKey, props); +}; + +export const useInjectTable = () => { + return inject(BodyContextKey, {} as TableContextProps); +}; diff --git a/components/new-table/hooks/useColumns.tsx b/components/new-table/hooks/useColumns.tsx new file mode 100644 index 000000000..cc8f2ceb7 --- /dev/null +++ b/components/new-table/hooks/useColumns.tsx @@ -0,0 +1,238 @@ +import { warning } from 'ant-design-vue/es/vc-util/warning'; +import { computed, ComputedRef, Ref, watchEffect } from 'vue'; +import type { + ColumnsType, + ColumnType, + FixedType, + Key, + GetRowKey, + TriggerEventHandler, + RenderExpandIcon, + ColumnGroupType, +} from '../interface'; +import { INTERNAL_COL_DEFINE } from '../utils/legacyUtil'; + +export function convertChildrenToColumns( + children: any[] = [], +): ColumnsType { + return children.map(({ key, props }) => { + const { children: nodeChildren, ...restProps } = props; + const column = { + key, + ...restProps, + }; + + if (nodeChildren) { + column.children = convertChildrenToColumns(nodeChildren); + } + + return column; + }); +} + +function flatColumns(columns: ColumnsType): ColumnType[] { + return columns.reduce((list, column) => { + const { fixed } = column; + + // Convert `fixed='true'` to `fixed='left'` instead + const parsedFixed = fixed === true ? 'left' : fixed; + + const subColumns = (column as ColumnGroupType).children; + if (subColumns && subColumns.length > 0) { + return [ + ...list, + ...flatColumns(subColumns).map(subColum => ({ + fixed: parsedFixed, + ...subColum, + })), + ]; + } + return [ + ...list, + { + ...column, + fixed: parsedFixed, + }, + ]; + }, []); +} + +function warningFixed(flattenColumns: readonly { fixed?: FixedType }[]) { + let allFixLeft = true; + for (let i = 0; i < flattenColumns.length; i += 1) { + const col = flattenColumns[i]; + if (allFixLeft && col.fixed !== 'left') { + allFixLeft = false; + } else if (!allFixLeft && col.fixed === 'left') { + warning(false, `Index ${i - 1} of \`columns\` missing \`fixed='left'\` prop.`); + break; + } + } + + let allFixRight = true; + for (let i = flattenColumns.length - 1; i >= 0; i -= 1) { + const col = flattenColumns[i]; + if (allFixRight && col.fixed !== 'right') { + allFixRight = false; + } else if (!allFixRight && col.fixed === 'right') { + warning(false, `Index ${i + 1} of \`columns\` missing \`fixed='right'\` prop.`); + break; + } + } +} + +function revertForRtl(columns: ColumnsType): ColumnsType { + return columns.map(column => { + const { fixed, ...restProps } = column; + + // Convert `fixed='left'` to `fixed='right'` instead + let parsedFixed = fixed; + if (fixed === 'left') { + parsedFixed = 'right'; + } else if (fixed === 'right') { + parsedFixed = 'left'; + } + return { + fixed: parsedFixed, + ...restProps, + }; + }); +} + +/** + * Parse `columns` & `children` into `columns`. + */ +function useColumns( + { + prefixCls, + columns: baseColumns, + // children, + expandable, + expandedKeys, + getRowKey, + onTriggerExpand, + expandIcon, + rowExpandable, + expandIconColumnIndex, + direction, + expandRowByClick, + columnWidth, + fixed, + }: { + prefixCls?: Ref; + columns?: Ref>; + // children?: React.ReactNode; + expandable: Ref; + expandedKeys: Ref>; + getRowKey: GetRowKey; + onTriggerExpand: TriggerEventHandler; + expandIcon?: Ref>; + rowExpandable?: Ref<(record: RecordType) => boolean>; + expandIconColumnIndex?: Ref; + direction?: Ref<'ltr' | 'rtl'>; + expandRowByClick?: Ref; + columnWidth?: Ref; + fixed?: Ref; + }, + transformColumns: (columns: ColumnsType) => ColumnsType, +): [ComputedRef>, ComputedRef[]>] { + // const baseColumns = React.useMemo>( + // () => columns || convertChildrenToColumns(children), + // [columns, children], + // ); + + // Add expand column + const withExpandColumns = computed>(() => { + if (expandable.value) { + const expandColIndex = expandIconColumnIndex.value || 0; + const prevColumn = baseColumns[expandColIndex]; + + let fixedColumn: FixedType | null; + if ((fixed.value === 'left' || fixed.value) && !expandIconColumnIndex.value) { + fixedColumn = 'left'; + } else if ( + (fixed.value === 'right' || fixed.value) && + expandIconColumnIndex.value === baseColumns.value.length + ) { + fixedColumn = 'right'; + } else { + fixedColumn = prevColumn ? prevColumn.fixed : null; + } + const expandedKeysValue = expandedKeys.value; + const rowExpandableValue = rowExpandable.value; + const expandIconValue = expandIcon.value; + const prefixClsValue = prefixCls.value; + const expandRowByClickValue = expandRowByClick.value; + const expandColumn = { + [INTERNAL_COL_DEFINE]: { + class: `${prefixCls.value}-expand-icon-col`, + }, + title: '', + fixed: fixedColumn, + class: `${prefixCls.value}-row-expand-icon-cell`, + width: columnWidth.value, + render: (_, record, index) => { + const rowKey = getRowKey(record, index); + const expanded = expandedKeysValue.has(rowKey); + const recordExpandable = rowExpandableValue ? rowExpandableValue(record) : true; + + const icon = expandIconValue({ + prefixCls: prefixClsValue, + expanded, + expandable: recordExpandable, + record, + onExpand: onTriggerExpand, + }); + + if (expandRowByClickValue) { + return e.stopPropagation()}>{icon}; + } + return icon; + }, + }; + + // Insert expand column in the target position + const cloneColumns = baseColumns.value.slice(); + if (expandColIndex >= 0) { + cloneColumns.splice(expandColIndex, 0, expandColumn); + } + return cloneColumns; + } + return baseColumns.value; + }); + + const mergedColumns = computed(() => { + let finalColumns = withExpandColumns.value; + if (transformColumns) { + finalColumns = transformColumns(finalColumns); + } + + // Always provides at least one column for table display + if (!finalColumns.length) { + finalColumns = [ + { + customRender: () => null, + }, + ]; + } + return finalColumns; + }); + + const flattenColumns = computed(() => { + if (direction.value === 'rtl') { + return revertForRtl(flatColumns(mergedColumns.value)); + } + return flatColumns(mergedColumns.value); + }); + // Only check out of production since it's waste for each render + if (process.env.NODE_ENV !== 'production') { + watchEffect(() => { + setTimeout(() => { + warningFixed(flattenColumns.value); + }); + }); + } + return [mergedColumns, flattenColumns]; +} + +export default useColumns; diff --git a/components/new-table/hooks/useFlattenRecords.ts b/components/new-table/hooks/useFlattenRecords.ts new file mode 100644 index 000000000..87a284b25 --- /dev/null +++ b/components/new-table/hooks/useFlattenRecords.ts @@ -0,0 +1,85 @@ +import type { Ref } from 'vue'; +import { computed } from 'vue'; +import type { GetRowKey, Key } from '../interface'; + +// recursion (flat tree structure) +function flatRecord( + record: T, + indent: number, + childrenColumnName: string, + expandedKeys: Set, + getRowKey: GetRowKey, +) { + const arr = []; + + arr.push({ + record, + indent, + }); + + const key = getRowKey(record); + + const expanded = expandedKeys?.has(key); + + if (record && Array.isArray(record[childrenColumnName]) && expanded) { + // expanded state, flat record + for (let i = 0; i < record[childrenColumnName].length; i += 1) { + const tempArr = flatRecord( + record[childrenColumnName][i], + indent + 1, + childrenColumnName, + expandedKeys, + getRowKey, + ); + + arr.push(...tempArr); + } + } + + return arr; +} + +/** + * flat tree data on expanded state + * + * @export + * @template T + * @param {*} data : table data + * @param {string} childrenColumnName : 指定树形结构的列名 + * @param {Set} expandedKeys : 展开的行对应的keys + * @param {GetRowKey} getRowKey : 获取当前rowKey的方法 + * @returns flattened data + */ +export default function useFlattenRecords( + dataRef: Ref<[]>, + childrenColumnNameRef: Ref, + expandedKeysRef: Ref>, + getRowKey: GetRowKey, +) { + const arr: Ref<{ record: T; indent: number }[]> = computed(() => { + const childrenColumnName = childrenColumnNameRef.value; + const expandedKeys = expandedKeysRef.value; + const data = dataRef.value; + if (expandedKeys?.size) { + const temp: { record: T; indent: number }[] = []; + + // collect flattened record + for (let i = 0; i < data?.length; i += 1) { + const record = data[i]; + + temp.push(...flatRecord(record, 0, childrenColumnName, expandedKeys, getRowKey)); + } + + return temp; + } + + return data?.map(item => { + return { + record: item, + indent: 0, + }; + }); + }); + + return arr; +} diff --git a/components/new-table/hooks/useFrame.ts b/components/new-table/hooks/useFrame.ts new file mode 100644 index 000000000..04b6b1d03 --- /dev/null +++ b/components/new-table/hooks/useFrame.ts @@ -0,0 +1,79 @@ +import type { Ref, UnwrapRef } from 'vue'; +import { getCurrentInstance, onBeforeUnmount, ref } from 'vue'; + +export type Updater = (prev: State) => State; + +/** + * Execute code before next frame but async + */ +export function useLayoutState( + defaultState: State, +): [Ref, (updater: Updater) => void] { + const stateRef = ref(defaultState); + // const [, forceUpdate] = useState({}); + + const lastPromiseRef = ref>(null); + const updateBatchRef = ref[]>([]); + const instance = getCurrentInstance(); + function setFrameState(updater: Updater) { + updateBatchRef.value.push(updater); + + const promise = Promise.resolve(); + lastPromiseRef.value = promise; + + promise.then(() => { + if (lastPromiseRef.value === promise) { + const prevBatch = updateBatchRef.value; + const prevState = stateRef.value; + updateBatchRef.value = []; + + prevBatch.forEach(batchUpdater => { + stateRef.value = batchUpdater(stateRef.value as State) as UnwrapRef; + }); + + lastPromiseRef.value = null; + + if (prevState !== stateRef.value) { + instance.update(); + } + } + }); + } + onBeforeUnmount(() => { + lastPromiseRef.value = null; + }); + + return [stateRef as Ref, setFrameState]; +} + +/** Lock frame, when frame pass reset the lock. */ +export function useTimeoutLock( + defaultState?: State, +): [(state: UnwrapRef) => void, () => UnwrapRef | null] { + const frameRef = ref(defaultState || null); + const timeoutRef = ref(); + + function cleanUp() { + window.clearTimeout(timeoutRef.value); + } + + function setState(newState: UnwrapRef) { + frameRef.value = newState; + cleanUp(); + + timeoutRef.value = window.setTimeout(() => { + frameRef.value = null; + timeoutRef.value = undefined; + }, 100); + } + + function getState() { + return frameRef.value; + } + + onBeforeUnmount(() => { + cleanUp(); + }); + + return [setState, getState]; +} diff --git a/components/new-table/hooks/useSticky.ts b/components/new-table/hooks/useSticky.ts new file mode 100644 index 000000000..494abc7cf --- /dev/null +++ b/components/new-table/hooks/useSticky.ts @@ -0,0 +1,40 @@ +import canUseDom from '../../_util/canUseDom'; +import type { ComputedRef, Ref } from 'vue'; +import { computed } from 'vue'; +import type { TableSticky } from '../interface'; + +// fix ssr render +const defaultContainer = canUseDom() ? window : null; + +/** Sticky header hooks */ +export default function useSticky( + stickyRef: Ref, + prefixClsRef: Ref, +): ComputedRef<{ + isSticky: boolean; + offsetHeader: number; + offsetSummary: number; + offsetScroll: number; + stickyClassName: string; + container: Window | HTMLElement; +}> { + return computed(() => { + const { + offsetHeader = 0, + offsetSummary = 0, + offsetScroll = 0, + getContainer = () => defaultContainer, + } = typeof stickyRef.value === 'object' ? stickyRef.value : {}; + + const container = getContainer() || defaultContainer; + const isSticky = !!stickyRef.value; + return { + isSticky, + stickyClassName: isSticky ? `${prefixClsRef.value}-sticky-holder` : '', + offsetHeader, + offsetSummary, + offsetScroll, + container, + }; + }); +} diff --git a/components/new-table/hooks/useStickyOffsets.ts b/components/new-table/hooks/useStickyOffsets.ts new file mode 100644 index 000000000..1885ed7a5 --- /dev/null +++ b/components/new-table/hooks/useStickyOffsets.ts @@ -0,0 +1,55 @@ +import type { ComputedRef, Ref } from 'vue'; + +import { computed } from 'vue'; +import type { StickyOffsets } from '../interface'; + +/** + * Get sticky column offset width + */ +function useStickyOffsets( + colWidthsRef: Ref, + columnCountRef: Ref, + directionRef: Ref<'ltr' | 'rtl'>, +) { + const stickyOffsets: ComputedRef = computed(() => { + const leftOffsets: number[] = []; + const rightOffsets: number[] = []; + let left = 0; + let right = 0; + + const colWidths = colWidthsRef.value; + const columnCount = columnCountRef.value; + const direction = directionRef.value; + + for (let start = 0; start < columnCount; start += 1) { + if (direction === 'rtl') { + // Left offset + rightOffsets[start] = right; + right += colWidths[start] || 0; + + // Right offset + const end = columnCount - start - 1; + leftOffsets[end] = left; + left += colWidths[end] || 0; + } else { + // Left offset + leftOffsets[start] = left; + left += colWidths[start] || 0; + + // Right offset + const end = columnCount - start - 1; + rightOffsets[end] = right; + right += colWidths[end] || 0; + } + } + + return { + left: leftOffsets, + right: rightOffsets, + }; + }); + + return stickyOffsets; +} + +export default useStickyOffsets; diff --git a/components/new-table/index.ts b/components/new-table/index.ts new file mode 100644 index 000000000..efbde4cfd --- /dev/null +++ b/components/new-table/index.ts @@ -0,0 +1,10 @@ +// base rc-table@7.17.2 +import Table from './Table'; +import { FooterComponents as Summary } from './Footer'; +import Column from './sugar/Column'; +import ColumnGroup from './sugar/ColumnGroup'; +import { INTERNAL_COL_DEFINE } from './utils/legacyUtil'; + +export { Summary, Column, ColumnGroup, INTERNAL_COL_DEFINE }; + +export default Table; diff --git a/components/new-table/interface.ts b/components/new-table/interface.ts new file mode 100644 index 000000000..bc6553ee8 --- /dev/null +++ b/components/new-table/interface.ts @@ -0,0 +1,226 @@ +/** + * ColumnType which applied in antd: https://ant.design/components/table-cn/#Column + * - defaultSortOrder + * - filterDropdown + * - filterDropdownVisible + * - filtered + * - filteredValue + * - filterIcon + * - filterMultiple + * - filters + * - sorter + * - sortOrder + * - sortDirections + * - onFilter + * - onFilterDropdownVisibleChange + */ + +import type { CSSProperties, DefineComponent, FunctionalComponent, HTMLAttributes, Ref } from 'vue'; + +export type Key = number | string; + +export type FixedType = 'left' | 'right' | boolean; + +export type DefaultRecordType = Record; + +export type TableLayout = 'auto' | 'fixed'; + +// ==================== Row ===================== +export type RowClassName = ( + record: RecordType, + index: number, + indent: number, +) => string; + +// =================== Column =================== +export interface CellType { + key?: Key; + class?: string; + className?: string; + style?: CSSProperties; + children?: any; + column?: ColumnsType[number]; + colSpan?: number; + rowSpan?: number; + + /** Only used for table header */ + hasSubColumns?: boolean; + colStart?: number; + colEnd?: number; +} + +export interface RenderedCell { + props?: CellType; + children?: any; +} + +export type DataIndex = string | number | readonly (string | number)[]; + +export type CellEllipsisType = { showTitle?: boolean } | boolean; + +interface ColumnSharedType { + title?: any; + key?: Key; + class?: string; + className?: string; + fixed?: FixedType; + onHeaderCell?: GetComponentProps[number]>; + ellipsis?: CellEllipsisType; + align?: AlignType; +} + +export interface ColumnGroupType extends ColumnSharedType { + children: ColumnsType; +} + +export type AlignType = 'left' | 'center' | 'right'; + +export interface ColumnType extends ColumnSharedType { + colSpan?: number; + dataIndex?: DataIndex; + customRender?: (opt: { + value: any; + text: any; // 兼容 V2 + record: RecordType; + index: number; + column: ColumnType; + }) => any | RenderedCell; + rowSpan?: number; + width?: number | string; + onCell?: GetComponentProps; + /** @deprecated Please use `onCell` instead */ + onCellClick?: (record: RecordType, e: MouseEvent) => void; +} + +export type ColumnsType = readonly ( + | ColumnGroupType + | ColumnType +)[]; + +export type GetRowKey = (record: RecordType, index?: number) => Key; + +// ================= Fix Column ================= +export interface StickyOffsets { + left: readonly number[]; + right: readonly number[]; + isSticky?: boolean; +} + +// ================= Customized ================= +export type GetComponentProps = ( + data: DataType, + index?: number, +) => Omit & { style?: CSSProperties }; + +type Component

= DefineComponent

| FunctionalComponent

| string; + +export type CustomizeComponent = Component; + +export type CustomizeScrollBody = ( + data: readonly RecordType[], + info: { + scrollbarSize: number; + ref: Ref<{ scrollLeft: number }>; + onScroll: (info: { currentTarget?: HTMLElement; scrollLeft?: number }) => void; + }, +) => any; + +export interface TableComponents { + table?: CustomizeComponent; + header?: { + wrapper?: CustomizeComponent; + row?: CustomizeComponent; + cell?: CustomizeComponent; + }; + body?: + | CustomizeScrollBody + | { + wrapper?: CustomizeComponent; + row?: CustomizeComponent; + cell?: CustomizeComponent; + }; +} + +export type GetComponent = ( + path: readonly string[], + defaultComponent?: CustomizeComponent, +) => CustomizeComponent; + +// =================== Expand =================== +export type ExpandableType = false | 'row' | 'nest'; + +export interface LegacyExpandableProps { + /** @deprecated Use `expandable.expandedRowKeys` instead */ + expandedRowKeys?: Key[]; + /** @deprecated Use `expandable.defaultExpandedRowKeys` instead */ + defaultExpandedRowKeys?: Key[]; + /** @deprecated Use `expandable.expandedRowRender` instead */ + expandedRowRender?: ExpandedRowRender; + /** @deprecated Use `expandable.expandRowByClick` instead */ + expandRowByClick?: boolean; + /** @deprecated Use `expandable.expandIcon` instead */ + expandIcon?: RenderExpandIcon; + /** @deprecated Use `expandable.onExpand` instead */ + onExpand?: (expanded: boolean, record: RecordType) => void; + /** @deprecated Use `expandable.onExpandedRowsChange` instead */ + onExpandedRowsChange?: (expandedKeys: Key[]) => void; + /** @deprecated Use `expandable.defaultExpandAllRows` instead */ + defaultExpandAllRows?: boolean; + /** @deprecated Use `expandable.indentSize` instead */ + indentSize?: number; + /** @deprecated Use `expandable.expandIconColumnIndex` instead */ + expandIconColumnIndex?: number; + /** @deprecated Use `expandable.expandedRowClassName` instead */ + expandedRowClassName?: RowClassName; + /** @deprecated Use `expandable.childrenColumnName` instead */ + childrenColumnName?: string; +} + +export type ExpandedRowRender = ( + record: ValueType, + index: number, + indent: number, + expanded: boolean, +) => any; + +export interface RenderExpandIconProps { + prefixCls: string; + expanded: boolean; + record: RecordType; + expandable: boolean; + onExpand: TriggerEventHandler; +} + +export type RenderExpandIcon = (props: RenderExpandIconProps) => any; + +export interface ExpandableConfig { + expandedRowKeys?: readonly Key[]; + defaultExpandedRowKeys?: readonly Key[]; + expandedRowRender?: ExpandedRowRender; + expandRowByClick?: boolean; + expandIcon?: RenderExpandIcon; + onExpand?: (expanded: boolean, record: RecordType) => void; + onExpandedRowsChange?: (expandedKeys: readonly Key[]) => void; + defaultExpandAllRows?: boolean; + indentSize?: number; + expandIconColumnIndex?: number; + expandedRowClassName?: RowClassName; + childrenColumnName?: string; + rowExpandable?: (record: RecordType) => boolean; + columnWidth?: number | string; + fixed?: FixedType; +} + +// =================== Render =================== +export type PanelRender = (data: readonly RecordType[]) => any; + +// =================== Events =================== +export type TriggerEventHandler = (record: RecordType, event: MouseEvent) => void; + +// =================== Sticky =================== +export interface TableSticky { + offsetHeader?: number; + offsetSummary?: number; + offsetScroll?: number; + getContainer?: () => Window | HTMLElement; +} diff --git a/components/new-table/sugar/Column.tsx b/components/new-table/sugar/Column.tsx new file mode 100644 index 000000000..4bd267de8 --- /dev/null +++ b/components/new-table/sugar/Column.tsx @@ -0,0 +1,13 @@ +import { FunctionalComponent } from 'vue'; +import { ColumnType } from '../interface'; + +export type ColumnProps = ColumnType; + +/* istanbul ignore next */ +/** + * This is a syntactic sugar for `columns` prop. + * So HOC will not work on this. + */ +const Column: { (arg: T): FunctionalComponent> } = () => null; + +export default Column; diff --git a/components/new-table/sugar/ColumnGroup.tsx b/components/new-table/sugar/ColumnGroup.tsx new file mode 100644 index 000000000..0dab3868d --- /dev/null +++ b/components/new-table/sugar/ColumnGroup.tsx @@ -0,0 +1,13 @@ +import { ColumnType } from '../interface'; +import { FunctionalComponent } from 'vue'; +/* istanbul ignore next */ +/** + * This is a syntactic sugar for `columns` prop. + * So HOC will not work on this. + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export type ColumnGroupProps = ColumnType; + +const ColumnGroup: { (arg: T): FunctionalComponent> } = () => null; + +export default ColumnGroup; diff --git a/components/new-table/utils/expandUtil.tsx b/components/new-table/utils/expandUtil.tsx new file mode 100644 index 000000000..987e0fad5 --- /dev/null +++ b/components/new-table/utils/expandUtil.tsx @@ -0,0 +1,51 @@ +import { RenderExpandIconProps, Key, GetRowKey } from '../interface'; + +export function renderExpandIcon({ + prefixCls, + record, + onExpand, + expanded, + expandable, +}: RenderExpandIconProps) { + const expandClassName = `${prefixCls}-row-expand-icon`; + + if (!expandable) { + return ; + } + + const onClick = event => { + onExpand(record, event); + event.stopPropagation(); + }; + + return ( + + ); +} + +export function findAllChildrenKeys( + data: readonly RecordType[], + getRowKey: GetRowKey, + childrenColumnName: string, +): Key[] { + const keys: Key[] = []; + + function dig(list: readonly RecordType[]) { + (list || []).forEach((item, index) => { + keys.push(getRowKey(item, index)); + + dig((item as any)[childrenColumnName]); + }); + } + + dig(data); + + return keys; +} diff --git a/components/new-table/utils/fixUtil.ts b/components/new-table/utils/fixUtil.ts new file mode 100644 index 000000000..7209dd2e8 --- /dev/null +++ b/components/new-table/utils/fixUtil.ts @@ -0,0 +1,69 @@ +import type { StickyOffsets, FixedType } from '../interface'; + +export interface FixedInfo { + fixLeft: number | false; + fixRight: number | false; + lastFixLeft: boolean; + firstFixRight: boolean; + + // For Rtl Direction + lastFixRight: boolean; + firstFixLeft: boolean; + + isSticky: boolean; +} + +export function getCellFixedInfo( + colStart: number, + colEnd: number, + columns: readonly { fixed?: FixedType }[], + stickyOffsets: StickyOffsets, + direction: 'ltr' | 'rtl', +): FixedInfo { + const startColumn = columns[colStart] || {}; + const endColumn = columns[colEnd] || {}; + + let fixLeft: number; + let fixRight: number; + + if (startColumn.fixed === 'left') { + fixLeft = stickyOffsets.left[colStart]; + } else if (endColumn.fixed === 'right') { + fixRight = stickyOffsets.right[colEnd]; + } + + let lastFixLeft = false; + let firstFixRight = false; + + let lastFixRight = false; + let firstFixLeft = false; + + const nextColumn = columns[colEnd + 1]; + const prevColumn = columns[colStart - 1]; + + if (direction === 'rtl') { + if (fixLeft !== undefined) { + const prevFixLeft = prevColumn && prevColumn.fixed === 'left'; + firstFixLeft = !prevFixLeft; + } else if (fixRight !== undefined) { + const nextFixRight = nextColumn && nextColumn.fixed === 'right'; + lastFixRight = !nextFixRight; + } + } else if (fixLeft !== undefined) { + const nextFixLeft = nextColumn && nextColumn.fixed === 'left'; + lastFixLeft = !nextFixLeft; + } else if (fixRight !== undefined) { + const prevFixRight = prevColumn && prevColumn.fixed === 'right'; + firstFixRight = !prevFixRight; + } + + return { + fixLeft, + fixRight, + lastFixLeft, + firstFixRight, + lastFixRight, + firstFixLeft, + isSticky: stickyOffsets.isSticky, + }; +} diff --git a/components/new-table/utils/legacyUtil.ts b/components/new-table/utils/legacyUtil.ts new file mode 100644 index 000000000..cf9f7e2c9 --- /dev/null +++ b/components/new-table/utils/legacyUtil.ts @@ -0,0 +1,55 @@ +import { warning } from '../../vc-util/warning'; +import type { ExpandableConfig, LegacyExpandableProps } from '../interface'; + +export const INTERNAL_COL_DEFINE = 'RC_TABLE_INTERNAL_COL_DEFINE'; + +export function getExpandableProps( + props: LegacyExpandableProps & { + expandable?: ExpandableConfig; + }, +): ExpandableConfig { + const { expandable, ...legacyExpandableConfig } = props; + + if ('expandable' in props) { + return { + ...legacyExpandableConfig, + ...expandable, + }; + } + + if ( + process.env.NODE_ENV !== 'production' && + [ + 'indentSize', + 'expandedRowKeys', + 'defaultExpandedRowKeys', + 'defaultExpandAllRows', + 'expandedRowRender', + 'expandRowByClick', + 'expandIcon', + 'onExpand', + 'onExpandedRowsChange', + 'expandedRowClassName', + 'expandIconColumnIndex', + ].some(prop => prop in props) + ) { + warning(false, 'expanded related props have been moved into `expandable`.'); + } + + return legacyExpandableConfig; +} + +/** + * Returns only data- and aria- key/value pairs + * @param {object} props + */ +export function getDataAndAriaProps(props: object) { + /* eslint-disable no-param-reassign */ + return Object.keys(props).reduce((memo, key) => { + if (key.substr(0, 5) === 'data-' || key.substr(0, 5) === 'aria-') { + memo[key] = props[key]; + } + return memo; + }, {}); + /* eslint-enable */ +} diff --git a/components/new-table/utils/valueUtil.tsx b/components/new-table/utils/valueUtil.tsx new file mode 100644 index 000000000..8b4f5fbe8 --- /dev/null +++ b/components/new-table/utils/valueUtil.tsx @@ -0,0 +1,91 @@ +import { Key, DataIndex } from '../interface'; + +const INTERNAL_KEY_PREFIX = 'RC_TABLE_KEY'; + +function toArray(arr: T | readonly T[]): T[] { + if (arr === undefined || arr === null) { + return []; + } + return (Array.isArray(arr) ? arr : [arr]) as T[]; +} + +export function getPathValue( + record: ObjectType, + path: DataIndex, +): ValueType { + // Skip if path is empty + if (!path && typeof path !== 'number') { + return record as unknown as ValueType; + } + + const pathList = toArray(path); + + let current: ValueType | ObjectType = record; + + for (let i = 0; i < pathList.length; i += 1) { + if (!current) { + return null; + } + + const prop = pathList[i]; + current = current[prop]; + } + + return current as ValueType; +} + +interface GetColumnKeyColumn { + key?: Key; + dataIndex?: DataIndex; +} + +export function getColumnsKey(columns: readonly GetColumnKeyColumn[]) { + const columnKeys: Key[] = []; + const keys: Record = {}; + + columns.forEach(column => { + const { key, dataIndex } = column || {}; + + let mergedKey = key || toArray(dataIndex).join('-') || INTERNAL_KEY_PREFIX; + while (keys[mergedKey]) { + mergedKey = `${mergedKey}_next`; + } + keys[mergedKey] = true; + + columnKeys.push(mergedKey); + }); + + return columnKeys; +} + +export function mergeObject( + ...objects: Partial[] +): ReturnObject { + const merged: Partial = {}; + + /* eslint-disable no-param-reassign */ + function fillProps(obj: object, clone: object) { + if (clone) { + Object.keys(clone).forEach(key => { + const value = clone[key]; + if (value && typeof value === 'object') { + obj[key] = obj[key] || {}; + fillProps(obj[key], value); + } else { + obj[key] = value; + } + }); + } + } + /* eslint-enable */ + + objects.forEach(clone => { + fillProps(merged, clone); + }); + + return merged as ReturnObject; +} + +export function validateValue(val: T) { + return val !== null && val !== undefined; +} diff --git a/tsconfig.json b/tsconfig.json index 5dafc25f1..bd9ecb7a1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ "ant-design-vue": ["components/index.ts"], "ant-design-vue/es/*": ["components/*"] }, + "lib": ["ESNext", "DOM", "DOM.Iterable"], "strictNullChecks": false, "moduleResolution": "node", "esModuleInterop": true, @@ -14,7 +15,6 @@ "noUnusedLocals": true, "noImplicitAny": false, "target": "es6", - "lib": ["dom", "es2017"], "skipLibCheck": true, "allowJs": true, "importsNotUsedAsValues": "preserve"