diff --git a/.changeset/smooth-llamas-tease.md b/.changeset/smooth-llamas-tease.md new file mode 100644 index 000000000..34a848c01 --- /dev/null +++ b/.changeset/smooth-llamas-tease.md @@ -0,0 +1,5 @@ +--- +'@tiny-design/react': patch +--- + +Redesign the Descriptions component with a new data-driven API, responsive columns, semantic rendering modes, richer docs demos, and updated layout behavior. diff --git a/packages/react/src/descriptions/__tests__/__snapshots__/descriptions.test.tsx.snap b/packages/react/src/descriptions/__tests__/__snapshots__/descriptions.test.tsx.snap index 06cc2a09e..60c5f9117 100644 --- a/packages/react/src/descriptions/__tests__/__snapshots__/descriptions.test.tsx.snap +++ b/packages/react/src/descriptions/__tests__/__snapshots__/descriptions.test.tsx.snap @@ -3,49 +3,114 @@ exports[` should match the snapshot 1`] = `
+
+
+ Profile +
+
- - - +
+
-
- - - -
Name - John + : - + +
+ John +
+ +
+
+
Age + : + +
+
+
+ 30 +
+
+
+
+
+ + Address + + - 30 + : -
+
+ +
+ Sydney +
+
+ + +
diff --git a/packages/react/src/descriptions/__tests__/descriptions.test.tsx b/packages/react/src/descriptions/__tests__/descriptions.test.tsx index 4e6e6c966..92209268d 100644 --- a/packages/react/src/descriptions/__tests__/descriptions.test.tsx +++ b/packages/react/src/descriptions/__tests__/descriptions.test.tsx @@ -5,9 +5,12 @@ import Descriptions from '../index'; describe('', () => { it('should match the snapshot', () => { const { asFragment } = render( - + John 30 + + Sydney + ); expect(asFragment()).toMatchSnapshot(); @@ -20,15 +23,18 @@ describe('', () => { ); expect(container.firstChild).toHaveClass('ty-descriptions'); + expect(container.querySelector('dl')).toBeInTheDocument(); }); - it('should render title', () => { + it('should render header and footer', () => { const { getByText } = render( - + Action} footer="Summary footer"> John ); expect(getByText('User Info')).toBeInTheDocument(); + expect(getByText('Action')).toBeInTheDocument(); + expect(getByText('Summary footer')).toBeInTheDocument(); }); it('should render items', () => { @@ -40,4 +46,34 @@ describe('', () => { expect(getByText('Name')).toBeInTheDocument(); expect(getByText('John')).toBeInTheDocument(); }); + + it('should render items from items prop', () => { + const { getByText } = render( + + ); + + expect(getByText('Name')).toBeInTheDocument(); + expect(getByText('Maintainer')).toBeInTheDocument(); + }); + + it('should use table semantic when bordered', () => { + const { container } = render( + + ); + + expect(container.querySelector('table')).toBeInTheDocument(); + }); + + it('should render empty placeholder for nullish content', () => { + const { getByText } = render( + + ); + + expect(getByText('Pending')).toBeInTheDocument(); + }); }); diff --git a/packages/react/src/descriptions/demo/Basic.tsx b/packages/react/src/descriptions/demo/Basic.tsx index 9f0216c8e..85f80a8a0 100644 --- a/packages/react/src/descriptions/demo/Basic.tsx +++ b/packages/react/src/descriptions/demo/Basic.tsx @@ -1,14 +1,22 @@ import React from 'react'; -import { Descriptions } from '@tiny-design/react'; +import { Button, Descriptions, Tag } from '@tiny-design/react'; export default function BasicDemo() { return ( - - React - 0200004567 - Sydney, Australia - Great - 456P+HW Camperdown, New South Wales + Edit} + columns={2} + footer={Last synced 2 minutes ago}> + Tiny Studio + Australia Southeast + Core}> + Product Ops + + tiny.design + + Shared component workspace for tokens, docs, charts, and platform-specific UI packages. + ); -} \ No newline at end of file +} diff --git a/packages/react/src/descriptions/demo/Border.tsx b/packages/react/src/descriptions/demo/Border.tsx index 0db010c5f..e754de316 100644 --- a/packages/react/src/descriptions/demo/Border.tsx +++ b/packages/react/src/descriptions/demo/Border.tsx @@ -1,36 +1,40 @@ import React from 'react'; -import { Descriptions, Badge } from '@tiny-design/react'; +import { Badge, Descriptions } from '@tiny-design/react'; export default function BorderDemo() { return ( - - Cloud Database - Prepaid - YES - 2018-04-24 18:00:00 - - 2019-04-24 18:00:00 - - - - Running - - $80.00 - $20.00 - $60.00 - - Data disk type: MongoDB -
- Database version: 3.4 -
- Package: dds.mongo.mid -
- Storage space: 10 GB -
- Replication factor: 3 -
- Region: East China 1 -
-
+ + + Running + + ), + span: 'fill', + }, + { key: 'amount', label: 'Amount', content: '$80.00' }, + { key: 'discount', label: 'Discount', content: '$20.00' }, + { key: 'total', label: 'Receipts', content: '$60.00' }, + { + key: 'config', + label: 'Config', + span: 'fill', + content: 'MongoDB / 3 replicas / 10 GB', + }, + ]} + /> ); -} \ No newline at end of file +} diff --git a/packages/react/src/descriptions/demo/EmptyHidden.tsx b/packages/react/src/descriptions/demo/EmptyHidden.tsx new file mode 100644 index 000000000..3dfad0678 --- /dev/null +++ b/packages/react/src/descriptions/demo/EmptyHidden.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { Descriptions } from '@tiny-design/react'; + +export default function EmptyHiddenDemo() { + return ( + + ); +} diff --git a/packages/react/src/descriptions/demo/RenderAlign.tsx b/packages/react/src/descriptions/demo/RenderAlign.tsx new file mode 100644 index 000000000..b3bf9fdb5 --- /dev/null +++ b/packages/react/src/descriptions/demo/RenderAlign.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { Descriptions, Tag } from '@tiny-design/react'; + +export default function RenderAlignDemo() { + return ( + ( + + {item.label} + + )} + contentRender={(item) => { + if (item.key === 'severity') { + return High; + } + + return item.content; + }} + /> + ); +} diff --git a/packages/react/src/descriptions/demo/Semantic.tsx b/packages/react/src/descriptions/demo/Semantic.tsx new file mode 100644 index 000000000..2b0214310 --- /dev/null +++ b/packages/react/src/descriptions/demo/Semantic.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { Descriptions } from '@tiny-design/react'; + +export default function SemanticDemo() { + return ( +
+ + +
+ ); +} diff --git a/packages/react/src/descriptions/demo/Separator.tsx b/packages/react/src/descriptions/demo/Separator.tsx new file mode 100644 index 000000000..e067493db --- /dev/null +++ b/packages/react/src/descriptions/demo/Separator.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { Descriptions } from '@tiny-design/react'; + +export default function SeparatorDemo() { + return ( + + ); +} diff --git a/packages/react/src/descriptions/demo/Size.tsx b/packages/react/src/descriptions/demo/Size.tsx index 29d5da715..9851cc66b 100644 --- a/packages/react/src/descriptions/demo/Size.tsx +++ b/packages/react/src/descriptions/demo/Size.tsx @@ -1,51 +1,23 @@ import React from 'react'; -import { Descriptions, Radio } from '@tiny-design/react'; - -type DemoSize = 'sm' | 'md' | 'lg'; +import { Descriptions } from '@tiny-design/react'; export default function SizeDemo() { - const [size, setSize] = React.useState('md'); - return ( -
- setSize(val as DemoSize)} value={size}> - Small - Medium - Large - -
-
- - Cloud Database - Prepaid - 18:00:00 - $80.00 - $20.00 - $60.00 - - Data disk type: MongoDB -
- Database version: 3.4 -
- Package: dds.mongo.mid -
- Storage space: 10 GB -
- Replication factor: 3 -
- Region: East China 1 -
-
-
-
- - Cloud Database - Prepaid - 18:00:00 - $80.00 - $20.00 - $60.00 - -
+ ); } diff --git a/packages/react/src/descriptions/demo/Vertical.tsx b/packages/react/src/descriptions/demo/Vertical.tsx index 49a236f0b..4dd85c74b 100644 --- a/packages/react/src/descriptions/demo/Vertical.tsx +++ b/packages/react/src/descriptions/demo/Vertical.tsx @@ -3,12 +3,26 @@ import { Descriptions } from '@tiny-design/react'; export default function VerticalDemo() { return ( - - React - 0200004567 - Sydney, Australia - Great - 456P+HW Camperdown, New South Wales - + ); -} \ No newline at end of file +} diff --git a/packages/react/src/descriptions/demo/VerticalBorder.tsx b/packages/react/src/descriptions/demo/VerticalBorder.tsx index 514ac3020..4ff8a420d 100644 --- a/packages/react/src/descriptions/demo/VerticalBorder.tsx +++ b/packages/react/src/descriptions/demo/VerticalBorder.tsx @@ -1,36 +1,26 @@ import React from 'react'; -import { Descriptions, Badge } from '@tiny-design/react'; +import { Descriptions } from '@tiny-design/react'; export default function VerticalBorderDemo() { return ( - - Cloud Database - Prepaid - YES - 2018-04-24 18:00:00 - - 2019-04-24 18:00:00 - - - - Running - - $80.00 - $20.00 - $60.00 - - Data disk type: MongoDB -
- Database version: 3.4 -
- Package: dds.mongo.mid -
- Storage space: 10 GB -
- Replication factor: 3 -
- Region: East China 1 -
-
+ ); -} \ No newline at end of file +} diff --git a/packages/react/src/descriptions/descriptions-item.tsx b/packages/react/src/descriptions/descriptions-item.tsx index aa9da4045..a5061da5c 100755 --- a/packages/react/src/descriptions/descriptions-item.tsx +++ b/packages/react/src/descriptions/descriptions-item.tsx @@ -1,7 +1,5 @@ -import { DescriptionsItemProps } from './types'; - -const DescriptionsItem = (props: DescriptionsItemProps): JSX.Element => { - return props.children as JSX.Element; +const DescriptionsItem = (): JSX.Element => { + return null as unknown as JSX.Element; }; DescriptionsItem.displayName = 'DescriptionsItem'; diff --git a/packages/react/src/descriptions/descriptions.tsx b/packages/react/src/descriptions/descriptions.tsx index 3099be976..4dad235f9 100644 --- a/packages/react/src/descriptions/descriptions.tsx +++ b/packages/react/src/descriptions/descriptions.tsx @@ -1,88 +1,284 @@ -import React, { useContext } from 'react'; +import React, { useContext, useMemo } from 'react'; import classNames from 'classnames'; import { ConfigContext } from '../config-provider/config-context'; import { getPrefixCls } from '../_utils/general'; -import { DescriptionsItemProps, DescriptionsProps } from './types'; -import Row from './row'; +import warning from '../_utils/warning'; +import { resolveResponsiveValue } from '../grid/responsive'; +import { useActiveBreakpoint } from '../grid/use-active-breakpoint'; +import DescriptionsItem from './descriptions-item'; +import { + DescriptionsItemProps, + DescriptionsItemType, + DescriptionsProps, + DescriptionsSemantic, + DescriptionsSpan, +} from './types'; + +type NormalizedItem = DescriptionsItemType & { + key: React.Key; + content: React.ReactNode; + span: DescriptionsSpan; +}; + +type LayoutItem = NormalizedItem & { + computedSpan: number; +}; + +function normalizeColumns(columns: number | undefined): number { + if (!Number.isFinite(columns) || (columns as number) <= 0) { + warning(true, 'Descriptions `columns` should be a positive number. Falling back to 3.'); + return 3; + } + + return Math.max(1, Math.floor(columns as number)); +} + +function getItemSpan(span: DescriptionsSpan | undefined, columns: number, remaining: number): number { + if (span === 'fill') { + return remaining; + } + + const numericSpan = Math.max(1, Math.floor(span ?? 1)); + if (numericSpan > columns) { + warning(true, `Descriptions item span ${numericSpan} exceeds columns ${columns}. Clamping to ${columns}.`); + } + + return Math.min(numericSpan, columns); +} + +function layoutItems(items: NormalizedItem[], columns: number): LayoutItem[][] { + const rows: LayoutItem[][] = []; + let currentRow: LayoutItem[] = []; + let remaining = columns; + + items.forEach((item) => { + let computedSpan = getItemSpan(item.span, columns, remaining); + + if (computedSpan > remaining && currentRow.length > 0) { + rows.push(currentRow); + currentRow = []; + remaining = columns; + computedSpan = getItemSpan(item.span, columns, remaining); + } + + currentRow.push({ ...item, computedSpan }); + remaining -= computedSpan; + + if (remaining === 0) { + rows.push(currentRow); + currentRow = []; + remaining = columns; + } + }); + + if (currentRow.length > 0) { + rows.push(currentRow); + } + + return rows; +} + +function isDescriptionsItemElement( + child: React.ReactNode, +): child is React.ReactElement { + return React.isValidElement(child) && child.type === DescriptionsItem; +} + +function renderValue(value: React.ReactNode, empty: React.ReactNode): React.ReactNode { + return value === null || value === undefined || value === '' ? empty : value; +} const Descriptions = (props: DescriptionsProps): React.ReactElement => { const { size = 'md', bordered = false, - column = 3, + columns: responsiveColumns = 3, layout = 'horizontal', title, + extra, + footer, className, children, + items, + empty = '-', + semantic = 'auto', + labelAlign = 'start', + contentAlign = 'start', + labelRender, + contentRender, + separator, prefixCls: customisedCls, ...otherProps } = props; const configContext = useContext(ConfigContext); const prefixCls = getPrefixCls('descriptions', configContext.prefixCls, customisedCls); - const cls = classNames(prefixCls, className, `${prefixCls}_${size}`, { - [`${prefixCls}_bordered`]: bordered, - }); + const breakpoint = useActiveBreakpoint(); + const columns = normalizeColumns(resolveResponsiveValue(responsiveColumns, breakpoint) ?? 3); + const displayColon = 'colon' in props ? (props.colon as boolean) : !bordered; + const resolvedSemantic: DescriptionsSemantic = semantic === 'auto' ? (bordered ? 'table' : 'list') : semantic; + + const normalizedItems = useMemo(() => { + if (items && items.length > 0) { + if (process.env.NODE_ENV !== 'production' && React.Children.count(children) > 0) { + warning(true, 'Descriptions `items` takes priority over `children`.'); + } - const getRows = (): React.ReactElement[][] => { - const rows: React.ReactElement[][] = []; - let columns: React.ReactElement[] | null = null; - let leftSpans: number; - - const numOfChildren = React.Children.count(children); - React.Children.forEach(children, (child, idx) => { - const childElement = child as React.FunctionComponentElement; - if (childElement.type.displayName === 'DescriptionsItem') { - let itemNode = childElement; - - if (!columns) { - leftSpans = column; - columns = []; - rows.push(columns); - } - - // set last span to align the end of Descriptions - if (idx === numOfChildren - 1) { - const props: Partial = { span: leftSpans }; - itemNode = React.cloneElement(childElement, props); - } - - // calculate left fill span - const { span = 1 } = itemNode.props; - columns.push(itemNode); - leftSpans -= span; - - if (leftSpans <= 0) { - columns = null; - } + return items + .filter((item) => !item.hidden) + .map((item, index) => ({ + ...item, + key: item.key ?? index, + content: item.content, + span: item.span ?? 1, + })); + } + + const childItems: NormalizedItem[] = []; + React.Children.forEach(children, (child, index) => { + if (!child) { + return; + } + + if (!isDescriptionsItemElement(child)) { + warning(true, 'Descriptions only accepts `Descriptions.Item` as children.'); + return; } + + if (child.props.hidden) { + return; + } + + childItems.push({ + key: child.key ?? index, + label: child.props.label, + content: child.props.children, + span: child.props.span ?? 1, + extra: child.props.extra, + className: child.props.className, + style: child.props.style, + }); }); - return rows; + return childItems; + }, [children, items]); + + const rows = useMemo(() => layoutItems(normalizedItems, columns), [columns, normalizedItems]); + + const cls = classNames( + prefixCls, + className, + `${prefixCls}_${size}`, + `${prefixCls}_${layout}`, + `${prefixCls}_${resolvedSemantic}`, + { + [`${prefixCls}_bordered`]: bordered, + [`${prefixCls}_label-${labelAlign}`]: labelAlign, + [`${prefixCls}_content-${contentAlign}`]: contentAlign, + } + ); + + const separatorNode = separator ?? (displayColon ? ':' : null); + + const renderLabel = (item: DescriptionsItemType, index: number) => { + const labelContent = labelRender ? labelRender(item, index) : item.label; + return ( +
+ {labelContent} + {separatorNode ? {separatorNode} : null} + {item.extra ? {item.extra} : null} +
+ ); }; - const rows = getRows(); - const displayColon = 'colon' in props ? (props.colon as boolean) : !bordered; - // the reason of using a div to wrapper a table is to figure out border radius issue of the table + const renderContent = (item: DescriptionsItemType, index: number) => { + const content = contentRender ? contentRender(item, index) : item.content; + return renderValue(content, empty); + }; + + const renderTable = () => ( +
+ + + {rows.map((row, rowIndex) => + layout === 'vertical' ? ( + + + {row.map((item, index) => ( + + ))} + + + {row.map((item, index) => ( + + ))} + + + ) : ( + + {row.map((item, index) => ( + + + + + ))} + + ) + )} + +
+ {renderLabel(item, index)} +
+ {renderContent(item, index)} +
+ {renderLabel(item, index)} + + {renderContent(item, index)} +
+
+ ); + + const renderList = () => ( +
+
+ {rows.flat().map((item, index) => ( +
+
{renderLabel(item, index)}
+
{renderContent(item, index)}
+
+ ))} +
+
+ ); + return (
- {title &&
{title}
} -
- - - {rows.map((row, idx) => ( - - ))} - -
-
+ {(title || extra) && ( +
+ {title ?
{title}
:
} + {extra ?
{extra}
: null} +
+ )} + {resolvedSemantic === 'table' ? renderTable() : renderList()} + {footer ?
{footer}
: null}
); }; diff --git a/packages/react/src/descriptions/index.md b/packages/react/src/descriptions/index.md index 6bff3e721..a06135e9f 100644 --- a/packages/react/src/descriptions/index.md +++ b/packages/react/src/descriptions/index.md @@ -2,6 +2,14 @@ import BasicDemo from './demo/Basic'; import BasicSource from './demo/Basic.tsx?raw'; import BorderDemo from './demo/Border'; import BorderSource from './demo/Border.tsx?raw'; +import EmptyHiddenDemo from './demo/EmptyHidden'; +import EmptyHiddenSource from './demo/EmptyHidden.tsx?raw'; +import RenderAlignDemo from './demo/RenderAlign'; +import RenderAlignSource from './demo/RenderAlign.tsx?raw'; +import SemanticDemo from './demo/Semantic'; +import SemanticSource from './demo/Semantic.tsx?raw'; +import SeparatorDemo from './demo/Separator'; +import SeparatorSource from './demo/Separator.tsx?raw'; import SizeDemo from './demo/Size'; import SizeSource from './demo/Size.tsx?raw'; import VerticalDemo from './demo/Vertical'; @@ -11,11 +19,11 @@ import VerticalBorderSource from './demo/VerticalBorder.tsx?raw'; # Descriptions -Display multiple read-only fields in groups. +Display grouped read-only fields with responsive columns and explicit span rules. ## Scenario -Commonly displayed on the details page. +Use it for metadata, release notes, system details, or any dense read-only summary where labels and values need consistent rhythm. ## Usage @@ -31,7 +39,7 @@ import { Descriptions } from 'tiny-design'; ### Basic -A simple usage. +Children API with title, actions, and footer. @@ -40,7 +48,7 @@ A simple usage. ### Border -Descriptions with border. +Bordered table semantic for strongly aligned detail views. @@ -49,10 +57,28 @@ Descriptions with border. ### Size -Customised sizes to fit in a variety of containers. +Responsive columns using the data-driven `items` API. + + + +### Separator + +Use `separator` when the default colon is too plain for the tone of the page. + + + + + + +### Empty And Hidden + +Use `empty` for nullish values and `hidden` to remove internal-only rows. + + + @@ -60,7 +86,7 @@ Customised sizes to fit in a variety of containers. ### Vertical -Vertical layout. +Vertical layout for denser editorial summaries. @@ -69,10 +95,28 @@ Vertical layout. ### Vertical Border -Vertical layout with border. +Vertical table layout for structured audit or approval data. + + + +### Semantic + +Compare `semantic="list"` and `semantic="table"` for the same kind of content. + + + + + + +### Render And Align + +Use `labelRender`, `contentRender`, `labelAlign`, and `contentAlign` for presentation control. + + + @@ -83,16 +127,35 @@ Vertical layout with border. | Property | Description | Type | Default | | ------------- | --------------------------------------------- | ------------------------------------- | ------------- | -| title | the title of the description list | ReactNode | - | -| bordered | whether to display the border | boolean | false | -| column | the number of `Descriptions.Items` in a row | number | 3 | -| size | set the size of `Descriptions` | enum: `sm` | `md` | `lg` | `md` | +| title | header title | ReactNode | - | +| extra | header actions | ReactNode | - | +| footer | footer content | ReactNode | - | +| bordered | whether to display borders | boolean | false | +| columns | logical column count or responsive columns | `number` | `{ xs?; sm?; md?; lg?; xl?; xxl? }` | `3` | +| size | size of the component | enum: `sm` | `md` | `lg` | `md` | | layout | description layout | enum: `horizontal` | `vertical` | `horizontal` | -| colon | whether to display the colon | boolean | - | +| colon | whether to display the colon | boolean | `!bordered` | +| separator | custom separator between label and content | ReactNode | `:` or none | +| items | data-driven items | `DescriptionsItemType[]` | - | +| empty | placeholder for nullish content | ReactNode | `-` | +| semantic | semantic renderer | enum: `auto` | `table` | `list` | `auto` | +| labelAlign | label alignment | enum: `start` | `center` | `end` | `start` | +| contentAlign | content alignment | enum: `start` | `center` | `end` | `start` | +| labelRender | custom label render | `(item, index) => ReactNode` | - | +| contentRender | custom content render | `(item, index) => ReactNode` | - | ### Descriptions.Item | Property | Description | Type | Default | | ------------- | ------------------------------------- | ----------------- | --------- | -| label | description of the content | ReactNode | - | -| span | the number of columns included | number | 1 | \ No newline at end of file +| label | label content | ReactNode | - | +| span | logical columns to span | `number` | `'fill'` | `1` | +| hidden | whether to hide the item | boolean | false | +| extra | extra content shown near the label | ReactNode | - | + +## Notes + +- `columns` is the number of logical columns, not the number of rendered DOM cells. +- `span="fill"` expands the current item to the remaining space of the current row. +- `items` takes priority over `children` when both are provided. +- `semantic="auto"` uses `table` when `bordered` is enabled, otherwise `list`. diff --git a/packages/react/src/descriptions/index.zh_CN.md b/packages/react/src/descriptions/index.zh_CN.md index f56255ada..72cd88417 100644 --- a/packages/react/src/descriptions/index.zh_CN.md +++ b/packages/react/src/descriptions/index.zh_CN.md @@ -2,6 +2,14 @@ import BasicDemo from './demo/Basic'; import BasicSource from './demo/Basic.tsx?raw'; import BorderDemo from './demo/Border'; import BorderSource from './demo/Border.tsx?raw'; +import EmptyHiddenDemo from './demo/EmptyHidden'; +import EmptyHiddenSource from './demo/EmptyHidden.tsx?raw'; +import RenderAlignDemo from './demo/RenderAlign'; +import RenderAlignSource from './demo/RenderAlign.tsx?raw'; +import SemanticDemo from './demo/Semantic'; +import SemanticSource from './demo/Semantic.tsx?raw'; +import SeparatorDemo from './demo/Separator'; +import SeparatorSource from './demo/Separator.tsx?raw'; import SizeDemo from './demo/Size'; import SizeSource from './demo/Size.tsx?raw'; import VerticalDemo from './demo/Vertical'; @@ -11,11 +19,11 @@ import VerticalBorderSource from './demo/VerticalBorder.tsx?raw'; # Descriptions 描述列表 -成组展示多个只读字段。 +以响应式列布局展示成组只读字段。 ## 使用场景 -常用于详情页的信息展示。 +适合元数据、发布信息、系统详情等需要稳定节奏的只读信息展示。 ## 使用方式 @@ -31,7 +39,7 @@ import { Descriptions } from 'tiny-design'; ### 基本用法 -简单的用法。 +使用 children API,并带有标题、操作区和底部信息。 @@ -40,7 +48,7 @@ import { Descriptions } from 'tiny-design'; ### 带边框 -带边框的描述列表。 +带边框且使用 table 语义的对齐详情展示。 @@ -49,10 +57,28 @@ import { Descriptions } from 'tiny-design'; ### 尺寸 -适应各种容器的尺寸。 +使用 `items` 数据驱动,并根据断点自动调整列数。 + + + +### 分隔符 + +当默认冒号不够明显时,可以用 `separator` 自定义标签与内容之间的关系。 + + + + + + +### 空值与隐藏 + +使用 `empty` 统一空值占位,使用 `hidden` 移除不应展示的行。 + + + @@ -60,7 +86,7 @@ import { Descriptions } from 'tiny-design'; ### 垂直布局 -垂直布局。 +适合编辑性更强的摘要信息展示。 @@ -69,10 +95,28 @@ import { Descriptions } from 'tiny-design'; ### 垂直带边框 -垂直布局带边框。 +适合审计、审批等结构化信息。 + + + +### 语义模式 + +对比 `semantic="list"` 和 `semantic="table"` 的展示差异。 + + + + + + +### 渲染与对齐 + +使用 `labelRender`、`contentRender`、`labelAlign`、`contentAlign` 控制呈现方式。 + + + @@ -83,16 +127,35 @@ import { Descriptions } from 'tiny-design'; | 属性 | 说明 | 类型 | 默认值 | | ------------- | --------------------------------------------- | ------------------------------------- | ------------- | -| title | 描述列表的标题 | ReactNode | - | +| title | 头部标题 | ReactNode | - | +| extra | 头部操作区 | ReactNode | - | +| footer | 底部内容 | ReactNode | - | | bordered | 是否显示边框 | boolean | false | -| column | 每行 `Descriptions.Items` 的数量 | number | 3 | -| size | 设置 `Descriptions` 的尺寸 | enum: `sm` | `md` | `lg` | `md` | +| columns | 逻辑列数或响应式列数配置 | `number` | `{ xs?; sm?; md?; lg?; xl?; xxl? }` | `3` | +| size | 组件尺寸 | enum: `sm` | `md` | `lg` | `md` | | layout | 描述布局方式 | enum: `horizontal` | `vertical` | `horizontal` | -| colon | 是否显示冒号 | boolean | - | +| colon | 是否显示冒号 | boolean | `!bordered` | +| separator | 自定义标签与内容之间的分隔符 | ReactNode | `:` 或无 | +| items | 数据驱动项 | `DescriptionsItemType[]` | - | +| empty | 空内容占位 | ReactNode | `-` | +| semantic | 语义渲染方式 | enum: `auto` | `table` | `list` | `auto` | +| labelAlign | 标签对齐方式 | enum: `start` | `center` | `end` | `start` | +| contentAlign | 内容对齐方式 | enum: `start` | `center` | `end` | `start` | +| labelRender | 自定义标签渲染 | `(item, index) => ReactNode` | - | +| contentRender | 自定义内容渲染 | `(item, index) => ReactNode` | - | ### Descriptions.Item | 属性 | 说明 | 类型 | 默认值 | | ------------- | ------------------------------------- | ----------------- | --------- | -| label | 内容的标签描述 | ReactNode | - | -| span | 包含的列数 | number | 1 | \ No newline at end of file +| label | 标签内容 | ReactNode | - | +| span | 跨越的逻辑列数 | `number` | `'fill'` | `1` | +| hidden | 是否隐藏该项 | boolean | false | +| extra | 标签旁的补充内容 | ReactNode | - | + +## 说明 + +- `columns` 表示逻辑列数,不是最终渲染出来的 DOM 单元格数量。 +- `span="fill"` 会占满当前行剩余空间。 +- 同时传入 `items` 和 `children` 时,优先使用 `items`。 +- `semantic="auto"` 会在 `bordered` 时使用 `table`,其他情况使用 `list`。 diff --git a/packages/react/src/descriptions/style/index.scss b/packages/react/src/descriptions/style/index.scss index 75f8ff908..a472fb882 100644 --- a/packages/react/src/descriptions/style/index.scss +++ b/packages/react/src/descriptions/style/index.scss @@ -1,38 +1,92 @@ @use "../../style/variables" as *; .#{$prefix}-descriptions { + display: flex; + flex-direction: column; + gap: 12px; + + &__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + } + &__body { width: 100%; - overflow: hidden; + min-width: 0; + } - > table { - width: 100%; - border-collapse: collapse; - table-layout: fixed; - box-sizing: border-box; - text-align: left; - background-color: transparent; - } + &__body > table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; + box-sizing: border-box; + text-align: left; + background-color: transparent; + } + + &__list { + display: grid; + gap: 12px 16px; + margin: 0; } &__title { text-align: left; - margin-bottom: var(--ty-descriptions-title-margin-bottom); color: var(--ty-descriptions-title-color); font-weight: var(--ty-descriptions-title-font-weight); font-size: var(--ty-descriptions-title-font-size); } - &__item_colon::after { - content: ':'; - margin-left: var(--ty-descriptions-item-colon-margin-start); - margin-right: var(--ty-descriptions-item-colon-margin-end); + &__extra { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + } + + &__footer { + padding-top: 4px; + border-top: 1px solid color-mix(in srgb, var(--ty-color-text-4) 18%, transparent); + } + + &__list-item { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 8px; + margin: 0; + padding-bottom: 12px; + border-bottom: 1px solid color-mix(in srgb, var(--ty-color-text-4) 16%, transparent); + } + + &__label-inner { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; + } + + &__separator { + color: color-mix(in srgb, var(--ty-descriptions-label-color) 72%, transparent); + } + + &__label-extra { + display: inline-flex; + align-items: center; + gap: 4px; + } + + &__item-label, + &__item-content { + margin: 0; } &__item-label { color: var(--ty-descriptions-label-color); font-size: var(--ty-descriptions-label-font-size); - font-weight: 400; + font-weight: 500; line-height: var(--ty-descriptions-label-line-height); } @@ -40,76 +94,139 @@ color: var(--ty-descriptions-content-color); font-size: var(--ty-descriptions-content-font-size); line-height: var(--ty-descriptions-content-line-height); + min-width: 0; + word-break: break-word; } - &_bordered { - .#{$prefix}-descriptions { - &__body { - border: 1px solid var(--ty-descriptions-border); - border-radius: var(--ty-descriptions-radius); - } + &_vertical { + .#{$prefix}-descriptions__header { + align-items: center; + } + } - &__row { - border-bottom: 1px solid var(--ty-descriptions-border); + &_table { + .#{$prefix}-descriptions__body { + overflow: hidden; + } - &:last-child { - border-bottom: 0; - } - } + .#{$prefix}-descriptions__body > table { + table-layout: auto; + } - &__item-label { - background-color: var(--ty-descriptions-label-bg); - } + .#{$prefix}-descriptions__cell { + padding: 0; + vertical-align: top; + text-align: left; + } - &__item-label, &__item-content { - border-right: 1px solid var(--ty-descriptions-border); + .#{$prefix}-descriptions__item-label { + width: 18%; + min-width: 108px; + white-space: normal; + } + } - &:last-child { - border-right: 0; - } - } + &_bordered.#{$prefix}-descriptions_table { + .#{$prefix}-descriptions__body { + border: 1px solid var(--ty-descriptions-border); + border-radius: var(--ty-descriptions-radius); + } + + .#{$prefix}-descriptions__row:not(:last-child) { + border-bottom: 1px solid var(--ty-descriptions-border); + } + + .#{$prefix}-descriptions__item-label { + background-color: var(--ty-descriptions-label-bg); + } + + .#{$prefix}-descriptions__cell { + padding: var(--ty-descriptions-md-padding-vt) var(--ty-descriptions-md-padding-hr); + border-right: 1px solid var(--ty-descriptions-border); + } + + .#{$prefix}-descriptions__row > :last-child { + border-right: 0; + } + } + + &_bordered.#{$prefix}-descriptions_list { + .#{$prefix}-descriptions__list-item { + padding: 14px 16px; + border: 1px solid var(--ty-descriptions-border); + border-radius: calc(var(--ty-descriptions-radius) - 2px); + } + } + + &_label-center { + .#{$prefix}-descriptions__item-label { + text-align: center; + } + } + + &_label-end { + .#{$prefix}-descriptions__item-label { + text-align: right; + } + } + + &_content-center { + .#{$prefix}-descriptions__item-content { + text-align: center; + } + } + + &_content-end { + .#{$prefix}-descriptions__item-content { + text-align: right; } } &_sm { - .#{$prefix}-descriptions__item { + .#{$prefix}-descriptions__list { + gap: 8px 12px; + } + + .#{$prefix}-descriptions__list-item { + gap: 8px 12px; padding-bottom: var(--ty-descriptions-sm-padding-vt); } - &.#{$prefix}-descriptions_bordered { - .#{$prefix}-descriptions { - &__item-label, &__item-content { - padding: var(--ty-descriptions-sm-padding-vt) var(--ty-descriptions-sm-padding-hr); - } + &.#{$prefix}-descriptions_bordered.#{$prefix}-descriptions_table { + .#{$prefix}-descriptions__cell { + padding: var(--ty-descriptions-sm-padding-vt) var(--ty-descriptions-sm-padding-hr); } } } &_md { - .#{$prefix}-descriptions__item { + .#{$prefix}-descriptions__list-item { padding-bottom: var(--ty-descriptions-md-padding-vt); } - &.#{$prefix}-descriptions_bordered { - .#{$prefix}-descriptions { - &__item-label, &__item-content { - padding: var(--ty-descriptions-md-padding-vt) var(--ty-descriptions-md-padding-hr); - } + &.#{$prefix}-descriptions_bordered.#{$prefix}-descriptions_table { + .#{$prefix}-descriptions__cell { + padding: var(--ty-descriptions-md-padding-vt) var(--ty-descriptions-md-padding-hr); } } } &_lg { - .#{$prefix}-descriptions__item { + .#{$prefix}-descriptions__list-item { padding-bottom: var(--ty-descriptions-lg-padding-vt); } - &.#{$prefix}-descriptions_bordered { - .#{$prefix}-descriptions { - &__item-label, &__item-content { - padding: var(--ty-descriptions-lg-padding-vt) var(--ty-descriptions-lg-padding-hr); - } + &.#{$prefix}-descriptions_bordered.#{$prefix}-descriptions_table { + .#{$prefix}-descriptions__cell { + padding: var(--ty-descriptions-lg-padding-vt) var(--ty-descriptions-lg-padding-hr); } } } + + @media (width <= 599px) { + &__header { + flex-direction: column; + align-items: stretch; + } + } } diff --git a/packages/react/src/descriptions/types.ts b/packages/react/src/descriptions/types.ts index 5390dbb36..0e4b603c4 100644 --- a/packages/react/src/descriptions/types.ts +++ b/packages/react/src/descriptions/types.ts @@ -1,19 +1,45 @@ import React from 'react'; import { BaseProps, DirectionType, SizeType } from '../_utils/props'; +import { ResponsiveValue } from '../grid/responsive'; + +export type DescriptionsSpan = number | 'fill'; +export type DescriptionsSemantic = 'auto' | 'table' | 'list'; +export type DescriptionsAlign = 'start' | 'center' | 'end'; + +export interface DescriptionsItemType extends BaseProps { + key?: React.Key; + label: React.ReactNode; + content?: React.ReactNode; + span?: DescriptionsSpan; + hidden?: boolean; + extra?: React.ReactNode; +} export interface DescriptionsProps extends BaseProps, Omit, 'title'> { title?: React.ReactNode; + extra?: React.ReactNode; + footer?: React.ReactNode; bordered?: boolean; - column?: number; + columns?: ResponsiveValue; size?: SizeType; layout?: DirectionType; colon?: boolean; + separator?: React.ReactNode; + items?: DescriptionsItemType[]; + empty?: React.ReactNode; + semantic?: DescriptionsSemantic; + labelAlign?: DescriptionsAlign; + contentAlign?: DescriptionsAlign; + labelRender?: (item: DescriptionsItemType, index: number) => React.ReactNode; + contentRender?: (item: DescriptionsItemType, index: number) => React.ReactNode; } export interface DescriptionsItemProps extends BaseProps { - label?: React.ReactNode; - span?: number; - children: React.ReactNode; + label: React.ReactNode; + span?: DescriptionsSpan; + hidden?: boolean; + extra?: React.ReactNode; + children?: React.ReactNode; }