diff --git a/package.json b/package.json index ddf887ff1..5b700ca74 100644 --- a/package.json +++ b/package.json @@ -164,6 +164,7 @@ }, "dependencies": { "@popperjs/core": "^2.11.8", + "@types/sortablejs": "^1.15.9", "@use-gesture/react": "^10.2.10", "ahooks": "^3.8.5", "classnames": "^2.3.1", @@ -173,6 +174,7 @@ "lodash-es": "^4.17.21", "react-transition-group": "^4.4.2", "smoothscroll-polyfill": "^0.4.4", + "sortablejs": "^1.15.7", "tdesign-icons-react": "^0.6.6", "tinycolor2": "^1.6.0", "validator": "^13.15.15" diff --git a/src/_common b/src/_common index 87824d0d2..57a70cc3a 160000 --- a/src/_common +++ b/src/_common @@ -1 +1 @@ -Subproject commit 87824d0d280408303e350f3b7ec7736ae72728c6 +Subproject commit 57a70cc3a9159ae487ea3283ac515df7dcf82d65 diff --git a/src/checkbox/Checkbox.tsx b/src/checkbox/Checkbox.tsx index 74883fcf3..5802e1b42 100644 --- a/src/checkbox/Checkbox.tsx +++ b/src/checkbox/Checkbox.tsx @@ -1,13 +1,13 @@ -import React, { useContext, useMemo, Ref, forwardRef } from 'react'; +import React, { forwardRef, Ref, useContext, useMemo } from 'react'; import classNames from 'classnames'; import { - CheckIcon, - MinusIcon, CheckCircleFilledIcon, + CheckIcon, + CheckRectangleFilledIcon, CircleIcon, MinusCircleFilledIcon, + MinusIcon, MinusRectangleFilledIcon, - CheckRectangleFilledIcon, } from 'tdesign-icons-react'; import { TdCheckboxProps } from './type'; import forwardRefWithStatics from '../_util/forwardRefWithStatics'; @@ -20,15 +20,15 @@ import { usePrefixClass } from '../hooks/useClass'; import useDefaultProps from '../hooks/useDefaultProps'; import { checkboxDefaultProps } from './defaultProps'; -export interface CheckBoxProps extends TdCheckboxProps, StyledProps {} +export interface CheckboxProps extends TdCheckboxProps, StyledProps {} export interface CheckContextValue { - inject: (props: CheckBoxProps) => CheckBoxProps; + inject: (props: CheckboxProps) => CheckboxProps; } export const CheckContext = React.createContext(null); -const Checkbox = forwardRef((_props, ref) => { +const Checkbox = forwardRef((_props, ref) => { const context = useContext(CheckContext); const props = useDefaultProps(context ? context.inject(_props) : _props, checkboxDefaultProps); const { classPrefix } = useConfig(); @@ -181,6 +181,6 @@ const Checkbox = forwardRef((_props, ref) => { Checkbox.displayName = 'Checkbox'; export default forwardRefWithStatics( - (props: CheckBoxProps, ref: Ref) => , + (props: CheckboxProps, ref: Ref) => , { Group: CheckboxGroup }, ); diff --git a/src/checkbox/__tests__/checkbox.test.tsx b/src/checkbox/__tests__/checkbox.test.tsx index 0eccc7963..33a6c7060 100644 --- a/src/checkbox/__tests__/checkbox.test.tsx +++ b/src/checkbox/__tests__/checkbox.test.tsx @@ -1,13 +1,13 @@ import React from 'react'; -import { describe, it, expect, render, vi, act } from '@test/utils'; -import { Checkbox, CheckboxGroup, CheckBoxProps } from '../index'; +import { act, describe, expect, it, render, vi } from '@test/utils'; +import { Checkbox, CheckboxGroup, CheckboxProps } from '../index'; -type Shape = Extract; +type Shape = Extract; const prefix = 't'; const name = `.${prefix}-checkbox`; -const placements = ['left', 'right'] as Array; +const placements = ['left', 'right'] as Array; const iconShapes = ['circle', 'rectangle', 'line'] as Array; describe('Checkbox', () => { diff --git a/src/checkbox/index.ts b/src/checkbox/index.ts index 58b86faaf..19168b0b8 100644 --- a/src/checkbox/index.ts +++ b/src/checkbox/index.ts @@ -3,7 +3,7 @@ import _CheckboxGroup from './CheckboxGroup'; import './style'; -export type { CheckBoxProps } from './Checkbox'; +export type { CheckboxProps } from './Checkbox'; export type { CheckboxGroupProps } from './CheckboxGroup'; export * from './type'; diff --git a/src/config-provider/config-provider.en-US.md b/src/config-provider/config-provider.en-US.md index 7a9a95e05..00bca49ee 100644 --- a/src/config-provider/config-provider.en-US.md +++ b/src/config-provider/config-provider.en-US.md @@ -6,28 +6,29 @@ name | type | default | description | required -- | -- | -- | -- | -- -globalConfig | Object | - | global config。Typescript:`GlobalConfigProvider` | N +globalConfig | Object | - | global config。Typescript: `GlobalConfigProvider` | N ### GlobalConfigProvider name | type | default | description | required -- | -- | -- | -- | -- -actionSheet | Object | - | ActionSheet global configs。Typescript:`ActionSheetConfig` | N -calendar | Object | - | Calendar global configs。Typescript:`CalendarConfig` | N -cascader | Object | - | Cascader global configs。Typescript:`CascaderConfig` | N +actionSheet | Object | - | ActionSheet global configs。Typescript: `ActionSheetConfig` | N +calendar | Object | - | Calendar global configs。Typescript: `CalendarConfig` | N +cascader | Object | - | Cascader global configs。Typescript: `CascaderConfig` | N classPrefix | String | t | \- | N -dateTimePicker | Object | - | DateTimePicker global configs。Typescript:`DateTimePickerConfig` | N -dropdownMenu | Object | - | DropdownMenu global configs。Typescript:`DropdownMenuConfig` | N -form | Object | - | Form global configs。Typescript:`FormConfig` | N -guide | Object | - | Guide global configs。Typescript:`GuideConfig` | N -list | Object | - | List global configs。Typescript:`ListConfig` | N -picker | Object | - | Picker global configs。Typescript:`PickerConfig` | N -pullDownRefresh | Object | - | PullDownRefresh global configs。Typescript:`PullDownRefreshConfig` | N -qrcode | Object | - | QRCode global configs。Typescript:`QRCodeConfig` | N -rate | Object | - | Rate global configs。Typescript:`RateConfig` | N -tabBar | Object | - | TabBar global configs。Typescript:`TabBarConfig` | N -table | Object | - | Table global configs。Typescript:`TableConfig` | N -upload | Object | - | Upload global configs。Typescript:`UploadConfig` | N +dateTimePicker | Object | - | DateTimePicker global configs。Typescript: `DateTimePickerConfig` | N +dropdownMenu | Object | - | DropdownMenu global configs。Typescript: `DropdownMenuConfig` | N +form | Object | - | Form global configs。Typescript: `FormConfig` | N +guide | Object | - | Guide global configs。Typescript: `GuideConfig` | N +list | Object | - | List global configs。Typescript: `ListConfig` | N +picker | Object | - | Picker global configs。Typescript: `PickerConfig` | N +pullDownRefresh | Object | - | PullDownRefresh global configs。Typescript: `PullDownRefreshConfig` | N +qrcode | Object | - | QRCode global configs。Typescript: `QRCodeConfig` | N +rate | Object | - | Rate global configs。Typescript: `RateConfig` | N +tabBar | Object | - | TabBar global configs。Typescript: `TabBarConfig` | N +table | Object | - | Table global configs。Typescript: `TableConfig` | N +typography | Object | - | Typography global configs。Typescript: `TypographyConfig` | N +upload | Object | - | Upload global configs。Typescript: `UploadConfig` | N ### ActionSheetConfig @@ -41,9 +42,9 @@ name | type | default | description | required -- | -- | -- | -- | -- confirm | String | - | confirm text | N monthTitle | String | - | \- | N -months | Array | - | Typescript:`string[]` | N +months | Array | - | Typescript: `string[]` | N title | String | - | \- | N -weekdays | Array | - | Typescript:`string[]` | N +weekdays | Array | - | Typescript: `string[]` | N ### CascaderConfig @@ -79,7 +80,7 @@ reset | String | - | reset text | N name | type | default | description | required -- | -- | -- | -- | -- colonText | String | - | colon on the right of label ":" | N -errorMessage | Object | - | Typescript:`FormErrorMessage`,[Form API Documents](./form?tab=api)。[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/tree/develop/src/config-provider/type.ts) | N +errorMessage | Object | - | Typescript: `FormErrorMessage`,[Form API Documents](./form?tab=api)。[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/tree/develop/src/config-provider/type.ts) | N requiredMark | Boolean | true | \- | N requiredMarkPosition | String | left | Display position of required symbols。options: left/right | N @@ -98,7 +99,7 @@ name | type | default | description | required -- | -- | -- | -- | -- errorText | String | - | loading text, default value is "Error" | N loadingText | String | - | loading text, default value is "loading" | N -replaceImageSrc | Function | - | replace all `src` attribute of images。Typescript:`(params: ImageProps) => string`,[Image API Documents](./image?tab=api)。[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/tree/develop/src/config-provider/type.ts) | N +replaceImageSrc | Function | - | replace all `src` attribute of images。Typescript: `(params: ImageProps) => string`,[Image API Documents](./image?tab=api)。[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/tree/develop/src/config-provider/type.ts) | N ### InputConfig @@ -127,7 +128,7 @@ confirm | String | - | confirm text | N name | type | default | description | required -- | -- | -- | -- | -- -loadingTexts | Array | - | Typescript:`string[]` | N +loadingTexts | Array | - | Typescript: `string[]` | N ### QRCodeConfig @@ -157,13 +158,28 @@ newsAriaLabel | String | - | \- | N name | type | default | description | required -- | -- | -- | -- | -- -empty | TNode | - | Typescript:`string \| TNode`。[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/blob/develop/src/common.ts) | N +clearFilterResultButtonText | String | - | \- | N +confirmText | String | - | \- | N +empty | TNode | - | Typescript: `string \| TNode`。[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/blob/develop/src/common.ts) | N +filterIcon | TElement | undefined | Typescript: `TNode`。[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/blob/develop/src/common.ts) | N +hideSortTips | Boolean | false | hide sort tips | N +pullUpLoadingText | String | - | \- | N +resetText | String | - | \- | N +searchResultText | String | - | \- | N + +### TypographyConfig + +name | type | default | description | required +-- | -- | -- | -- | -- +collapseText | String | - | collapse text | N +copiedText | String | - | copied text | N +expandText | String | - | expand text | N ### UploadConfig name | type | default | description | required -- | -- | -- | -- | -- -progress | Object | - | Typescript:`UploadConfigProgress` | N +progress | Object | - | Typescript: `UploadConfigProgress` | N ### UploadConfigProgress diff --git a/src/config-provider/config-provider.md b/src/config-provider/config-provider.md index b4aec4017..ae89bda77 100644 --- a/src/config-provider/config-provider.md +++ b/src/config-provider/config-provider.md @@ -27,6 +27,7 @@ qrcode | Object | - | 二维码全局配置。TS 类型:`QRCodeConfig` | N rate | Object | - | 评分全局配置。TS 类型:`RateConfig` | N tabBar | Object | - | 标签栏全局配置。TS 类型:`TabBarConfig` | N table | Object | - | 表格组件全局配置。TS 类型:`TableConfig` | N +typography | Object | - | 排版组件全局配置。TS 类型:`TypographyConfig` | N upload | Object | - | 上传组件全局配置。TS 类型:`UploadConfig` | N ### ActionSheetConfig @@ -157,7 +158,22 @@ newsAriaLabel | String | - | 语言配置,“有新的消息”描述文本 | 名称 | 类型 | 默认值 | 描述 | 必传 -- | -- | -- | -- | -- +clearFilterResultButtonText | String | - | 语言配置,过滤功能中,“清空筛选” 描述文本 | N +confirmText | String | - | 语言配置,“确认” 描述文本 | N empty | TNode | - | 语言配置,“暂无数据” 描述文本。TS 类型:`string \| TNode`。[通用类型定义](https://github.com/Tencent/tdesign-mobile-react/blob/develop/src/common.ts) | N +filterIcon | TElement | undefined | 过滤图标,如果没有配置,会使用组件内置的默认图标。TS 类型:`TNode`。[通用类型定义](https://github.com/Tencent/tdesign-mobile-react/blob/develop/src/common.ts) | N +hideSortTips | Boolean | false | 隐藏排序文本提示 | N +pullUpLoadingText | String | - | 语言配置,“加载中...” 描述文本 | N +resetText | String | - | 语言配置,“重置” 描述文本 | N +searchResultText | String | - | 语言配置,过滤功能中,过滤条件和结果描述文本,示例:'搜索“{result}”,找到 {count} 条结果' | N + +### TypographyConfig + +名称 | 类型 | 默认值 | 描述 | 必传 +-- | -- | -- | -- | -- +collapseText | String | - | 语言配置,“收起”描述文本 | N +copiedText | String | - | 语言配置,“复制成功”描述文本 | N +expandText | String | - | 语言配置,“展开”描述文本 | N ### UploadConfig diff --git a/src/config-provider/type.ts b/src/config-provider/type.ts index 73972e16a..39c60842e 100644 --- a/src/config-provider/type.ts +++ b/src/config-provider/type.ts @@ -6,7 +6,7 @@ import { FormErrorMessage } from '../form'; import { ImageProps } from '../image'; -import { TNode } from '../common'; +import type { TNode, TElement } from '../common'; export interface TdConfigProviderProps { /** @@ -77,6 +77,10 @@ export interface GlobalConfigProvider { * 表格组件全局配置 */ table?: TableConfig; + /** + * 排版组件全局配置 + */ + typography?: TypographyConfig; /** * 上传组件全局配置 */ @@ -369,10 +373,62 @@ export interface TabBarConfig { } export interface TableConfig { + /** + * 语言配置,过滤功能中,“清空筛选” 描述文本 + * @default '' + */ + clearFilterResultButtonText?: string; + /** + * 语言配置,“确认” 描述文本 + * @default '' + */ + confirmText?: string; /** * 语言配置,“暂无数据” 描述文本 */ empty?: TNode; + /** + * 过滤图标,如果没有配置,会使用组件内置的默认图标 + */ + filterIcon?: TElement; + /** + * 隐藏排序文本提示 + * @default false + */ + hideSortTips?: boolean; + /** + * 语言配置,“加载中...” 描述文本 + * @default '' + */ + pullUpLoadingText?: string; + /** + * 语言配置,“重置” 描述文本 + * @default '' + */ + resetText?: string; + /** + * 语言配置,过滤功能中,过滤条件和结果描述文本,示例:'搜索“{result}”,找到 {count} 条结果' + * @default '' + */ + searchResultText?: string; +} + +export interface TypographyConfig { + /** + * 语言配置,“收起”描述文本 + * @default '' + */ + collapseText?: string; + /** + * 语言配置,“复制成功”描述文本 + * @default '' + */ + copiedText?: string; + /** + * 语言配置,“展开”描述文本 + * @default '' + */ + expandText?: string; } export interface UploadConfig { diff --git a/src/table/BaseTable.tsx b/src/table/BaseTable.tsx index c1e34a0ea..a6aa27470 100644 --- a/src/table/BaseTable.tsx +++ b/src/table/BaseTable.tsx @@ -1,4 +1,13 @@ -import React, { forwardRef, useRef, useImperativeHandle } from 'react'; +import React, { + CSSProperties, + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react'; import { get, isFunction } from 'lodash-es'; import cx from 'classnames'; @@ -6,45 +15,55 @@ import parseTNode from '../_util/parseTNode'; import { ClassName } from '../common'; import useClassName from './hooks/useClassName'; import useStyle, { formatCSSUnit } from './hooks/useStyle'; -import useFixed, { getRowFixedStyles, getColumnFixedStyles } from './hooks/useFixed'; +import useFixed, { getColumnFixedStyles, getRowFixedStyles } from './hooks/useFixed'; import useDefaultProps from '../hooks/useDefaultProps'; import defaultConfig from '../_common/js/global-config/mobile/locale/zh_CN'; import Loading from '../loading'; import { baseTableDefaultProps } from './defaultProps'; -import { BaseTableRef, BaseTableProps } from './interface'; +import usePullRefresh from './hooks/usePullRefresh'; +import { BaseTableProps, BaseTableRef } from './interface'; import { formatClassNames, formatRowAttributes, formatRowClassNames, handleCellSpan, - isLastRowInSpan, isFirstColumnInSpan, + isLastRowInSpan, } from './utils'; import useRowspanAndColspan from './hooks/useRowspanAndColspan'; +import usePagination from './hooks/usePagination'; +import useTableHeader from './hooks/useTableHeader'; +import { BaseTableCellParams, BaseTableCol, TableRowData, TdBaseTableProps } from './type'; -import type { TdBaseTableProps, BaseTableCol, TableRowData, BaseTableCellParams } from './type'; - -const BaseTable = forwardRef((props, ref) => { +const BaseTable = forwardRef((originProps, ref) => { + const props = useDefaultProps(originProps, baseTableDefaultProps); const { + bordered, + className, + columns, + cellEmptyContent, data, empty, height, loading, loadingProps, - columns, - bordered, + loadingMode, maxHeight, - tableLayout, + pagination: originPagination, + rowspanAndColspan, + rowKey, showHeader, - cellEmptyContent, - className, style, + tableLayout, onRowClick, onCellClick, onScroll, - rowspanAndColspan, - rowKey, - } = useDefaultProps(props, baseTableDefaultProps); + onLeafColumnsChange, + } = props; + + // 保留用户未传 pagination 时为 undefined 的语义(不分页) + // 默认值填充由 TablePagination 组件内部处理 + const pagination = originPagination; const { skipSpansMap } = useRowspanAndColspan(data, columns, rowKey, rowspanAndColspan); @@ -68,6 +87,7 @@ const BaseTable = forwardRef((props, ref) => { showColumnShadow, refreshTable, updateColumnFixedShadow, + setData, } = useFixed(props); const { tableClasses, tableContentStyles, tableElementStyles } = useStyle(props, { @@ -76,12 +96,16 @@ const BaseTable = forwardRef((props, ref) => { showColumnShadow, }); + const { spansAndLeafNodes } = useTableHeader({ columns }); + const tableRef = useRef(null); + const tableHeaderIsFixed = Boolean(maxHeight || height); + const tableElmClasses = tableLayoutClasses[tableLayout || 'fixed']; const theadClasses = cx(tableHeaderClasses.header, { - [tableHeaderClasses.fixed]: Boolean(maxHeight || height), + [tableHeaderClasses.fixed]: tableHeaderIsFixed, [tableBaseClass.bordered]: bordered, }); @@ -89,10 +113,53 @@ const BaseTable = forwardRef((props, ref) => { const defaultColWidth = tableLayout === 'fixed' ? '80px' : undefined; + const [lastLeafColumns, setLastLeafColumns] = useState(props.columns || []); + const tableElmRef = useRef(null); const theadRef = useRef(null); + const paginationRef = useRef(null); + + const isPullRefreshMode = loadingMode === 'pull-refresh'; + + const { + dataSource: paginationDataSource, + isPaginateData: isPaginationData, + renderPagination, + } = usePagination({ ...props, pagination }, tableContentRef); + + const { + dataSource: pullRefreshDataSource, + isPaginateData: isPullRefreshData, + pullOffset, + isPulling, + renderPullRefreshLoading, + } = usePullRefresh({ ...props, pagination }, tableContentRef); + + const getDisplayData = useCallback(() => { + if (isPullRefreshMode) { + return isPullRefreshData ? pullRefreshDataSource : data; + } + return isPaginationData ? paginationDataSource : data; + }, [data, isPaginationData, isPullRefreshData, isPullRefreshMode, paginationDataSource, pullRefreshDataSource]); + + const newData = useMemo(() => getDisplayData(), [getDisplayData]); + + useEffect(() => { + setData(newData || props.data); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.data, newData]); + + useEffect(() => { + if (lastLeafColumns.map((t) => t.colKey).join() !== spansAndLeafNodes.leafColumns.map((t) => t.colKey).join()) { + onLeafColumnsChange?.(spansAndLeafNodes.leafColumns); + // eslint-disable-next-line react-hooks/exhaustive-deps + setLastLeafColumns(spansAndLeafNodes.leafColumns); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [spansAndLeafNodes.leafColumns]); + const colStyle = (colItem: BaseTableCol) => ({ width: `${formatCSSUnit(colItem.width || defaultColWidth)}`, minWidth: `${ @@ -127,6 +194,36 @@ const BaseTable = forwardRef((props, ref) => { return cx([className, ...extra]); }; + const handleRowClick = (row: TableRowData, rowIndex: number, e: React.MouseEvent) => { + onRowClick?.({ row, index: rowIndex, e }); + }; + + const handleCellClick = ( + row: TableRowData, + col: any, + rowIndex: number, + colIndex: number, + e: React.MouseEvent, + ) => { + if (col.stopPropagation) { + e.stopPropagation(); + } + onCellClick?.({ row, col, rowIndex, colIndex, e }); + }; + + const onInnerVirtualScroll = (e: React.UIEvent) => { + const target = e.target as HTMLElement; + updateColumnFixedShadow(target); + onScroll?.({ e }); + }; + + useImperativeHandle(ref, () => ({ + tableElement: tableRef.current, + tableHtmlElement: tableElmRef.current, + tableContentElement: tableContentRef.current, + refreshTable, + })); + const renderCell = ( params: BaseTableCellParams, cellEmptyContent?: TdBaseTableProps['cellEmptyContent'], @@ -163,27 +260,45 @@ const BaseTable = forwardRef((props, ref) => { return thItem?.title; }; - const handleRowClick = (row: TableRowData, rowIndex: number, e: React.MouseEvent) => { - onRowClick?.({ row, index: rowIndex, e }); - }; - - const handleCellClick = ( - row: TableRowData, - col: any, - rowIndex: number, - colIndex: number, - e: React.MouseEvent, - ) => { - if (col.stopPropagation) { - e.stopPropagation(); + const getTableHeaderStyle = () => { + if (!isPullRefreshData) { + return; } - onCellClick?.({ row, col, rowIndex, colIndex, e }); + if (tableHeaderIsFixed) { + return { zIndex: 2 }; + } + return { position: 'relative', zIndex: 2 }; }; + const renderTableHeader = () => + showHeader && ( + + + {columns?.map((thItem, idx) => { + const thStyles = getColumnFixedStyles(thItem, idx, rowAndColFixedPosition, tableColFixedClasses); + const customClasses = formatClassNames(thItem.className, { + col: thItem, + colIndex: idx, + row: {}, + rowIndex: -1, + type: 'th', + }); + return ( + +
+ {renderTitle(thItem, idx)} +
+ + ); + })} + + + ); + const renderTableBody = () => { const renderContentEmpty = empty || defaultConfig?.table?.empty; - if (!data?.length && renderContentEmpty) { + if (!newData?.length && renderContentEmpty) { return ( @@ -192,12 +307,12 @@ const BaseTable = forwardRef((props, ref) => { ); } - if (data?.length) { - return data?.map((trItem, trIdx) => { + if (newData?.length) { + return newData?.map((trItem, trIdx) => { const trStyles = getRowFixedStyles( get(trItem, props.rowKey || 'id'), trIdx, - props.data?.length || 0, + newData?.length || 0, props.fixedRows, rowAndColFixedPosition, tableRowFixedClasses, @@ -212,10 +327,11 @@ const BaseTable = forwardRef((props, ref) => { const trAttributes = formatRowAttributes(props.rowAttributes, { row: trItem, rowIndex: trIdx, type: 'body' }) || {}; + const trKey = get(trItem, props.rowKey) ?? trIdx; return ( { @@ -241,7 +357,7 @@ const BaseTable = forwardRef((props, ref) => { const cellClasses = cx(tdClassName(tdItem, [tdStyles.classes, customClasses]), { // 合并单元格场景:最后一行移除底部边框 - [tableBaseClass.tdLastRow]: isLastRowInSpan(trIdx, rowspan, props.data?.length), + [tableBaseClass.tdLastRow]: isLastRowInSpan(trIdx, rowspan, newData?.length), // 合并单元格场景:第一列移除左边框 [tableBaseClass.tdFirstCol]: rowspanAndColspan && isFirstColumnInSpan(tdIdx), }); @@ -267,19 +383,40 @@ const BaseTable = forwardRef((props, ref) => { } }; - const onInnerVirtualScroll = (e: React.UIEvent) => { - const target = e.target as HTMLElement; - updateColumnFixedShadow(target); + const renderLoading = () => { + // pull-refresh 模式下,上拉加载 loading 由 usePullRefresh hook 内部渲染 + if (isPullRefreshMode) { + return renderPullRefreshLoading(); + } - onScroll?.({ e }); + // 非 pull-refresh 模式,使用外部 loading 控制 + if (!loading) return null; + + // 默认全屏 loading + return ( +
+ +
+ ); }; - useImperativeHandle(ref, () => ({ - tableElement: tableRef.current, - tableHtmlElement: tableElmRef.current, - tableContentElement: tableContentRef.current, - refreshTable, - })); + // 外部传入 loading={true} 时的全屏 loading(pull-refresh 模式下也支持外部控制全屏 loading) + const renderFullLoading = () => { + if (!loading || !isPullRefreshMode) return null; + return ( +
+ +
+ ); + }; + + const renderPaginationNode = () => + pagination && + loadingMode === 'pagination' && ( +
+ {renderPagination()} +
+ ); return (
@@ -295,40 +432,27 @@ const BaseTable = forwardRef((props, ref) => { ))} - {showHeader && ( - - - {columns?.map((thItem, idx) => { - const thStyles = getColumnFixedStyles(thItem, idx, rowAndColFixedPosition, tableColFixedClasses); - const customClasses = formatClassNames(thItem.className, { - col: thItem, - colIndex: idx, - row: {}, - rowIndex: -1, - type: 'th', - }); - return ( - -
- {renderTitle(thItem, idx)} -
- - ); - })} - - - )} - {renderTableBody()} + {renderTableHeader()} + 0 ? `translateY(-${pullOffset}px)` : 'translateY(0)', + transition: isPulling ? 'none' : 'transform 0.3s ease', + } + : undefined + } + > + {renderTableBody()} + - {loading && ( -
- -
- )} + {renderLoading()} + {renderFullLoading()} + {renderPaginationNode()}
); diff --git a/src/table/Ellipsis.tsx b/src/table/Ellipsis.tsx new file mode 100644 index 000000000..2fa46617b --- /dev/null +++ b/src/table/Ellipsis.tsx @@ -0,0 +1,29 @@ +import React, { useRef } from 'react'; +import classNames from 'classnames'; +import type { TNode } from '../common'; + +export interface EllipsisProps { + content?: string | TNode; + children?: string | TNode; + popupContent?: string | number | TNode; + attach?: () => HTMLElement; + zIndex?: number; + overlayClassName?: string; + classPrefix?: string; +} + +/** 超出省略显示 */ +export default function Ellipsis(props: EllipsisProps) { + const { classPrefix } = props; + const root = useRef(null); + const ellipsisClasses = classNames([`${classPrefix}-table__ellipsis`, `${classPrefix}-text-ellipsis`]); + + const cellNode = props.content || props.children; + return ( +
+ {cellNode as React.ReactNode} +
+ ); +} + +Ellipsis.displayName = 'Ellipsis'; diff --git a/src/table/FilterController.tsx b/src/table/FilterController.tsx new file mode 100644 index 000000000..e327b87af --- /dev/null +++ b/src/table/FilterController.tsx @@ -0,0 +1,156 @@ +import React, { useRef, useState } from 'react'; +import classNames from 'classnames'; +import { isEmpty } from 'lodash-es'; +import { FilterIcon } from 'tdesign-icons-react'; +import log from '../_common/js/log'; + +import { parseContentTNode } from '../_util/parseTNode'; +import TButton from '../button'; +import Checkbox from '../checkbox'; +import Input from '../input'; +import { useLocaleReceiver } from '../locale/LocalReceiver'; +import type { PopupProps } from '../popup'; +import Popup from '../popup'; +import { RadioGroup } from '../radio'; +import type { FilterValue, PrimaryTableCol, TableRowData, TdPrimaryTableProps } from './type'; + +const CheckboxGroup = Checkbox.Group; + +export interface TableFilterControllerProps { + filterIcon: TdPrimaryTableProps['filterIcon']; + tFilterValue: FilterValue; + innerFilterValue: FilterValue; + tableFilterClasses: { + filterable: string; + popup: string; + icon: string; + popupContent: string; + result: string; + inner: string; + bottomButtons: string; + contentInner: string; + iconWrap: string; + }; + isFocusClass: string; + column: PrimaryTableCol; + colIndex: number; + primaryTableElement: HTMLElement; + popupProps: PopupProps; + onVisibleChange: (val: boolean) => void; + onReset: (column: PrimaryTableCol) => void; + onConfirm: (column: PrimaryTableCol) => void; + onInnerFilterChange: (val: any, column: PrimaryTableCol) => void; +} + +export default function TableFilterController(props: TableFilterControllerProps) { + const { tFilterValue, innerFilterValue, tableFilterClasses, isFocusClass, column } = props; + const triggerElementRef = useRef(null); + const [locale] = useLocaleReceiver('table'); + const [filterPopupVisible, setFilterPopupVisible] = useState(false); + const filterValue = tFilterValue?.[column.colKey]; + const isObjectTrue = typeof filterValue === 'object' && !isEmpty(filterValue); + const isValueExist = ![null, undefined, ''].includes(filterValue) && typeof filterValue !== 'object'; + const defaultFilterIcon = locale.filterIcon || ; + + const onFilterPopupVisibleChange = (visible: boolean) => { + setFilterPopupVisible(visible); + props.onVisibleChange?.(visible); + }; + + const getFilterContent = (column: PrimaryTableCol) => { + const types = ['single', 'multiple', 'input']; + if (column.type && !types.includes(column.filter.type)) { + log.error('Table', `TDesign Table Error: column.filter.type must be the following: ${JSON.stringify(types)}`); + return; + } + const Component = { + single: RadioGroup, + multiple: CheckboxGroup, + input: Input, + }[column.filter.type]; + if (!Component && !column?.filter?.component) return; + const filterComponentProps: { [key: string]: any } = { + options: ['single', 'multiple'].includes(column.filter.type) ? column.filter?.list : undefined, + ...(column.filter?.props || {}), + onChange: (val: any) => { + props.onInnerFilterChange?.(val, column); + }, + }; + if (column.colKey && innerFilterValue && column.colKey in innerFilterValue) { + filterComponentProps.value = innerFilterValue[column.colKey]; + } + // 允许自定义触发确认搜索的事件 + if (column.filter?.confirmEvents) { + column.filter.confirmEvents.forEach((event) => { + filterComponentProps[event] = () => { + setFilterPopupVisible(false); + props.onConfirm?.(column); + }; + }); + } + const FilterComponent = column?.filter?.component || Component; + const filter = column.filter || {}; + return ( +
+ +
+ ); + }; + + const getBottomButtons = (column: PrimaryTableCol) => { + if (!column.filter.showConfirmAndReset) return; + return ( +
+ { + setFilterPopupVisible(false); + props.onReset?.(column); + }} + > + {locale.resetText} + + { + setFilterPopupVisible(false); + props.onConfirm?.(column); + }} + > + {locale.confirmText} + +
+ ); + }; + + if (!column.filter || (column.filter && !Object.keys(column.filter).length)) { + return null; + } + + return ( +
+
setFilterPopupVisible(true)}> + {parseContentTNode(props.filterIcon, { col: column, colIndex: props.colIndex }) || defaultFilterIcon} +
+ onFilterPopupVisibleChange(val)} + {...props.popupProps} + > +
+ {getFilterContent(column)} + {getBottomButtons(column)} +
+
+
+ ); +} diff --git a/src/table/PrimaryTable.tsx b/src/table/PrimaryTable.tsx new file mode 100644 index 000000000..64fc84096 --- /dev/null +++ b/src/table/PrimaryTable.tsx @@ -0,0 +1,233 @@ +import React, { forwardRef, useCallback, useImperativeHandle, useMemo, useRef } from 'react'; +import classNames from 'classnames'; +import { get } from 'lodash-es'; +import { PrimaryTableProps, PrimaryTableRef } from './interface'; +import { + PageInfo, + PaginationProps, + PrimaryTableCol, + TableRowData, + TdBaseTableProps, + TdPrimaryTableProps, +} from './type'; +import useDefaultProps from '../hooks/useDefaultProps'; +import useControlled from '../hooks/useControlled'; +import useClassName from './hooks/useClassName'; +import useRowSelect from './hooks/useRowSelect'; +import useSorter from './hooks/useSorter'; +import useFilter from './hooks/useFilter'; +import useDragSort from './hooks/useDragSort'; +import useRowExpand from './hooks/useRowExpand'; +import useTableHeader, { renderTitle } from './hooks/useTableHeader'; +import { primaryTableDefaultProps } from './defaultProps'; +import BaseTable from './BaseTable'; + +const PrimaryTable = forwardRef((props, ref) => { + const { + className, + columns, + style, + pagination: originPagination, + } = useDefaultProps(props, primaryTableDefaultProps); + // 保留用户未传 pagination 时为 undefined 的语义(不分页) + // 默认值填充由 TablePagination 组件内部处理 + const pagination = originPagination; + const primaryTableRef = useRef(null); + const innerPagination = useRef(pagination); + const { classPrefix, tableDraggableClasses, tableBaseClass, tableSelectedClasses, tableSortClasses } = useClassName(); + const [tDisplayColumns] = useControlled(props, 'displayColumns', props.onDisplayColumnsChange); + // 行选中功能 + const { + selectedRowClassNames, + setCurrentPaginateData, + formatToRowSelectColumn, + setTSelectedRowKeys, + onInnerSelectRowClick, + } = useRowSelect(props, tableSelectedClasses); + // 排序功能 + const { renderSortIcon } = useSorter(props); + // 拖拽排序功能 + const { isRowHandlerDraggable, isRowDraggable, isColDraggable, updateLastRowList, setDragSortColumns } = useDragSort( + props, + { + primaryTableRef, + innerPagination, + }, + ); + // 过滤功能 + const { isTableOverflowHidden, renderFilterIcon } = useFilter(props, primaryTableRef); + // 展开/收起行功能 + const { + showExpandedRow, + showExpandIconColumn, + getExpandColumn, + renderExpandedRow, + onInnerExpandRowClick, + getExpandedRowClass, + } = useRowExpand(props); + const { renderTitleWidthIcon } = useTableHeader({ columns: props.columns }); + + // 如果想给 TR 添加类名,请在这里补充,不要透传更多额外 Props 到 BaseTable + const tRowClassNames = useMemo(() => { + const tClassNames = [props.rowClassName, selectedRowClassNames, getExpandedRowClass]; + return tClassNames.filter((v) => v); + }, [getExpandedRowClass, props.rowClassName, selectedRowClassNames]); + + // 如果想给 TR 添加属性,请在这里补充,不要透传更多额外 Props 到 BaseTable + const tRowAttributes = useMemo(() => { + const tAttributes = [props.rowAttributes]; + if (isRowHandlerDraggable || isRowDraggable) { + tAttributes.push(({ row }) => ({ 'data-id': get(row, props.rowKey || 'id') })); + } + return tAttributes.filter((v) => v); + }, [isRowDraggable, isRowHandlerDraggable, props.rowAttributes, props.rowKey]); + + const primaryTableClasses = useMemo( + () => ({ + [tableDraggableClasses.colDraggable]: isColDraggable, + [tableDraggableClasses.rowHandlerDraggable]: isRowHandlerDraggable, + [tableDraggableClasses.rowDraggable]: isRowDraggable, + [tableBaseClass.overflowVisible]: isTableOverflowHidden === false, + }), + [ + isColDraggable, + isRowDraggable, + isRowHandlerDraggable, + isTableOverflowHidden, + tableBaseClass, + tableDraggableClasses, + ], + ); + + useImperativeHandle(ref, () => ({ + ...primaryTableRef.current, + })); + + const getColumns = useCallback( + (columns: PrimaryTableCol[]) => { + const arr: PrimaryTableCol[] = []; + for (let i = 0, len = columns.length; i < len; i++) { + let item = { ...columns[i] }; + // 自定义列显示控制 + const isDisplayColumn = item.children?.length || tDisplayColumns?.includes(item.colKey); + if (!isDisplayColumn && tDisplayColumns) continue; + item = formatToRowSelectColumn(item); + const { sort, showSortColumnBgColor } = props; + if (item.sorter && showSortColumnBgColor) { + const sorts = sort instanceof Array ? sort : [sort]; + const sortedColumn = sorts.find( + (sort) => sort && sort.sortBy === item.colKey && sort.descending !== undefined, + ); + if (sortedColumn) { + item.className = + item.className instanceof Array + ? item.className.concat(tableSortClasses.sortColumn) + : [item.className, tableSortClasses.sortColumn]; + } + } + // 添加排序图标和过滤图标 + if (item.sorter || item.filter) { + const titleContent = renderTitle(item, i); + const { ellipsisTitle } = item; + item.title = (p) => { + const sortIcon = item.sorter ? renderSortIcon(p) : null; + const filterIcon = item.filter ? renderFilterIcon(p) : null; + const attach = primaryTableRef.current?.tableContentRef; + return renderTitleWidthIcon( + [titleContent, sortIcon, filterIcon], + p.col, + p.colIndex, + ellipsisTitle, + attach, + { + classPrefix, + ellipsisOverlayClassName: '', + }, + ); + }; + item.ellipsisTitle = false; + } + if (item.children?.length) { + item.children = getColumns(item.children); + } + // 多级表头和自定义列配置特殊逻辑:要么子节点不存在,要么子节点长度大于 1,方便做自定义列配置 + if (!item.children || item.children?.length) { + arr.push(item); + } + } + return arr; + }, + [ + tDisplayColumns, + formatToRowSelectColumn, + props, + tableSortClasses.sortColumn, + renderSortIcon, + renderFilterIcon, + renderTitleWidthIcon, + classPrefix, + ], + ); + + const tColumns = useMemo(() => { + const cols = getColumns(columns); + if (showExpandIconColumn) { + cols.unshift(getExpandColumn()); + } + return cols; + }, [columns, getColumns, getExpandColumn, showExpandIconColumn]); + + const onInnerPageChange = (pageInfo: PageInfo, newData: Array) => { + innerPagination.current = { ...innerPagination, ...pageInfo }; + setCurrentPaginateData(newData); + props.onPageChange?.(pageInfo, newData); + const changeParams: Parameters = [ + { pagination: pageInfo }, + { trigger: 'pagination', currentData: newData }, + ]; + props.onChange?.(...changeParams); + // 是否在分页时保留选中结果,如果不保留则需清空 + if (!props.reserveSelectedRowOnPaginate) { + setTSelectedRowKeys([], { + selectedRowData: [], + type: 'uncheck', + currentRowKey: 'CLEAR_ON_PAGINATE', + }); + } + }; + + const onInnerRowClick: TdPrimaryTableProps['onRowClick'] = (params) => { + if (props.expandOnRowClick) { + onInnerExpandRowClick(params); + } + if (props.selectOnRowClick) { + onInnerSelectRowClick(params); + } + }; + + const onPrimaryTableScroll: TdBaseTableProps['onScroll'] = (params) => { + props.onScroll?.(params); + updateLastRowList(); + }; + + const baseTableProps = { + ...props, + className: classNames(primaryTableClasses, className), + columns: tColumns, + rowClassName: tRowClassNames, + rowAttributes: tRowAttributes, + thDraggable: ['col', 'row-handler-col'].includes(props.dragSort), + renderExpandedRow: showExpandedRow ? renderExpandedRow : undefined, + onPageChange: onInnerPageChange, + onScroll: onPrimaryTableScroll, + onLeafColumnsChange: setDragSortColumns, + }; + + if (props.expandOnRowClick || props.selectOnRowClick) { + baseTableProps.onRowClick = onInnerRowClick; + } + + return ; +}); + +export default PrimaryTable; diff --git a/src/table/SorterButton.tsx b/src/table/SorterButton.tsx new file mode 100644 index 000000000..9c2022c69 --- /dev/null +++ b/src/table/SorterButton.tsx @@ -0,0 +1,51 @@ +import React, { MouseEvent, ReactNode, useMemo } from 'react'; +import classNames from 'classnames'; +import { ChevronDownIcon } from 'tdesign-icons-react'; +import useClassName from './hooks/useClassName'; +import type { TNode } from '../common'; +import type { SortType } from './type'; + +type SortTypeEnums = Array<'desc' | 'asc'>; + +export interface SorterButtonProps { + sortType: SortType; + sortOrder: string; + sortIcon: TNode; + hideSortTips?: boolean; + onSortIconClick: (e: MouseEvent, p: { descending: boolean }) => void; +} + +export default function SorterButton(props: SorterButtonProps) { + const { sortType = 'all' } = props; + const { tableSortClasses, negativeRotate180 } = useClassName(); + const allowSortTypes: SortTypeEnums = useMemo(() => (sortType === 'all' ? ['asc', 'desc'] : [sortType]), [sortType]); + const classes = useMemo( + () => [tableSortClasses.trigger, { [tableSortClasses.doubleIcon]: allowSortTypes.length > 1 }], + [allowSortTypes, tableSortClasses], + ); + + function getSortIcon(direction: string) { + const defaultIcon = ; + const icon = props.sortIcon || defaultIcon; + const activeClass = direction === props.sortOrder ? tableSortClasses.iconActive : tableSortClasses.iconDefault; + const sortClassName = [ + activeClass, + tableSortClasses.sortIcon, + tableSortClasses.iconDirection[direction], + { [negativeRotate180]: direction === 'asc' }, + ]; + return ( + ) => props?.onSortIconClick(e, { descending: direction === 'desc' })} + > + {icon as ReactNode} + + ); + } + + const sortButton = allowSortTypes.map((direction: string) => getSortIcon(direction)); + + return
{sortButton}
; +} diff --git a/src/table/TablePagination.tsx b/src/table/TablePagination.tsx new file mode 100644 index 000000000..3c0c1be12 --- /dev/null +++ b/src/table/TablePagination.tsx @@ -0,0 +1,69 @@ +import React, { useMemo } from 'react'; +import { PaginationProps } from './type'; +import Button from '../button'; +import useClassName from './hooks/useClassName'; +import useControlled from '../hooks/useControlled'; +import useDefaultProps from '../hooks/useDefaultProps'; +import { tablePaginationDefaultProps } from './tablePaginationDefaultProps'; + +const PAGE_COUNT_MIN = 1; +const TablePagination: React.FC = (originalProps) => { + const props = useDefaultProps(originalProps, tablePaginationDefaultProps); + const { total, onChange, onCurrentChange } = props; + const { tablePaginationClasses } = useClassName(); + const [current, setCurrent] = useControlled(props, 'current', onCurrentChange); + const [pageSize] = useControlled(props, 'pageSize', onCurrentChange); + + const pageCount = useMemo(() => { + const calCount = Math.ceil(total / pageSize); + return calCount > 0 ? calCount : PAGE_COUNT_MIN; + }, [pageSize, total]); + + const handlePageChange = (nextCurrent: number) => { + let resNextCurrent = nextCurrent; + // 边界处理 + if (nextCurrent < PAGE_COUNT_MIN) { + resNextCurrent = PAGE_COUNT_MIN; + } + if (nextCurrent > pageCount) { + resNextCurrent = pageCount; + } + setCurrent(resNextCurrent, { current: resNextCurrent, previous: current, pageSize }); + onChange({ + current: resNextCurrent, + previous: current, + pageSize, + }); + }; + return ( +
+ +
+ {current} + / + {pageCount} +
+ +
+ ); +}; + +export default TablePagination; diff --git a/src/table/_example/custom-row-style.tsx b/src/table/_example/custom-row-style.tsx index bb2d7cf2e..62b8deca5 100644 --- a/src/table/_example/custom-row-style.tsx +++ b/src/table/_example/custom-row-style.tsx @@ -1,7 +1,7 @@ import React, { useEffect } from 'react'; -import { Table, Tag } from 'tdesign-mobile-react'; -import { ErrorCircleFilledIcon, CheckCircleFilledIcon, CloseCircleFilledIcon } from 'tdesign-icons-react'; import type { BaseTableProps } from 'tdesign-mobile-react'; +import { Table, Tag } from 'tdesign-mobile-react'; +import { CheckCircleFilledIcon, CloseCircleFilledIcon, ErrorCircleFilledIcon } from 'tdesign-icons-react'; const classStyles = `