diff --git a/packages/casts-table/docs/table.en-US.md b/packages/casts-table/docs/table.en-US.md index a85908964..75c3ab894 100644 --- a/packages/casts-table/docs/table.en-US.md +++ b/packages/casts-table/docs/table.en-US.md @@ -20,10 +20,14 @@ $ pnpm add @casts/table ## Manual pagination -当 `manualPagination=true` 时,组件内部不会自动处理分页数据,适用于远程加载数据时的分页状态。 +When `manualPagination=true`, paging data is not automatically processed internally by the component, and applies to the paging state when data is loaded remotely. +## Column pinning + + + ## Selection diff --git a/packages/casts-table/docs/table.zh-CN.md b/packages/casts-table/docs/table.zh-CN.md index 6e7a1d186..a9de2b52c 100644 --- a/packages/casts-table/docs/table.zh-CN.md +++ b/packages/casts-table/docs/table.zh-CN.md @@ -24,6 +24,10 @@ $ pnpm add @casts/table +## 固定列 + + + ## 可选择 diff --git a/packages/casts-table/examples/column-pinning.tsx b/packages/casts-table/examples/column-pinning.tsx new file mode 100644 index 000000000..63a6de211 --- /dev/null +++ b/packages/casts-table/examples/column-pinning.tsx @@ -0,0 +1,88 @@ +// @ts-ignore example should import React +import React, { useMemo } from 'react'; +import { Link } from '@casts/link'; +import { Table } from '@casts/table'; + +const makeData = () => { + const names = ['Alice', 'Bob', 'Tom']; + const ages = [12, 18, 23, 55, 22]; + const addresses = [ + 'New York No. 1 Lake Park', + 'London No. 1 Lake Park', + 'Sydney No. 1 Lake Park', + '833 S Lark Ellen Ave', + 'Apt. 279 94808 Maryellen Run, Lake Josefinemouth, OR 24712-1214', + 'Apt. 639 17013 Lubowitz Isle, Robertschester, IN 93121-9896', + '267 Kemmer Mall, New Rickeyburgh, IN 34429', + ]; + + return [...Array(100)].map((_, idx) => { + return { + name: names[idx % names.length], + age: ages[idx % ages.length], + address1: addresses[idx % addresses.length], + address2: addresses[(idx + 1) % addresses.length], + address3: addresses[(idx + 2) % addresses.length], + address4: addresses[(idx + 3) % addresses.length], + address5: addresses[(idx + 4) % addresses.length], + address6: addresses[(idx + 4) % addresses.length], + }; + }); +}; + +const TableColumnPinningDemo = () => { + const data = useMemo(() => makeData(), []); + return ( + `${column.id}`, + fixed: 'left', + }, + { key: 'address1', title: 'address1' }, + { key: 'address2', title: 'address2' }, + { + key: 'address3', + title: 'address3', + + fixed: 'right', + }, + { key: 'address4', title: 'address4' }, + { key: 'address5', title: 'address5', fixed: 'right' }, + { key: 'address6', title: 'address6', fixed: 'right' }, + { + key: 'operate', + title: 'operate', + size: 100, + fixed: 'right', + cell: () => ( + { + console.log('click'); + }} + > + View + + ), + }, + ]} + round + // rowRound + bordered + // cellBordered + rowBordered + // stripe + maxHeight={500} + /> + ); +}; + +export default TableColumnPinningDemo; diff --git a/packages/casts-table/src/components/hooks/use-table-column-pinning.tsx b/packages/casts-table/src/components/hooks/use-table-column-pinning.tsx new file mode 100644 index 000000000..212bfb845 --- /dev/null +++ b/packages/casts-table/src/components/hooks/use-table-column-pinning.tsx @@ -0,0 +1,293 @@ +import { + CSSProperties, + RefObject, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { + getStyle, + isUndefined, + reduce, + useScroll, + useSize, +} from '@casts/common'; +import { useConfig } from '@casts/config-provider'; +import clsx from 'clsx'; + +import { Column } from '../types'; + +export type Rect = { + x: number; + offsetLeft?: number; + offsetRight?: number; + fixed?: 'left' | 'right'; +}; + +type ColumnPinning = Pick; + +export type UseTableColumnTablePinningProps = { + columns: ColumnPinning[]; + tableRef: RefObject; + tableContainerRef: RefObject; + columnsRef: RefObject>; +}; + +export const useTableColumnTablePinning = ( + props: UseTableColumnTablePinningProps, +) => { + const { columns, tableContainerRef, columnsRef, tableRef } = props; + + const columnRectsRef = useRef>({}); + + const [rightFirstColumnKey, setRightFirstColumnKey] = useState(); + const [leftLastColumnKey, setLeftLastColumnKey] = useState(); + + const { left: leftFixedColumns, right: rightFixedColumns } = useMemo( + () => + reduce( + columns, + (acc, currentColumn) => { + if (currentColumn.fixed === 'left') { + acc.left.push(currentColumn); + } + + if (currentColumn.fixed === 'right') { + acc.right.unshift(currentColumn); + } + + return acc; + }, + { + left: [] as ColumnPinning[], + right: [] as ColumnPinning[], + }, + ), + [columns], + ); + + const scroll = useScroll(tableContainerRef); + + const tableSize = useSize(tableRef); + const tableContainerSize = useSize(tableContainerRef); + + const columnStickyRights = useMemo>(() => { + const calculateColumnStickyRights = (columns: ColumnPinning[]) => { + if ( + columns.length === 0 || + !columnsRef.current || + !tableContainerRef.current + ) { + return {}; + } + + const columnStickyRights: Record = {}; + + let offsetRight = 0; + + rightFixedColumns.forEach((column) => { + const columnElement = columnsRef.current?.[column.key]; + + if (!columnElement || !tableContainerRef.current) { + return; + } + + const currentPosition = getStyle(columnElement)?.position; + + if (currentPosition === 'sticky') { + columnElement.style.position = 'static'; + } + + const clientRect = columnElement.getBoundingClientRect(); + + const rect: Rect = { + fixed: column.fixed, + offsetRight, + x: Math.floor( + columnElement.offsetLeft - + tableContainerRef.current.clientWidth + + clientRect.width + + offsetRight, + ), + }; + + columnStickyRights[column.key] = Math.floor(offsetRight * 100) / 100; + // Keep two decimals to prevent gaps in columns + offsetRight += clientRect.width; + + columnRectsRef.current[column.key] = rect; + + if (currentPosition === 'sticky') { + columnElement.style.position = currentPosition; + } + }); + + return columnStickyRights; + }; + + return calculateColumnStickyRights(rightFixedColumns); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + columnsRef, + rightFixedColumns, + tableContainerRef, + tableContainerSize?.width, + tableSize?.width, + ]); + + const columnStickyLefts = useMemo>(() => { + const calculateColumnStickyLefts = (columns: ColumnPinning[]) => { + if ( + columns.length === 0 || + !columnsRef.current || + !tableContainerRef.current + ) { + return {}; + } + + const columnStickyLefts: Record = {}; + + let offsetLeft = 0; + + leftFixedColumns.forEach((column) => { + const columnElement = columnsRef.current?.[column.key]; + + if (!columnElement || !tableContainerRef.current) { + return; + } + + const currentPosition = getStyle(columnElement)?.position; + + if (currentPosition === 'sticky') { + columnElement.style.position = 'static'; + } + + const clientRect = columnElement.getBoundingClientRect(); + + const rect: Rect = { + fixed: column.fixed, + offsetLeft, + x: Math.floor(columnElement.offsetLeft - offsetLeft), + }; + + columnStickyLefts[column.key] = offsetLeft; + offsetLeft += clientRect.width; + + columnRectsRef.current[column.key] = rect; + + if (currentPosition === 'sticky') { + columnElement.style.position = currentPosition; + } + }); + + return columnStickyLefts; + }; + + return calculateColumnStickyLefts(leftFixedColumns); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + columnsRef, + leftFixedColumns, + tableContainerRef, + tableContainerSize?.width, + ]); + + const [rightColumnKeys, setRightColumnKeys] = useState< + Record + >({}); + const [leftColumnKeys, setLeftColumnKeys] = useState>( + {}, + ); + + useEffect(() => { + const scrollLeft = scroll?.left; + if (isUndefined(scrollLeft)) { + return; + } + + const calculateRightFirstColumnKey = () => { + let rightFirstColumnKey = ''; + + const rightColumnKeys = reduce( + columnRectsRef.current, + (acc, current, currentKey) => { + if ( + current.fixed === 'right' && + !isUndefined(current.x) && + current.x > scrollLeft + ) { + rightFirstColumnKey = currentKey; + return { + ...acc, + [currentKey]: true, + }; + } + + return acc; + }, + {} as Record, + ); + + setRightColumnKeys(rightColumnKeys); + setRightFirstColumnKey(rightFirstColumnKey); + }; + + const calculateLeftLastColumnKey = () => { + let leftLastColumnKey = ''; + + const leftColumnKeys = reduce( + columnRectsRef.current, + (acc, current, currentKey) => { + if ( + current.fixed === 'left' && + !isUndefined(current.x) && + current.x < scrollLeft + ) { + leftLastColumnKey = currentKey; + return { + ...acc, + [currentKey]: true, + }; + } + + return acc; + }, + {} as Record, + ); + + setLeftColumnKeys(leftColumnKeys); + setLeftLastColumnKey(leftLastColumnKey); + }; + + calculateRightFirstColumnKey(); + calculateLeftLastColumnKey(); + }, [scroll?.left, tableContainerSize?.width, tableSize?.width]); + + const { getPrefixCls } = useConfig(); + const prefixCls = getPrefixCls('table-column--pinning'); + + const getColumnPinningClasses = (column: ColumnPinning) => + clsx({ + [`${prefixCls}-${column.fixed}`]: + leftColumnKeys[column.key] || rightColumnKeys[column.key], + [`${prefixCls}-left-last`]: leftLastColumnKey === column.key, + + [`${prefixCls}-right-first`]: rightFirstColumnKey === column.key, + }); + + const getColumnPinningStyles = (column: ColumnPinning) => { + const styles: CSSProperties = { + position: column.fixed && 'sticky', + right: columnStickyRights[column.key], + left: columnStickyLefts[column.key], + }; + + return styles; + }; + + return { + getColumnPinningClasses, + getColumnPinningStyles, + }; +}; diff --git a/packages/casts-table/src/components/hooks/use-table.ts b/packages/casts-table/src/components/hooks/use-table.ts index f6d861fef..b6ad3659d 100644 --- a/packages/casts-table/src/components/hooks/use-table.ts +++ b/packages/casts-table/src/components/hooks/use-table.ts @@ -49,6 +49,7 @@ const getColumns = (columns: Column[]): ColumnDef[] => { // store meta data to read original config meta: { + fixed: column.fixed, size: column.size, minSize: column.minSize, maxSize: column.maxSize, @@ -205,6 +206,9 @@ export const useTable = (props: UseTableProps) => { pageIndex: (pagination.current || 1) - 1, }, rowSelection: rowSelection, + // columnPinning: { + // right: ['address5', 'operate'], + // }, }, }); diff --git a/packages/casts-table/src/components/styles/table.scss b/packages/casts-table/src/components/styles/table.scss index 1b762293d..71d045414 100644 --- a/packages/casts-table/src/components/styles/table.scss +++ b/packages/casts-table/src/components/styles/table.scss @@ -31,13 +31,16 @@ $table-prefix-cls: #{$prefix-cls}-table; } table { - width: 100%; + min-width: 100%; height: 100%; text-align: left; + + table-layout: fixed; border-spacing: 0; tr { + background-color: inherit; transition: background-color $motion-duration-rapid $motion-easing-in-out; } @@ -47,6 +50,9 @@ $table-prefix-cls: #{$prefix-cls}-table; font-size: $table-font-size; font-weight: $table-font-weight; line-height: $table-line-height; + background-color: inherit; + + transition: box-shadow $motion-duration-rapid $motion-easing-in-out; .#{$prefix-cls}-checkbox { vertical-align: text-bottom; @@ -60,6 +66,10 @@ $table-prefix-cls: #{$prefix-cls}-table; color: $table-thead-color-text; background-color: $table-thead-color-surface; } + + tbody { + background-color: $color-surface-container-default; + } } &--round { @@ -110,6 +120,7 @@ $table-prefix-cls: #{$prefix-cls}-table; border-top: $border-width-xsmall solid $table-color-border; } } + tr:last-child { th { border-bottom: none; @@ -155,7 +166,7 @@ $table-prefix-cls: #{$prefix-cls}-table; .#{$table-prefix-cls}-thead { position: sticky; top: 0; - z-index: $elevation-z-index-low; + z-index: 10; } } } @@ -215,4 +226,50 @@ $table-prefix-cls: #{$prefix-cls}-table; &-empty { padding: $space-105-x; } + + &-column--pinning { + &-left { + z-index: 2; + &::after { + position: absolute; + top: 1px; + right: 0; + bottom: 1px; + bottom: 1px; + width: 30px; + pointer-events: none; + content: ''; + transition: box-shadow $motion-duration-rapid $motion-easing-in-out; + transform: translateX(100%); + } + + &-last { + &::after { + box-shadow: inset 8px 0px 4px 0px $color-shadow-level-1; + } + } + } + + &-right { + z-index: 2; + &::before { + position: absolute; + top: 1px; + bottom: 1px; + bottom: 1px; + left: 0; + width: 30px; + pointer-events: none; + content: ''; + transition: box-shadow $motion-duration-rapid $motion-easing-in-out; + transform: translateX(-100%); + } + + &-first { + &::before { + box-shadow: inset -8px 0px 4px 0px $color-shadow-level-1; + } + } + } + } } diff --git a/packages/casts-table/src/components/table.tsx b/packages/casts-table/src/components/table.tsx index 7cc50cf81..408aa3517 100644 --- a/packages/casts-table/src/components/table.tsx +++ b/packages/casts-table/src/components/table.tsx @@ -1,11 +1,13 @@ -import { forwardRef, Ref, useImperativeHandle } from 'react'; +import { forwardRef, Ref, useImperativeHandle, useRef } from 'react'; import { isUndefined, pick, some } from '@casts/common'; import { Empty } from '@casts/empty'; import { Pagination } from '@casts/pagination'; import { CircularProgress } from '@casts/progress'; import { flexRender } from '@tanstack/react-table'; +import clsx from 'clsx'; import { useTable } from './hooks'; +import { useTableColumnTablePinning } from './hooks/use-table-column-pinning'; import { TableProps } from './types'; import '@casts/theme/styles/scss/core.scss'; @@ -43,6 +45,18 @@ export const Table = forwardRef((props: TableProps, ref: Ref) => { getRowKey, } = useTable(props); + const tableContainerRef = useRef(null); + const tableRef = useRef(null); + const thRefs = useRef>({}); + + const { getColumnPinningStyles, getColumnPinningClasses } = + useTableColumnTablePinning({ + columns: props.columns, + tableContainerRef, + columnsRef: thRefs, + tableRef, + }); + useImperativeHandle(ref, () => ({})); return ( @@ -52,16 +66,38 @@ export const Table = forwardRef((props: TableProps, ref: Ref) => { )} -
-
+
+
{table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => ( {row.getVisibleCells().map((cell) => { return ( -
{ + if (element) { + thRefs.current[header.column.id] = element; + } else { + delete thRefs.current[header.column.id]; + } + }} + className={clsx( + thClasses, + getColumnPinningClasses({ + key: header.column.id, + fixed: (header.column.columnDef.meta as any)?.fixed, + }), + )} style={{ + ...getColumnPinningStyles({ + key: header.column.id, + fixed: (header.column.columnDef.meta as any)?.fixed, + }), width: some( pick(header.column.columnDef.meta, [ 'size', @@ -90,7 +126,21 @@ export const Table = forwardRef((props: TableProps, ref: Ref) => {
+ {flexRender( cell.column.columnDef.cell, cell.getContext(), diff --git a/packages/casts-table/src/components/types/table.ts b/packages/casts-table/src/components/types/table.ts index 2b81bc5a2..2dc69d9cb 100644 --- a/packages/casts-table/src/components/types/table.ts +++ b/packages/casts-table/src/components/types/table.ts @@ -85,7 +85,7 @@ export type Column = { children?: Column[]; /** (IE not support) Set column to be fixed: true(same as left) 'left' 'right' */ - fixed?: boolean; + fixed?: 'left' | 'right'; } & Pick, 'cell' | 'size' | 'maxSize' | 'minSize'>; export type TableProps = UseTableProps & { diff --git a/packages/casts-theme/src/tokens/core/palette.json b/packages/casts-theme/src/tokens/core/palette.json index 953ecdee2..a9a3e5059 100644 --- a/packages/casts-theme/src/tokens/core/palette.json +++ b/packages/casts-theme/src/tokens/core/palette.json @@ -268,7 +268,7 @@ "darkValue": "0, 0%, 9%" }, "150": { - "value": "0, 0%, 96.5%", + "value": "0, 0%, 96.8%", "darkValue": "0, 0%, 13%" }, "200": { diff --git a/packages/casts-theme/src/tokens/core/shadow.json b/packages/casts-theme/src/tokens/core/shadow.json index 1730bdf03..e37362610 100644 --- a/packages/casts-theme/src/tokens/core/shadow.json +++ b/packages/casts-theme/src/tokens/core/shadow.json @@ -108,6 +108,11 @@ }, "value": "hsla({color.palette.hsl.neutral.900}, 0.15)" } + }, + "level": { + "1": { + "value": "hsla({color.palette.hsl.neutral.1000}, 0.04)" + } } } }, diff --git a/site/packages/rd-vite/src/node/remarks/react-api.ts b/site/packages/rd-vite/src/node/remarks/react-api.ts index 4aee2e409..0fc568873 100644 --- a/site/packages/rd-vite/src/node/remarks/react-api.ts +++ b/site/packages/rd-vite/src/node/remarks/react-api.ts @@ -11,7 +11,9 @@ import { isEmpty, isUndefined, map, + orderBy, reduce, + toPairs, uniq, zipObject, } from 'lodash-es'; @@ -148,7 +150,11 @@ export const remarkReactApi: Plugin< }, }); - const apisMarkdown = map(apiData, (apis, name) => { + const apiDataArray = orderBy(toPairs(apiData), (pair) => { + return pair[0].startsWith('use') ? 'z' : pair[0]; + }); + + const apisMarkdown = map(apiDataArray, ([name, apis]) => { return `### ${name}\n${getApiTableMarkdown(apis)}`; }).join('\n');