diff --git a/.changeset/feat-collapse-redesign.md b/.changeset/feat-collapse-redesign.md
index 5dd7be66..1c6f47b9 100644
--- a/.changeset/feat-collapse-redesign.md
+++ b/.changeset/feat-collapse-redesign.md
@@ -1,6 +1,9 @@
---
'@tiny-design/react': minor
+'@tiny-design/icons': minor
'@tiny-design/tokens': minor
+'@tiny-design/charts': minor
---
-Redesign the Collapse component API, styles, and docs, and align the related tokens.
+Redesign the Collapse component API, styles, and docs, align the related tokens, and keep
+the fixed-version package group in sync for release.
diff --git a/apps/docs/public/llms-full.txt b/apps/docs/public/llms-full.txt
index 46653342..ac9c1c6a 100644
--- a/apps/docs/public/llms-full.txt
+++ b/apps/docs/public/llms-full.txt
@@ -1712,21 +1712,31 @@ import React from 'react';
import { BaseProps, SizeType } from '../_utils/props';
export interface SegmentedOption {
- label: React.ReactNode;
- value: string | number;
+ value: SegmentedValue;
+ label?: React.ReactNode;
disabled?: boolean;
icon?: React.ReactNode;
+ title?: string;
+ className?: string;
}
export type SegmentedValue = string | number;
export interface SegmentedProps
extends BaseProps,
- Omit, 'onChange'> {
- options: (string | number | SegmentedOption)[];
+ Omit<
+ React.PropsWithoutRef,
+ 'children' | 'defaultValue' | 'onChange'
+ > {
+ options: SegmentedOption[];
+ name?: string;
value?: SegmentedValue;
defaultValue?: SegmentedValue;
- onChange?: (value: SegmentedValue) => void;
+ onChange?: (
+ value: SegmentedValue,
+ option: SegmentedOption,
+ event: React.ChangeEvent
+ ) => void;
block?: boolean;
disabled?: boolean;
size?: SizeType;
diff --git a/apps/docs/public/llms.txt b/apps/docs/public/llms.txt
index d0063c96..e6e827e4 100644
--- a/apps/docs/public/llms.txt
+++ b/apps/docs/public/llms.txt
@@ -121,7 +121,7 @@ Components that accept sizes use: `'sm' | 'md' | 'lg'`
- **NativeSelect** — Native HTML select wrapper.
- **Radio** — Single selection. `Radio.Group` for groups.
- **Rate** — Star rating component.
-- **Segmented** — Toggle between a set of options.
+- **Segmented** — Segmented single-choice control for switching between mutually exclusive options.
- **Select** — Select value from dropdown options. Props: `options`, `mode` (`'multiple'|'tags'`), `searchable`.
- **Slider** — Drag slider within range. Props: `min`, `max`, `step`, `range`.
- **SplitButton** — Button with attached dropdown menu.
diff --git a/apps/docs/src/containers/theme-studio/theme-document-adapter.ts b/apps/docs/src/containers/theme-studio/theme-document-adapter.ts
index 78c44e46..3eff1c89 100644
--- a/apps/docs/src/containers/theme-studio/theme-document-adapter.ts
+++ b/apps/docs/src/containers/theme-studio/theme-document-adapter.ts
@@ -279,7 +279,11 @@ export function buildThemeDocumentFromDraft(draft: ThemeEditorDraft): ThemeDocum
'input-number.height.md': fields.fieldHeightMd,
'input-number.height.lg': fields.fieldHeightLg,
'segmented.bg': fields.muted,
- 'segmented.active-bg': fields.card,
+ 'segmented.item-bg-hover': fields.secondary,
+ 'segmented.item-bg-selected': fields.card,
+ 'segmented.item-color': fields.mutedForeground,
+ 'segmented.item-color-selected': fields.baseForeground,
+ 'segmented.item-shadow-focus': fields.shadowFocus,
'segmented.radius': fields.radius,
'tag.bg': fields.secondary,
'tag.color': fields.secondaryForeground,
diff --git a/packages/mcp/src/data/components.json b/packages/mcp/src/data/components.json
index 4fd7766d..30b5149f 100644
--- a/packages/mcp/src/data/components.json
+++ b/packages/mcp/src/data/components.json
@@ -1581,32 +1581,62 @@
"name": "Collapse",
"dirName": "collapse",
"category": "Data Display",
- "description": "A content area which can be collapsed and expanded.",
- "descriptionZh": "可以折叠/展开的内容区域。",
+ "description": "Structured disclosure for dense information.",
+ "descriptionZh": "用于组织密集信息的分层展开组件。",
"props": [
{
- "name": "defaultActiveKey",
- "type": "string | string[]",
+ "name": "items",
+ "type": "CollapseItem[]",
+ "required": true,
+ "description": ""
+ },
+ {
+ "name": "value",
+ "type": "string[]",
"required": false,
"description": ""
},
{
- "name": "activeKey",
- "type": "string | string[]",
+ "name": "defaultValue",
+ "type": "string[]",
+ "required": false,
+ "description": ""
+ },
+ {
+ "name": "onValueChange",
+ "type": "(value: CollapseValue) => void",
"required": false,
"description": ""
},
{
- "name": "accordion",
+ "name": "multiple",
"type": "boolean",
"required": false,
- "description": "Only open one panel"
+ "description": ""
},
{
- "name": "deletable",
+ "name": "bordered",
"type": "boolean",
"required": false,
- "description": "Allow to delete"
+ "description": ""
+ },
+ {
+ "name": "size",
+ "type": "SizeType",
+ "required": false,
+ "description": ""
+ },
+ {
+ "name": "expandIcon",
+ "type": "ReactNode | CollapseExpandIconRender",
+ "required": false,
+ "description": ""
+ },
+ {
+ "name": "expandIconPosition",
+ "type": "'start' | 'end'",
+ "required": false,
+ "description": ""
},
{
"name": "showArrow",
@@ -1615,14 +1645,56 @@
"description": ""
},
{
- "name": "bordered",
+ "name": "disabled",
"type": "boolean",
"required": false,
"description": ""
},
{
- "name": "onChange",
- "type": "(keys: string | string[]) => void",
+ "name": "collapsible",
+ "type": "'header' | 'icon' | 'disabled'",
+ "required": false,
+ "description": ""
+ },
+ {
+ "name": "destroyOnHidden",
+ "type": "boolean",
+ "required": false,
+ "description": ""
+ },
+ {
+ "name": "forceRender",
+ "type": "boolean",
+ "required": false,
+ "description": ""
+ },
+ {
+ "name": "itemClassName",
+ "type": "string",
+ "required": false,
+ "description": ""
+ },
+ {
+ "name": "itemStyle",
+ "type": "CSSProperties",
+ "required": false,
+ "description": ""
+ },
+ {
+ "name": "headerClassName",
+ "type": "string",
+ "required": false,
+ "description": ""
+ },
+ {
+ "name": "bodyClassName",
+ "type": "string",
+ "required": false,
+ "description": ""
+ },
+ {
+ "name": "onItemClick",
+ "type": "(key: string, event: React.MouseEvent) => void",
"required": false,
"description": ""
},
@@ -1648,27 +1720,27 @@
"demos": [
{
"name": "Accordion",
- "code": "import React from 'react';\nimport { Collapse } from '@tiny-design/react';\n\nexport default function AccordionDemo() {\n const { Panel } = Collapse;\n\n const text = `A dog is a type of domesticated animal.\nKnown for its loyalty and faithfulness,\nit can be found as a welcome guest in many households across the world.`;\n\n return (\n \n \n {text}
\n \n \n {text}
\n \n \n {text}
\n \n \n );\n}\n"
+ "code": "import React from 'react';\nimport { Collapse } from '@tiny-design/react';\n\nconst text = `A dog is a type of domesticated animal.\nKnown for its loyalty and faithfulness,\nit can be found as a welcome guest in many households across the world.`;\n\nexport default function AccordionDemo() {\n return (\n {text}
,\n },\n {\n key: 'chapter-2',\n label: 'Chapter 2',\n children: {text}
,\n },\n {\n key: 'chapter-3',\n label: 'Chapter 3',\n children: {text}
,\n },\n ]}\n />\n );\n}\n"
},
{
"name": "Basic",
- "code": "import React from 'react';\nimport { Collapse } from '@tiny-design/react';\n\nexport default function BasicDemo() {\n const { Panel } = Collapse;\n\n const callback = (key: string | string[]) => {\n console.log(key);\n };\n\n const text = `A dog is a type of domesticated animal.\nKnown for its loyalty and faithfulness,\nit can be found as a welcome guest in many households across the world.`;\n\n return (\n \n \n {text}
\n \n \n {text}
\n \n \n {text}
\n \n \n );\n}\n"
+ "code": "import React from 'react';\nimport { Collapse } from '@tiny-design/react';\n\nconst text = `A dog is a type of domesticated animal.\nKnown for its loyalty and faithfulness,\nit can be found as a welcome guest in many households across the world.`;\n\nexport default function BasicDemo() {\n return (\n {text},\n },\n {\n key: 'details',\n label: 'Details',\n children: {text}
,\n },\n {\n key: 'disabled',\n label: 'Disabled panel',\n disabled: true,\n children: {text}
,\n },\n ]}\n />\n );\n}\n"
},
{
"name": "Borderless",
- "code": "import React from 'react';\nimport { Collapse } from '@tiny-design/react';\n\nexport default function BorderlessDemo() {\n const { Panel } = Collapse;\n\n const text = `A dog is a type of domesticated animal.\nKnown for its loyalty and faithfulness,\nit can be found as a welcome guest in many households across the world.`;\n\n return (\n \n \n {text}
\n \n \n {text}
\n \n \n {text}
\n \n \n );\n}"
+ "code": "import React from 'react';\nimport { Collapse, Tag } from '@tiny-design/react';\n\nexport default function BorderlessDemo() {\n return (\n Stable,\n children: 'Borderless mode keeps the layout lighter when Collapse is used inside cards or side panels.',\n },\n {\n key: 'spacing',\n label: 'Spacing rhythm',\n children: 'Size presets affect both the trigger row and the content body spacing.',\n },\n {\n key: 'motion',\n label: 'Motion tokens',\n children: 'Motion is driven by the shared transition token instead of a hard-coded timeout.',\n },\n ]}\n />\n );\n}\n"
},
{
"name": "Deletable",
- "code": "import React from 'react';\nimport { Collapse } from '@tiny-design/react';\n\nexport default function DeletableDemo() {\n const { Panel } = Collapse;\n\n const text = `A dog is a type of domesticated animal.\nKnown for its loyalty and faithfulness,\nit can be found as a welcome guest in many households across the world.`;\n\n return (\n \n \n {text}
\n \n \n {text}
\n \n \n {text}
\n \n \n );\n}"
+ "code": "import React from 'react';\nimport { Collapse, Button } from '@tiny-design/react';\n\ntype DemoItem = {\n key: string;\n label: string;\n children: string;\n};\n\nexport default function DeletableDemo() {\n const [items, setItems] = React.useState([\n {\n key: 'architecture',\n label: 'Architecture',\n children: 'Use controlled state to remove items from the source array instead of mutating DOM nodes.',\n },\n {\n key: 'performance',\n label: 'Performance',\n children: 'The active value is filtered alongside the item list so removed panels do not leave stale state behind.',\n },\n {\n key: 'preload',\n label: 'Preload',\n children: 'This pattern works with any async source because the Collapse only consumes items data.',\n },\n ]);\n const [value, setValue] = React.useState(['architecture', 'performance']);\n\n const removeItem = (key: string) => {\n setItems((currentItems) => currentItems.filter((item) => item.key !== key));\n setValue((currentValue) => currentValue.filter((activeKey) => activeKey !== key));\n };\n\n const resetItems = () => {\n setItems([\n {\n key: 'architecture',\n label: 'Architecture',\n children: 'Use controlled state to remove items from the source array instead of mutating DOM nodes.',\n },\n {\n key: 'performance',\n label: 'Performance',\n children: 'The active value is filtered alongside the item list so removed panels do not leave stale state behind.',\n },\n {\n key: 'preload',\n label: 'Preload',\n children: 'This pattern works with any async source because the Collapse only consumes items data.',\n },\n ]);\n setValue(['architecture', 'performance']);\n };\n\n return (\n <>\n \n Reset panels\n \n
\n ({\n ...item,\n extra: (\n {\n event.stopPropagation();\n removeItem(item.key);\n }}\n >\n Delete\n \n ),\n }))}\n />\n >\n );\n}\n"
},
{
"name": "Extra",
- "code": "import React from 'react';\nimport { Collapse, Button, Badge, Tag } from '@tiny-design/react';\n\nexport default function ExtraDemo() {\n const { Panel } = Collapse;\n\n return (\n \n {\n e.stopPropagation();\n alert('Settings clicked');\n }}\n >\n Settings\n \n }\n >\n Panel content with an extra action button.\n \n }\n >\n Panel content with a badge indicator.\n \n New}\n >\n Panel content with a tag.\n \n \n );\n}"
+ "code": "import React from 'react';\nimport { Collapse, Badge, Button, Tag } from '@tiny-design/react';\n\nexport default function ExtraDemo() {\n return (\n `Recent activity${active ? ' opened' : ''}`,\n extra: ,\n children: 'Header and extra content can be rendered from the item definition without composition wrappers.',\n },\n {\n key: 'release',\n label: 'Release notes',\n extra: ({ active }) => {active ? 'Live' : 'Draft'} ,\n children: 'Both label and extra accept render functions that receive the active and disabled state.',\n },\n {\n key: 'settings',\n label: 'Panel with action',\n extra: (\n {\n event.stopPropagation();\n alert('Settings clicked');\n }}\n >\n Settings\n \n ),\n children: 'Interactive controls inside extra should stop propagation if they should not toggle the panel.',\n },\n ]}\n />\n );\n}\n"
},
{
"name": "Nested",
- "code": "import React from 'react';\nimport { Collapse } from '@tiny-design/react';\n\nexport default function NestedDemo() {\n const { Panel } = Collapse;\n\n const text = `A dog is a type of domesticated animal.\nKnown for its loyalty and faithfulness,\nit can be found as a welcome guest in many households across the world.`;\n\n return (\n \n \n \n \n {text}
\n \n \n \n \n {text}
\n \n \n {text}
\n \n \n );\n}"
+ "code": "import React from 'react';\nimport { Collapse } from '@tiny-design/react';\n\nexport default function NestedDemo() {\n return (\n \n ),\n },\n {\n key: 'parent-2',\n label: 'Independent panel',\n children: 'Nested content no longer relies on child index cloning, so ordering stays stable.',\n },\n ]}\n />\n );\n}\n"
}
]
},
@@ -2705,7 +2777,7 @@
"demos": [
{
"name": "Align",
- "code": "import React from 'react';\nimport { Flex, Segmented, Button } from '@tiny-design/react';\nimport type { SegmentedValue } from '@tiny-design/react';\n\nexport default function AlignDemo() {\n const justifyOptions = [\n 'flex-start',\n 'center',\n 'flex-end',\n 'space-between',\n 'space-around',\n 'space-evenly',\n ];\n const alignOptions = ['flex-start', 'center', 'flex-end'];\n\n const [justify, setJustify] = React.useState('flex-start');\n const [align, setAlign] = React.useState('flex-start');\n\n const boxStyle = {\n width: '100%',\n height: 120,\n borderRadius: 6,\n border: '1px solid var(--ty-color-primary)',\n };\n\n return (\n \n Select justify: \n setJustify(String(val))}\n />\n Select align: \n setAlign(String(val))}\n />\n \n \n Primary\n \n \n Primary\n \n \n Primary\n \n \n Primary\n \n \n \n );\n}\n"
+ "code": "import React from 'react';\nimport { Flex, Segmented, Button } from '@tiny-design/react';\nimport type { SegmentedValue } from '@tiny-design/react';\n\nexport default function AlignDemo() {\n const justifyOptions = [\n { label: 'flex-start', value: 'flex-start' },\n { label: 'center', value: 'center' },\n { label: 'flex-end', value: 'flex-end' },\n { label: 'space-between', value: 'space-between' },\n { label: 'space-around', value: 'space-around' },\n { label: 'space-evenly', value: 'space-evenly' },\n ];\n const alignOptions = [\n { label: 'flex-start', value: 'flex-start' },\n { label: 'center', value: 'center' },\n { label: 'flex-end', value: 'flex-end' },\n ];\n\n const [justify, setJustify] = React.useState('flex-start');\n const [align, setAlign] = React.useState('flex-start');\n\n const boxStyle = {\n width: '100%',\n height: 120,\n borderRadius: 6,\n border: '1px solid var(--ty-color-primary)',\n };\n\n return (\n \n Select justify: \n setJustify(String(val))}\n />\n Select align: \n setAlign(String(val))}\n />\n \n \n Primary\n \n \n Primary\n \n \n Primary\n \n \n Primary\n \n \n \n );\n}\n"
},
{
"name": "Basic",
@@ -3026,57 +3098,37 @@
}
],
"demos": [
- {
- "name": "Alignment",
- "code": "import React from 'react';\nimport { Row, Col, Divider } from '@tiny-design/react';\n\nexport default function AlignmentDemo() {\n const row: React.CSSProperties = {\n padding: '10px 0',\n margin: '16px 0',\n background: 'rgba(128, 128, 128, 0.08)',\n };\n\n const box: React.CSSProperties = {\n color: '#fff',\n textAlign: 'center',\n };\n\n const box100 = {\n height: 100,\n lineHeight: '100px',\n };\n\n const box50 = {\n height: 50,\n lineHeight: '50px',\n };\n\n const box120 = {\n height: 120,\n lineHeight: '120px',\n };\n\n const box80 = {\n height: 80,\n lineHeight: '80px',\n };\n\n const lighterBox = {\n backgroundColor: 'color-mix(in srgb, var(--ty-color-primary) 84%, transparent)',\n };\n\n const darkerBox = {\n backgroundColor: 'color-mix(in srgb, var(--ty-color-primary) 98%, transparent)',\n };\n\n return (\n <>\n Align Top \n \n col-4
\n col-4
\n col-4
\n col-4
\n
\n\n Align Center \n \n col-4
\n col-4
\n col-4
\n col-4
\n
\n\n Align Bottom \n \n col-4
\n col-4
\n col-4
\n col-4
\n
\n >\n );\n}\n"
- },
{
"name": "AlignmentGrid",
- "code": "import React from 'react';\nimport { Button, Grid } from '@tiny-design/react';\n\nexport default function AlignmentGridDemo() {\n return (\n \n \n Primary\n \n Default \n \n Outline\n \n \n );\n}\n"
+ "code": "import React from 'react';\nimport { Grid, Radio } from '@tiny-design/react';\nimport { DemoControlLabel, DemoControls, getDemoBlockStyle } from './shared';\n\ntype AlignmentValue = 'start' | 'center' | 'end' | 'stretch';\n\nexport default function AlignmentGridDemo() {\n const [justify, setJustify] = React.useState('stretch');\n const [align, setAlign] = React.useState('stretch');\n\n return (\n \n
\n \n Justify \n setJustify(val as AlignmentValue)}>\n start \n center \n end \n stretch \n \n
\n \n Align \n setAlign(val as AlignmentValue)}>\n start \n center \n end \n stretch \n \n
\n \n
\n \n {justify}\n
\n \n {align}\n
\n \n items\n
\n \n
\n );\n}\n"
},
{
"name": "AutoFit",
- "code": "import React from 'react';\nimport { Card, Grid } from '@tiny-design/react';\n\nexport default function AutoFitDemo() {\n return (\n \n {['Analytics', 'Revenue', 'Orders', 'Retention', 'Conversion'].map((title) => (\n \n \n {title} \n Auto-fit cards without manual breakpoints.
\n \n \n ))}\n \n );\n}\n"
- },
- {
- "name": "Basic",
- "code": "import React from 'react';\nimport { Row, Col } from '@tiny-design/react';\n\nexport default function BasicDemo() {\n const row: React.CSSProperties = {\n margin: '16px 0',\n };\n\n const box: React.CSSProperties = {\n padding: '20px 0',\n color: '#fff',\n textAlign: 'center',\n };\n\n const lighterBox = {\n backgroundColor: 'color-mix(in srgb, var(--ty-color-primary) 84%, transparent)',\n };\n\n const darkerBox = {\n backgroundColor: 'color-mix(in srgb, var(--ty-color-primary) 98%, transparent)',\n };\n\n return (\n <>\n \n col
\n
\n \n col-12
\n col-12
\n
\n \n col-8
\n col-8
\n col-8
\n
\n \n col-6
\n col-6
\n col-6
\n col-6
\n
\n >\n );\n}\n"
+ "code": "import React from 'react';\nimport { Checkbox, Grid, Slider } from '@tiny-design/react';\nimport type { SliderValue } from '@tiny-design/react';\nimport { DemoBlock, DemoControlLabel, DemoControls } from './shared';\n\nexport default function AutoFitDemo() {\n const [minColumnWidth, setMinColumnWidth] = React.useState(180);\n const [autoFit, setAutoFit] = React.useState(true);\n const [count, setCount] = React.useState(5);\n\n return (\n \n
\n \n Min column width: {minColumnWidth}px \n {\n if (typeof val === 'number') {\n setMinColumnWidth(val);\n }\n }}\n />\n
\n \n Item count: {count} \n {\n if (typeof val === 'number') {\n setCount(val);\n }\n }}\n />\n
\n \n ) => setAutoFit(e.currentTarget.checked)}>\n Use auto-fit\n \n
\n \n
\n {[\n ['Analytics', `${autoFit ? 'auto-fit' : 'auto-fill'} / ${minColumnWidth}px`],\n ['Revenue', 'responsive tracks'],\n ['Orders', 'reflow'],\n ['Retention', 'no breakpoints'],\n ['Conversion', 'fluid blocks'],\n ['Traffic', 'auto placement'],\n ['Pipeline', 'repeat tracks'],\n ['Forecast', 'adaptive cells'],\n ].slice(0, count).map(([title, detail], index) => (\n \n ))}\n \n
\n );\n}\n"
},
{
"name": "DashboardShell",
- "code": "import React from 'react';\nimport { Button, Card, Divider, Grid, Progress, Tag, Text } from '@tiny-design/react';\n\nconst shellCardStyle: React.CSSProperties = {\n padding: 16,\n minHeight: 96,\n display: 'flex',\n flexDirection: 'column',\n justifyContent: 'space-between',\n};\n\nconst metricStyle: React.CSSProperties = {\n ...shellCardStyle,\n minHeight: 144,\n background:\n 'linear-gradient(180deg, color-mix(in srgb, var(--ty-color-primary) 10%, transparent), color-mix(in srgb, var(--ty-color-primary-bg) 70%, transparent))',\n};\n\nconst sectionLabelStyle: React.CSSProperties = {\n display: 'inline-flex',\n alignItems: 'center',\n gap: 6,\n};\n\nexport default function DashboardShellDemo() {\n return (\n \n \n \n \n
\n
Dashboard Header \n
\n Top-level shell area using `grid-template-areas`. \n
\n
\n
\n Refresh\n \n
\n \n \n Healthy\n \n \n 24h window\n \n \n 3 alerts\n \n
\n \n \n\n \n \n \n \n \n Revenue \n \n +12%\n \n
\n \n \n \n \n \n \n Conversion \n \n +1.8%\n \n
\n \n \n \n \n \n\n \n \n Filters \n \n Region: APAC \n Plan: Pro \n Channel: Web \n
\n \n \n\n \n \n \n Chart Area \n Last 7 days \n
\n \n
\n
\n
\n
\n
\n
\n \n Wide content region spanning two columns on desktop. \n \n \n\n \n \n Activity \n \n
\n New signups \n 128 \n
\n
\n
\n Trial started \n 42 \n
\n
\n
\n Churn risk \n 9 \n
\n
\n \n \n \n );\n}\n"
+ "code": "import React from 'react';\nimport { Checkbox, Grid, Slider } from '@tiny-design/react';\nimport type { SliderValue } from '@tiny-design/react';\nimport { DemoBlock, DemoControlLabel, DemoControls, getDemoBlockStyle } from './shared';\n\nexport default function DashboardShellDemo() {\n const [gap, setGap] = React.useState(16);\n const [showFilters, setShowFilters] = React.useState(true);\n\n return (\n \n
\n \n Desktop gap: {gap}px \n {\n if (typeof val === 'number') {\n setGap(val);\n }\n }}\n />\n
\n \n ) => setShowFilters(e.currentTarget.checked)}>\n Show filters area\n \n
\n \n
\n \n \n \n\n \n \n \n \n \n \n \n \n \n \n\n {showFilters ? (\n \n \n \n ) : null}\n\n \n \n
Chart \n
\n {[38, 60, 50, 82, 56, 72].map((height, index) => (\n
\n ))}\n \n
wide area spanning two columns \n
\n \n\n \n \n \n \n
\n );\n}\n"
},
{
"name": "ExplicitColumns",
- "code": "import React from 'react';\nimport { Card, Grid, Text } from '@tiny-design/react';\n\nconst panelStyle: React.CSSProperties = {\n minHeight: 104,\n padding: 16,\n display: 'flex',\n flexDirection: 'column',\n justifyContent: 'space-between',\n background: 'linear-gradient(180deg, color-mix(in srgb, var(--ty-color-primary) 12%, transparent), color-mix(in srgb, var(--ty-color-primary-bg) 70%, transparent))',\n};\n\nexport default function ExplicitColumnsDemo() {\n return (\n \n \n Sidebar \n Fixed 220px track \n \n \n Main content \n Fluid `minmax(0, 1fr)` track \n \n \n Inspector \n Fixed 180px track \n \n \n );\n}\n"
- },
- {
- "name": "Gutter",
- "code": "import React from 'react';\nimport { Row, Col, Slider } from '@tiny-design/react';\nimport type { SliderValue } from '@tiny-design/react';\n\nexport default function GutterDemo() {\n const box: React.CSSProperties = {\n padding: '20px 0',\n color: '#fff',\n textAlign: 'center',\n };\n\n const lighterBox = {\n backgroundColor: 'color-mix(in srgb, var(--ty-color-primary) 84%, transparent)',\n };\n\n const darkerBox = {\n backgroundColor: 'color-mix(in srgb, var(--ty-color-primary) 98%, transparent)',\n };\n\n const [gutter, setGutter] = React.useState(8);\n\n return (\n <>\n Gutter Size:
\n {\n if (typeof val === 'number') {\n setGutter(val);\n }\n }}\n style={{ width: 300 }}\n />\n \n col-6
\n col-6
\n col-6
\n col-6
\n
\n >\n );\n}\n"
+ "code": "import React from 'react';\nimport { Grid, Slider } from '@tiny-design/react';\nimport type { SliderValue } from '@tiny-design/react';\nimport { DemoBlock, DemoControlLabel, DemoControls } from './shared';\n\nexport default function ExplicitColumnsDemo() {\n const [sidebarWidth, setSidebarWidth] = React.useState(220);\n const [inspectorWidth, setInspectorWidth] = React.useState(180);\n\n return (\n \n
\n \n Sidebar: {sidebarWidth}px \n {\n if (typeof val === 'number') {\n setSidebarWidth(val);\n }\n }}\n />\n
\n \n Inspector: {inspectorWidth}px \n {\n if (typeof val === 'number') {\n setInspectorWidth(val);\n }\n }}\n />\n
\n \n
\n \n \n \n \n
\n );\n}\n"
},
{
"name": "NamedAreas",
- "code": "import React from 'react';\nimport { Card, Grid, Text } from '@tiny-design/react';\n\nconst panelStyle: React.CSSProperties = {\n padding: 16,\n minHeight: 96,\n display: 'flex',\n flexDirection: 'column',\n justifyContent: 'space-between',\n};\n\nexport default function NamedAreasDemo() {\n return (\n \n \n \n Hero \n grid-area: hero \n \n \n \n \n Sidebar \n grid-area: side \n \n \n \n \n Content \n grid-area: content \n \n \n \n );\n}\n"
- },
- {
- "name": "Offset",
- "code": "import React from 'react';\nimport { Row, Col } from '@tiny-design/react';\n\nexport default function OffsetDemo() {\n const row: React.CSSProperties = {\n margin: '16px 0',\n };\n\n const box: React.CSSProperties = {\n padding: '20px 0',\n color: '#fff',\n textAlign: 'center',\n };\n\n const lighterBox = {\n backgroundColor: 'color-mix(in srgb, var(--ty-color-primary) 84%, transparent)',\n };\n\n const darkerBox = {\n backgroundColor: 'color-mix(in srgb, var(--ty-color-primary) 98%, transparent)',\n };\n\n return (\n <>\n \n col-12 col-offset-6
\n
\n \n col-8
\n col-8 col-offset-8
\n
\n \n col-6 col-offset-6
\n col-6 col-offset-6
\n
\n >\n );\n}\n"
+ "code": "import React from 'react';\nimport { Grid, Radio } from '@tiny-design/react';\nimport { DemoBlock, DemoControlLabel, DemoControls } from './shared';\n\nexport default function NamedAreasDemo() {\n const [layout, setLayout] = React.useState<'right-rail' | 'header-band' | 'stacked'>('right-rail');\n\n const templates = {\n 'right-rail': {\n columns: { xs: 1, md: 3 },\n areas: {\n xs: ['hero', 'side', 'content'],\n md: ['hero hero side', 'content content side'],\n },\n },\n 'header-band': {\n columns: { xs: 1, md: 3 },\n areas: {\n xs: ['hero', 'content', 'side'],\n md: ['hero hero hero', 'content content side'],\n },\n },\n stacked: {\n columns: { xs: 1, md: 2 },\n areas: {\n xs: ['hero', 'content', 'side'],\n md: ['hero hero', 'content content', 'side side'],\n },\n },\n } as const;\n\n const current = templates[layout];\n\n return (\n \n
\n \n Area template \n setLayout(val as 'right-rail' | 'header-band' | 'stacked')}>\n right-rail \n header-band \n stacked \n \n
\n \n
\n \n \n \n \n \n \n \n \n \n \n
\n );\n}\n"
},
{
"name": "OffsetAuto",
- "code": "import React from 'react';\nimport { Card, Grid, Text } from '@tiny-design/react';\n\nconst Item = ({\n title,\n desc,\n}: {\n title: string;\n desc: string;\n}) => (\n \n {title} \n \n {desc} \n
\n \n);\n\nexport default function OffsetAutoDemo() {\n return (\n \n \n \n \n \n \n \n \n );\n}\n"
- },
- {
- "name": "Order",
- "code": "import React from 'react';\nimport { Row, Col } from '@tiny-design/react';\n\nexport default function OrderDemo() {\n const box: React.CSSProperties = {\n padding: '20px 0',\n color: '#fff',\n textAlign: 'center',\n };\n\n const lighterBox = {\n backgroundColor: 'color-mix(in srgb, var(--ty-color-primary) 84%, transparent)',\n };\n\n const darkerBox = {\n backgroundColor: 'color-mix(in srgb, var(--ty-color-primary) 98%, transparent)',\n };\n\n return (\n <>\n \n 1 col-order-4
\n 2 col-order-4
\n 3 col-order-4
\n 4 col-order-4
\n
\n >\n );\n}\n"
+ "code": "import React from 'react';\nimport { Checkbox, Grid, Slider } from '@tiny-design/react';\nimport type { SliderValue } from '@tiny-design/react';\nimport { DemoBlock, DemoControlLabel, DemoControls } from './shared';\n\nexport default function OffsetAutoDemo() {\n const [leftSize, setLeftSize] = React.useState(3);\n const [rightSize, setRightSize] = React.useState(3);\n const [useAutoOffset, setUseAutoOffset] = React.useState(true);\n\n return (\n \n
\n \n Left size: {leftSize} \n {\n if (typeof val === 'number') {\n setLeftSize(val);\n }\n }}\n />\n
\n \n Right size: {rightSize} \n {\n if (typeof val === 'number') {\n setRightSize(val);\n }\n }}\n />\n
\n \n ) => setUseAutoOffset(e.currentTarget.checked)}>\n Use `offset="auto"`\n \n
\n \n
\n \n \n \n \n \n \n \n
\n );\n}\n"
},
{
- "name": "Responsive",
- "code": "import React from 'react';\nimport { Row, Col } from '@tiny-design/react';\n\nexport default function ResponsiveDemo() {\n const style = (bg: string): React.CSSProperties => ({\n background: bg,\n color: '#fff',\n padding: '12px 0',\n textAlign: 'center',\n borderRadius: 4,\n marginBottom: 8,\n });\n\n return (\n <>\n \n \n xs=24 sm=12 md=8 lg=6
\n \n \n xs=24 sm=12 md=8 lg=6
\n \n \n xs=24 sm=12 md=8 lg=6
\n \n \n xs=24 sm=12 md=8 lg=6
\n \n
\n >\n );\n}\n"
+ "name": "ResponsiveLayout",
+ "code": "import React from 'react';\nimport { Grid, Radio, Slider } from '@tiny-design/react';\nimport type { SliderValue } from '@tiny-design/react';\nimport { DemoBlock, DemoControlLabel, DemoControls } from './shared';\n\nexport default function ResponsiveLayoutDemo() {\n const [preset, setPreset] = React.useState<'balanced' | 'hero' | 'sidebar'>('hero');\n const [desktopGap, setDesktopGap] = React.useState(24);\n\n const presets = {\n balanced: {\n hero: 6,\n sidebar: 6,\n cardA: 3,\n cardB: 3,\n content: 6,\n },\n hero: {\n hero: 8,\n sidebar: 4,\n cardA: 3,\n cardB: 3,\n content: 6,\n },\n sidebar: {\n hero: 7,\n sidebar: 5,\n cardA: 4,\n cardB: 4,\n content: 4,\n },\n } as const;\n\n const current = presets[preset];\n\n return (\n \n
\n \n Desktop layout \n setPreset(val as 'balanced' | 'hero' | 'sidebar')}>\n hero-first \n balanced \n sidebar-heavy \n \n
\n \n Desktop gap: {desktopGap}px \n {\n if (typeof val === 'number') {\n setDesktopGap(val);\n }\n }}\n />\n
\n \n
\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
\n );\n}\n"
},
{
- "name": "ResponsiveLayout",
- "code": "import React from 'react';\nimport { Card, Grid, Text } from '@tiny-design/react';\n\nconst Item = ({\n title,\n desc,\n minHeight = 88,\n}: {\n title: string;\n desc: string;\n minHeight?: number;\n}) => (\n \n {title} \n {desc} \n \n);\n\nexport default function ResponsiveLayoutDemo() {\n return (\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n );\n}\n"
+ "name": "shared",
+ "code": "import React from 'react';\n\nconst toneMix = {\n strong: 96,\n base: 88,\n soft: 76,\n subtle: 64,\n} as const;\n\ntype DemoTone = keyof typeof toneMix;\n\nexport function getDemoBlockStyle(\n tone: DemoTone = 'base',\n minHeight = 88,\n extraStyle?: React.CSSProperties,\n): React.CSSProperties {\n return {\n minHeight,\n padding: 16,\n display: 'flex',\n flexDirection: 'column',\n justifyContent: 'space-between',\n gap: 8,\n color: '#fff',\n background: `color-mix(in srgb, var(--ty-color-primary) ${toneMix[tone]}%, transparent)`,\n ...extraStyle,\n };\n}\n\nexport function DemoBlock({\n title,\n detail,\n tone = 'base',\n minHeight = 88,\n style,\n}: {\n title: string;\n detail?: string;\n tone?: DemoTone;\n minHeight?: number;\n style?: React.CSSProperties;\n}) {\n return (\n \n {title} \n {detail ? {detail} : null}\n
\n );\n}\n\nexport function DemoControls({\n children,\n}: {\n children: React.ReactNode;\n}) {\n return (\n \n {children}\n
\n );\n}\n\nexport function DemoControlLabel({\n children,\n}: {\n children: React.ReactNode;\n}) {\n return {children}
;\n}\n"
}
]
},
@@ -4241,6 +4293,10 @@
"name": "Inline",
"code": "import React from 'react';\nimport { Menu, Tag } from '@tiny-design/react';\n\nexport default function InlineDemo() {\n return (\n \n Overview \n Library \n Beta}>\n Updates\n \n \n Palette \n Tokens \n Components \n \n Preview \n Share \n Export \n \n \n \n \n Team \n Domains \n \n \n Billing \n Invoices \n Usage \n \n \n \n );\n}\n"
},
+ {
+ "name": "MegaNavigation",
+ "code": "import React from 'react';\nimport { Menu, Tag } from '@tiny-design/react';\nimport './mega-navigation.scss';\n\ntype FeatureItem = {\n key: string;\n title: string;\n description: string;\n badge?: string;\n};\n\nconst componentItems: FeatureItem[] = [\n {\n key: 'alert-dialog',\n title: 'Alert Dialog',\n description: 'A focused modal for urgent decisions that require a clear response.',\n },\n {\n key: 'hover-card',\n title: 'Hover Card',\n description: 'Preview linked content, people, or metadata without leaving the page.',\n },\n {\n key: 'progress',\n title: 'Progress',\n description: 'Show task, upload, or workflow completion with clear visual feedback.',\n badge: 'Updated',\n },\n {\n key: 'scroll-area',\n title: 'Scroll Area',\n description: 'Create more intentional scrolling regions for dense panels and inspectors.',\n },\n {\n key: 'tabs',\n title: 'Tabs',\n description: 'Split complex views into clear parallel sections for dashboards and settings.',\n },\n {\n key: 'tooltip',\n title: 'Tooltip',\n description: 'Add lightweight explanations and hints without interrupting the main flow.',\n },\n];\n\nconst resourceItems: FeatureItem[] = [\n {\n key: 'guides',\n title: 'Design Guides',\n description: 'Patterns for information architecture, interaction semantics, and theme strategy.',\n },\n {\n key: 'templates',\n title: 'Page Templates',\n description: 'Ready-made shells for authentication, product consoles, and marketing surfaces.',\n badge: 'New',\n },\n {\n key: 'tokens',\n title: 'Token System',\n description: 'A shared language for color, radius, elevation, spacing, and typography.',\n },\n {\n key: 'cli',\n title: 'CLI & MCP',\n description: 'Scaffolding, theme export, and AI workflow integration for faster delivery.',\n },\n];\n\nfunction FeatureCard({ item }: { item: FeatureItem }) {\n return (\n \n
\n {item.title} \n {item.badge ? (\n \n {item.badge}\n \n ) : null}\n
\n
{item.description}
\n
\n );\n}\n\nexport default function MegaNavigationDemo() {\n return (\n \n
\n New}>\n Installation \n Theme Setup \n Layout Principles \n Design Assets \n \n\n 28}>\n {componentItems.map((item) => (\n \n \n \n ))}\n \n\n \n {resourceItems.map((item) => (\n \n \n \n ))}\n \n \n \n );\n}\n"
+ },
{
"name": "Theme",
"code": "import React from 'react';\nimport { Menu, Tag } from '@tiny-design/react';\n\nconst shellStyle: React.CSSProperties = {\n padding: 20,\n borderRadius: 16,\n background: 'linear-gradient(180deg, var(--ty-color-bg-spotlight), var(--ty-color-bg-container))',\n};\n\nexport default function ThemeDemo() {\n return (\n \n
\n Studio Overview \n 8}>\n Assets\n \n \n Tokens \n Presets \n Live Preview \n \n Release Notes \n \n \n );\n}\n"
@@ -5633,15 +5689,21 @@
"name": "Segmented",
"dirName": "segmented",
"category": "Form",
- "description": "Segmented control for toggling between a set of options.",
- "descriptionZh": "用于在一组选项间进行切换的分段控制器。",
+ "description": "Segmented single-choice control for switching between a set of options.",
+ "descriptionZh": "用于在一组选项间进行单选切换的分段控制器。",
"props": [
{
"name": "options",
- "type": "(string | number | SegmentedOption)[]",
+ "type": "SegmentedOption[]",
"required": true,
"description": ""
},
+ {
+ "name": "name",
+ "type": "string",
+ "required": false,
+ "description": ""
+ },
{
"name": "value",
"type": "string | number",
@@ -5656,7 +5718,7 @@
},
{
"name": "onChange",
- "type": "(value: SegmentedValue) => void",
+ "type": "(\n value: SegmentedValue,\n option: SegmentedOption,\n event: React.ChangeEvent\n ) => void",
"required": false,
"description": ""
},
@@ -5700,11 +5762,11 @@
"demos": [
{
"name": "Basic",
- "code": "import React from 'react';\nimport { Segmented } from '@tiny-design/react';\n\nexport default function BasicDemo() {\n return (\n \n \n \n \n
\n );\n}"
+ "code": "import React from 'react';\nimport { Segmented } from '@tiny-design/react';\n\nexport default function BasicDemo() {\n return (\n \n \n \n \n
\n );\n}\n"
},
{
"name": "Disabled",
- "code": "import React from 'react';\nimport { Segmented } from '@tiny-design/react';\n\nexport default function DisabledDemo() {\n return (\n \n \n \n
\n );\n}"
+ "code": "import React from 'react';\nimport { Segmented } from '@tiny-design/react';\n\nexport default function DisabledDemo() {\n return (\n \n \n \n
\n );\n}\n"
},
{
"name": "Icon",
@@ -5712,7 +5774,7 @@
},
{
"name": "Size",
- "code": "import React from 'react';\nimport { Segmented } from '@tiny-design/react';\n\nexport default function SizeDemo() {\n return (\n \n \n \n \n
\n );\n}"
+ "code": "import React from 'react';\nimport { Segmented } from '@tiny-design/react';\n\nexport default function SizeDemo() {\n const options = [\n { label: 'Small', value: 'sm' },\n { label: 'Medium', value: 'md' },\n { label: 'Large', value: 'lg' },\n ];\n\n return (\n \n \n \n \n
\n );\n}\n"
}
]
},
@@ -7095,7 +7157,7 @@
},
{
"name": "Sizes",
- "code": "import React from 'react';\nimport { Table, Segmented } from '@tiny-design/react';\nimport type { SegmentedValue } from '@tiny-design/react';\n\ntype DemoSize = 'sm' | 'md' | 'lg';\n\nexport default function SizesDemo() {\n const [size, setSize] = React.useState('md');\n\n const columns = [\n { title: 'Name', dataIndex: 'name' },\n { title: 'Age', dataIndex: 'age' },\n { title: 'Address', dataIndex: 'address' },\n ];\n\n const data = [\n { key: '1', name: 'John Brown', age: 32, address: 'New York' },\n { key: '2', name: 'Jim Green', age: 42, address: 'London' },\n { key: '3', name: 'Joe Black', age: 28, address: 'Sydney' },\n ];\n\n return (\n \n
setSize(String(val) as DemoSize)}\n style={{ marginBottom: 16 }}\n />\n \n \n );\n}\n"
+ "code": "import React from 'react';\nimport { Table, Segmented } from '@tiny-design/react';\nimport type { SegmentedValue } from '@tiny-design/react';\n\ntype DemoSize = 'sm' | 'md' | 'lg';\n\nexport default function SizesDemo() {\n const [size, setSize] = React.useState('md');\n\n const columns = [\n { title: 'Name', dataIndex: 'name' },\n { title: 'Age', dataIndex: 'age' },\n { title: 'Address', dataIndex: 'address' },\n ];\n\n const data = [\n { key: '1', name: 'John Brown', age: 32, address: 'New York' },\n { key: '2', name: 'Jim Green', age: 42, address: 'London' },\n { key: '3', name: 'Joe Black', age: 28, address: 'Sydney' },\n ];\n\n return (\n \n
setSize(String(val) as DemoSize)}\n style={{ marginBottom: 16 }}\n />\n \n \n );\n}\n"
},
{
"name": "Sorting",
diff --git a/packages/react/src/flex/demo/Align.tsx b/packages/react/src/flex/demo/Align.tsx
index 8e088070..91458ab1 100644
--- a/packages/react/src/flex/demo/Align.tsx
+++ b/packages/react/src/flex/demo/Align.tsx
@@ -4,14 +4,18 @@ import type { SegmentedValue } from '@tiny-design/react';
export default function AlignDemo() {
const justifyOptions = [
- 'flex-start',
- 'center',
- 'flex-end',
- 'space-between',
- 'space-around',
- 'space-evenly',
+ { label: 'flex-start', value: 'flex-start' },
+ { label: 'center', value: 'center' },
+ { label: 'flex-end', value: 'flex-end' },
+ { label: 'space-between', value: 'space-between' },
+ { label: 'space-around', value: 'space-around' },
+ { label: 'space-evenly', value: 'space-evenly' },
+ ];
+ const alignOptions = [
+ { label: 'flex-start', value: 'flex-start' },
+ { label: 'center', value: 'center' },
+ { label: 'flex-end', value: 'flex-end' },
];
- const alignOptions = ['flex-start', 'center', 'flex-end'];
const [justify, setJustify] = React.useState('flex-start');
const [align, setAlign] = React.useState('flex-start');
diff --git a/packages/react/src/segmented/__tests__/__snapshots__/segmented.test.tsx.snap b/packages/react/src/segmented/__tests__/__snapshots__/segmented.test.tsx.snap
index fba2c83f..fc92f4bf 100644
--- a/packages/react/src/segmented/__tests__/__snapshots__/segmented.test.tsx.snap
+++ b/packages/react/src/segmented/__tests__/__snapshots__/segmented.test.tsx.snap
@@ -7,46 +7,63 @@ exports[` should match the snapshot 1`] = `
role="radiogroup"
>
- Daily
+
+ Daily
+
- Weekly
+
+ Weekly
+
- Monthly
+
+ Monthly
+
diff --git a/packages/react/src/segmented/__tests__/segmented.test.tsx b/packages/react/src/segmented/__tests__/segmented.test.tsx
index 8812e428..d203fc64 100644
--- a/packages/react/src/segmented/__tests__/segmented.test.tsx
+++ b/packages/react/src/segmented/__tests__/segmented.test.tsx
@@ -3,65 +3,127 @@ import { render, fireEvent } from '@testing-library/react';
import Segmented from '../index';
describe(' ', () => {
+ const options = [
+ { label: 'Daily', value: 'daily' },
+ { label: 'Weekly', value: 'weekly' },
+ { label: 'Monthly', value: 'monthly' },
+ ];
+
it('should match the snapshot', () => {
- const { asFragment } = render( );
+ const { asFragment } = render( );
expect(asFragment()).toMatchSnapshot();
});
it('should render correctly', () => {
- const { container } = render( );
+ const { container } = render(
+
+ );
expect(container.firstChild).toHaveClass('ty-segmented');
});
it('should render options', () => {
- const { getByText } = render( );
+ const { getByText } = render(
+
+ );
expect(getByText('Foo')).toBeInTheDocument();
expect(getByText('Bar')).toBeInTheDocument();
});
it('should select default value', () => {
const { container } = render(
-
+
);
const active = container.querySelector('.ty-segmented__item_active');
expect(active).toBeTruthy();
expect(active!).toHaveTextContent('B');
});
- it('should handle onChange', () => {
- const onChange = jest.fn();
- const { getByText } = render(
-
- );
- fireEvent.click(getByText('B'));
- expect(onChange).toHaveBeenCalledWith('B');
+ it('should not select any option by default', () => {
+ const { container } = render( );
+ expect(container.querySelector('.ty-segmented__item_active')).toBeNull();
});
- it('should support object options', () => {
- const { getByText } = render(
+ it('should handle onChange', () => {
+ const onChange = jest.fn();
+ const { getByLabelText } = render(
);
- expect(getByText('Option A')).toBeInTheDocument();
+ fireEvent.click(getByLabelText('B'));
+ expect(onChange).toHaveBeenCalledWith(
+ 'b',
+ { label: 'B', value: 'b' },
+ expect.any(Object)
+ );
});
it('should support block mode', () => {
const { container } = render(
-
+
);
expect(container.firstChild).toHaveClass('ty-segmented_block');
});
it('should support disabled', () => {
const onChange = jest.fn();
- const { getByText } = render(
-
+ const { getByLabelText } = render(
+
);
- fireEvent.click(getByText('B'));
+ fireEvent.click(getByLabelText('B'));
expect(onChange).not.toHaveBeenCalled();
});
+
+ it('should reset uncontrolled selection when option is removed', () => {
+ const { container, rerender } = render(
+
+ );
+
+ rerender(
+
+ );
+
+ expect(container.querySelector('.ty-segmented__item_active')).toBeNull();
+ });
});
diff --git a/packages/react/src/segmented/demo/Basic.tsx b/packages/react/src/segmented/demo/Basic.tsx
index 812375b1..8e065577 100644
--- a/packages/react/src/segmented/demo/Basic.tsx
+++ b/packages/react/src/segmented/demo/Basic.tsx
@@ -4,7 +4,14 @@ import { Segmented } from '@tiny-design/react';
export default function BasicDemo() {
return (
-
+
-
+
);
-}
\ No newline at end of file
+}
diff --git a/packages/react/src/segmented/demo/Controlled.tsx b/packages/react/src/segmented/demo/Controlled.tsx
new file mode 100644
index 00000000..74a40c7b
--- /dev/null
+++ b/packages/react/src/segmented/demo/Controlled.tsx
@@ -0,0 +1,23 @@
+import React from 'react';
+import { Segmented, Text } from '@tiny-design/react';
+
+const options = [
+ { label: 'List', value: 'list' },
+ { label: 'Board', value: 'board' },
+ { label: 'Timeline', value: 'timeline' },
+];
+
+export default function ControlledDemo() {
+ const [value, setValue] = React.useState('list');
+
+ return (
+
+ setValue(String(nextValue))}
+ />
+ Current value: {value}
+
+ );
+}
diff --git a/packages/react/src/segmented/demo/DefaultValue.tsx b/packages/react/src/segmented/demo/DefaultValue.tsx
new file mode 100644
index 00000000..f875eda2
--- /dev/null
+++ b/packages/react/src/segmented/demo/DefaultValue.tsx
@@ -0,0 +1,18 @@
+import React from 'react';
+import { Segmented, Text } from '@tiny-design/react';
+
+export default function DefaultValueDemo() {
+ return (
+
+
+ Initial selection is set with defaultValue.
+
+ );
+}
diff --git a/packages/react/src/segmented/demo/Disabled.tsx b/packages/react/src/segmented/demo/Disabled.tsx
index 8e897bf7..2b073f1d 100644
--- a/packages/react/src/segmented/demo/Disabled.tsx
+++ b/packages/react/src/segmented/demo/Disabled.tsx
@@ -4,7 +4,14 @@ import { Segmented } from '@tiny-design/react';
export default function DisabledDemo() {
return (
-
+
);
-}
\ No newline at end of file
+}
diff --git a/packages/react/src/segmented/demo/NoSelection.tsx b/packages/react/src/segmented/demo/NoSelection.tsx
new file mode 100644
index 00000000..52051b4e
--- /dev/null
+++ b/packages/react/src/segmented/demo/NoSelection.tsx
@@ -0,0 +1,17 @@
+import React from 'react';
+import { Segmented, Text } from '@tiny-design/react';
+
+export default function NoSelectionDemo() {
+ return (
+
+
+ Without value or defaultValue, no option is selected initially.
+
+ );
+}
diff --git a/packages/react/src/segmented/demo/Size.tsx b/packages/react/src/segmented/demo/Size.tsx
index ad2045c2..a4b808d9 100644
--- a/packages/react/src/segmented/demo/Size.tsx
+++ b/packages/react/src/segmented/demo/Size.tsx
@@ -2,11 +2,17 @@ import React from 'react';
import { Segmented } from '@tiny-design/react';
export default function SizeDemo() {
+ const options = [
+ { label: 'Small', value: 'sm' },
+ { label: 'Medium', value: 'md' },
+ { label: 'Large', value: 'lg' },
+ ];
+
return (
-
-
-
+
+
+
);
-}
\ No newline at end of file
+}
diff --git a/packages/react/src/segmented/index.md b/packages/react/src/segmented/index.md
index 64d5d5f4..27ce126d 100644
--- a/packages/react/src/segmented/index.md
+++ b/packages/react/src/segmented/index.md
@@ -1,15 +1,21 @@
import BasicDemo from './demo/Basic';
import BasicSource from './demo/Basic.tsx?raw';
+import ControlledDemo from './demo/Controlled';
+import ControlledSource from './demo/Controlled.tsx?raw';
+import DefaultValueDemo from './demo/DefaultValue';
+import DefaultValueSource from './demo/DefaultValue.tsx?raw';
import DisabledDemo from './demo/Disabled';
import DisabledSource from './demo/Disabled.tsx?raw';
import IconDemo from './demo/Icon';
import IconSource from './demo/Icon.tsx?raw';
+import NoSelectionDemo from './demo/NoSelection';
+import NoSelectionSource from './demo/NoSelection.tsx?raw';
import SizeDemo from './demo/Size';
import SizeSource from './demo/Size.tsx?raw';
# Segmented
-Segmented control for toggling between a set of options.
+Segmented single-choice control for switching between a set of options.
## Scenario
@@ -42,6 +48,24 @@ Add icons to segmented options using the `icon` property.
+
+
+
+### Controlled
+
+Use `value` and `onChange` when the selected state is managed externally.
+
+
+
+
+
+
+### No Selection
+
+Without `value` or `defaultValue`, the control starts with no selected option.
+
+
+
@@ -62,6 +86,15 @@ Disable the entire control or individual options.
+
+
+
+### Default Value
+
+Use `defaultValue` to set the initial selection in uncontrolled mode.
+
+
+
@@ -70,19 +103,22 @@ Disable the entire control or individual options.
| Property | Description | Type | Default |
| ------------ | ------------------------------------------- | ----------------------------------------------------- | ------- |
-| options | options for the segmented control | (string \| number \| SegmentedOption)[] | |
+| options | segmented options | SegmentedOption[] | |
+| name | shared radio name for the internal inputs | string | auto |
| value | currently selected value (controlled) | string \| number | |
-| defaultValue | default selected value | string \| number | |
-| onChange | callback when the value changes | (value: string \| number) => void | |
+| defaultValue | initial selected value | string \| number | |
+| onChange | callback when the value changes | (value, option, event) => void | |
| block | fit width to parent | boolean | false |
| disabled | disable the entire control | boolean | false |
-| size | size of the control | 'sm' \| 'md' \| 'lg' | md |
+| size | size of the control | 'sm' \| 'md' \| 'lg' | md |
### SegmentedOption
-| Property | Description | Type | Default |
-| -------- | ------------------------- | --------- | ------- |
-| label | display text | ReactNode | |
-| value | option value | string \| number | |
-| disabled | disable this option | boolean | false |
-| icon | icon node | ReactNode | |
\ No newline at end of file
+| Property | Description | Type | Default |
+| -------- | --------------------------------------- | --------- | ------- |
+| value | option value | string \| number | |
+| label | display content | ReactNode | |
+| disabled | disable this option | boolean | false |
+| icon | icon node | ReactNode | |
+| title | title and fallback accessible label | string | |
+| className| custom class name for the option item | string | |
diff --git a/packages/react/src/segmented/index.zh_CN.md b/packages/react/src/segmented/index.zh_CN.md
index 56a38fa1..6ec56282 100644
--- a/packages/react/src/segmented/index.zh_CN.md
+++ b/packages/react/src/segmented/index.zh_CN.md
@@ -1,15 +1,21 @@
import BasicDemo from './demo/Basic';
import BasicSource from './demo/Basic.tsx?raw';
+import ControlledDemo from './demo/Controlled';
+import ControlledSource from './demo/Controlled.tsx?raw';
+import DefaultValueDemo from './demo/DefaultValue';
+import DefaultValueSource from './demo/DefaultValue.tsx?raw';
import DisabledDemo from './demo/Disabled';
import DisabledSource from './demo/Disabled.tsx?raw';
import IconDemo from './demo/Icon';
import IconSource from './demo/Icon.tsx?raw';
+import NoSelectionDemo from './demo/NoSelection';
+import NoSelectionSource from './demo/NoSelection.tsx?raw';
import SizeDemo from './demo/Size';
import SizeSource from './demo/Size.tsx?raw';
# Segmented 分段控制器
-用于在一组选项间进行切换的分段控制器。
+用于在一组选项间进行单选切换的分段控制器。
## 使用场景
@@ -42,6 +48,24 @@ import { Segmented } from 'tiny-design';
+
+
+
+### 受控模式
+
+当选中状态由外部管理时,使用 `value` 和 `onChange`。
+
+
+
+
+
+
+### 默认无选中
+
+不传 `value` 或 `defaultValue` 时,初始状态下不会选中任何选项。
+
+
+
@@ -62,27 +86,39 @@ import { Segmented } from 'tiny-design';
+
+
+
+### 初始值
+
+在非受控模式下,使用 `defaultValue` 设置初始选中项。
+
+
+
## Props
-| 属性 | 说明 | 类型 | 默认值 |
-| ------------ | ---------------------- | ----------------------------------------------------- | ------- |
-| options | 选项列表 | (string \| number \| SegmentedOption)[] | |
-| value | 当前选中值(受控) | string \| number | |
-| defaultValue | 默认选中值 | string \| number | |
-| onChange | 值变化时的回调 | (value: string \| number) => void | |
-| block | 撑满父元素宽度 | boolean | false |
-| disabled | 禁用整个控件 | boolean | false |
-| size | 控件大小 | 'sm' \| 'md' \| 'lg' | md |
+| 属性 | 说明 | 类型 | 默认值 |
+| ------------ | ------------------------------ | ----------------------------------------------------- | ------- |
+| options | 分段选项列表 | SegmentedOption[] | |
+| name | 内部 radio 的共享名称 | string | 自动生成 |
+| value | 当前选中值(受控) | string \| number | |
+| defaultValue | 初始选中值 | string \| number | |
+| onChange | 值变化时的回调 | (value, option, event) => void | |
+| block | 撑满父元素宽度 | boolean | false |
+| disabled | 禁用整个控件 | boolean | false |
+| size | 控件大小 | 'sm' \| 'md' \| 'lg' | md |
### SegmentedOption
-| 属性 | 说明 | 类型 | 默认值 |
-| -------- | ------------ | --------- | ------- |
-| label | 显示文本 | ReactNode | |
-| value | 选项值 | string \| number | |
-| disabled | 禁用此选项 | boolean | false |
-| icon | 图标 | ReactNode | |
\ No newline at end of file
+| 属性 | 说明 | 类型 | 默认值 |
+| --------- | ------------------------ | --------- | ------- |
+| value | 选项值 | string \| number | |
+| label | 显示内容 | ReactNode | |
+| disabled | 禁用此选项 | boolean | false |
+| icon | 图标 | ReactNode | |
+| title | 标题与可访问名称兜底 | string | |
+| className | 选项项的自定义类名 | string | |
diff --git a/packages/react/src/segmented/segmented.tsx b/packages/react/src/segmented/segmented.tsx
index cf6b70aa..ccf410b5 100644
--- a/packages/react/src/segmented/segmented.tsx
+++ b/packages/react/src/segmented/segmented.tsx
@@ -1,23 +1,14 @@
-import React, { useState, useEffect, useContext } from 'react';
+import React, { useContext, useEffect, useId, useState } from 'react';
import classNames from 'classnames';
import { ConfigContext } from '../config-provider/config-context';
import { getPrefixCls } from '../_utils/general';
-import { SegmentedProps, SegmentedOption, SegmentedValue } from './types';
-
-const normalizeOptions = (
- options: (string | number | SegmentedOption)[]
-): SegmentedOption[] => {
- return options.map((opt) => {
- if (typeof opt === 'string' || typeof opt === 'number') {
- return { label: String(opt), value: opt };
- }
- return opt;
- });
-};
+import { SegmentedOption, SegmentedProps, SegmentedValue } from './types';
const Segmented = React.forwardRef((props, ref) => {
const {
options,
+ name,
+ value,
defaultValue,
block = false,
disabled = false,
@@ -32,19 +23,19 @@ const Segmented = React.forwardRef((props, ref)
const configContext = useContext(ConfigContext);
const prefixCls = getPrefixCls('segmented', configContext.prefixCls, customisedCls);
const segSize = size || configContext.componentSize || 'md';
-
- const normalizedOptions = normalizeOptions(options);
- const [selected, setSelected] = useState(
- 'value' in props
- ? (props.value as SegmentedValue)
- : defaultValue ?? normalizedOptions[0]?.value
+ const groupName = useId();
+ const isControlled = 'value' in props;
+ const [uncontrolledValue, setUncontrolledValue] = useState(
+ defaultValue
);
+ const selected = isControlled ? value : uncontrolledValue;
useEffect(() => {
- if ('value' in props) {
- setSelected(props.value as SegmentedValue);
+ const hasSelectedOption = options.some((option) => option.value === selected);
+ if (!isControlled && typeof selected !== 'undefined' && !hasSelectedOption) {
+ setUncontrolledValue(undefined);
}
- }, [props.value]);
+ }, [isControlled, options, selected]);
const cls = classNames(prefixCls, className, {
[`${prefixCls}_${segSize}`]: segSize,
@@ -52,12 +43,16 @@ const Segmented = React.forwardRef((props, ref)
[`${prefixCls}_disabled`]: disabled,
});
- const handleClick = (value: SegmentedValue, optDisabled?: boolean) => {
- if (disabled || optDisabled) return;
- if (!('value' in props)) {
- setSelected(value);
+ const handleChange = (option: SegmentedOption) => (event: React.ChangeEvent) => {
+ if (disabled || option.disabled) {
+ return;
}
- onChange?.(value);
+
+ if (!isControlled) {
+ setUncontrolledValue(option.value);
+ }
+
+ onChange?.(option.value, option, event);
};
return (
@@ -68,28 +63,39 @@ const Segmented = React.forwardRef((props, ref)
style={style}
role="radiogroup"
>
- {normalizedOptions.map((opt) => {
+ {options.map((opt) => {
const isActive = opt.value === selected;
const itemCls = classNames(`${prefixCls}__item`, {
[`${prefixCls}__item_active`]: isActive,
[`${prefixCls}__item_disabled`]: opt.disabled,
- });
+ }, opt.className);
+ const accessibleLabel =
+ typeof opt.label === 'string' || typeof opt.label === 'number'
+ ? String(opt.label)
+ : opt.title;
+
return (
handleClick(opt.value, opt.disabled)}
+ title={opt.title}
>
handleClick(opt.value, opt.disabled)}
+ onChange={handleChange(opt)}
value={opt.value}
+ aria-label={accessibleLabel}
/>
- {opt.icon && {opt.icon} }
- {opt.label}
+
+ {opt.icon && {opt.icon} }
+ {typeof opt.label !== 'undefined' ? (
+ {opt.label}
+ ) : null}
+
);
})}
diff --git a/packages/react/src/segmented/style/index.scss b/packages/react/src/segmented/style/index.scss
index 7d03888c..e3895012 100644
--- a/packages/react/src/segmented/style/index.scss
+++ b/packages/react/src/segmented/style/index.scss
@@ -14,35 +14,47 @@
.#{$prefix}-segmented__item {
flex: 1;
+ }
+
+ .#{$prefix}-segmented__item-content {
justify-content: center;
+ width: 100%;
}
}
&_disabled {
- opacity: 0.5;
cursor: not-allowed;
.#{$prefix}-segmented__item {
cursor: not-allowed;
pointer-events: none;
+
+ .#{$prefix}-segmented__item-content {
+ color: var(--ty-segmented-item-color-disabled);
+ background: var(--ty-segmented-item-bg-disabled);
+ opacity: var(--ty-segmented-item-opacity-disabled);
+ }
}
}
&_sm .#{$prefix}-segmented__item {
- padding: var(--ty-segmented-item-padding-sm);
- height: var(--ty-segmented-item-height-sm);
+ min-height: var(--ty-segmented-item-height-sm);
+ padding-block: var(--ty-segmented-item-padding-block-sm);
+ padding-inline: var(--ty-segmented-item-padding-inline-sm);
font-size: var(--ty-segmented-font-size-sm);
}
&_md .#{$prefix}-segmented__item {
- padding: var(--ty-segmented-item-padding-md);
- height: var(--ty-segmented-item-height-md);
+ min-height: var(--ty-segmented-item-height-md);
+ padding-block: var(--ty-segmented-item-padding-block-md);
+ padding-inline: var(--ty-segmented-item-padding-inline-md);
font-size: var(--ty-segmented-font-size-md);
}
&_lg .#{$prefix}-segmented__item {
- padding: var(--ty-segmented-item-padding-lg);
- height: var(--ty-segmented-item-height-lg);
+ min-height: var(--ty-segmented-item-height-lg);
+ padding-block: var(--ty-segmented-item-padding-block-lg);
+ padding-inline: var(--ty-segmented-item-padding-inline-lg);
font-size: var(--ty-segmented-font-size-lg);
}
@@ -50,27 +62,31 @@
position: relative;
display: inline-flex;
align-items: center;
- gap: var(--ty-segmented-item-gap);
cursor: pointer;
border-radius: var(--ty-segmented-radius);
- transition: all 0.2s;
+ transition: color 0.2s, background-color 0.2s, box-shadow 0.2s;
user-select: none;
white-space: nowrap;
+ color: var(--ty-segmented-item-color);
+ background: var(--ty-segmented-item-bg);
&:hover:not(&_active, &_disabled) {
color: var(--ty-segmented-item-color-hover);
+ background: var(--ty-segmented-item-bg-hover);
}
&_active {
- background: var(--ty-segmented-active-bg);
- color: var(--ty-segmented-item-color-active);
- box-shadow: var(--ty-segmented-item-shadow-active);
- font-weight: var(--ty-segmented-item-font-weight-active);
+ background: var(--ty-segmented-item-bg-selected);
+ color: var(--ty-segmented-item-color-selected);
+ box-shadow: var(--ty-segmented-item-shadow-selected);
+ font-weight: var(--ty-segmented-item-font-weight-selected);
}
&_disabled {
cursor: not-allowed;
- opacity: 0.5;
+ color: var(--ty-segmented-item-color-disabled);
+ background: var(--ty-segmented-item-bg-disabled);
+ opacity: var(--ty-segmented-item-opacity-disabled);
}
}
@@ -80,11 +96,30 @@
height: 0;
opacity: 0;
pointer-events: none;
+
+ &:focus-visible + .#{$prefix}-segmented__item-content {
+ box-shadow: var(--ty-segmented-item-shadow-focus);
+ outline: none;
+ }
+ }
+
+ &__item-content {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--ty-segmented-item-gap);
+ border-radius: var(--ty-segmented-radius);
+ color: inherit;
+ box-sizing: border-box;
}
&__icon {
display: inline-flex;
align-items: center;
+
+ svg {
+ width: var(--ty-segmented-icon-size);
+ height: var(--ty-segmented-icon-size);
+ }
}
&__label {
diff --git a/packages/react/src/segmented/types.ts b/packages/react/src/segmented/types.ts
index da82d95a..766b5436 100644
--- a/packages/react/src/segmented/types.ts
+++ b/packages/react/src/segmented/types.ts
@@ -2,21 +2,31 @@ import React from 'react';
import { BaseProps, SizeType } from '../_utils/props';
export interface SegmentedOption {
- label: React.ReactNode;
- value: string | number;
+ value: SegmentedValue;
+ label?: React.ReactNode;
disabled?: boolean;
icon?: React.ReactNode;
+ title?: string;
+ className?: string;
}
export type SegmentedValue = string | number;
export interface SegmentedProps
extends BaseProps,
- Omit, 'onChange'> {
- options: (string | number | SegmentedOption)[];
+ Omit<
+ React.PropsWithoutRef,
+ 'children' | 'defaultValue' | 'onChange'
+ > {
+ options: SegmentedOption[];
+ name?: string;
value?: SegmentedValue;
defaultValue?: SegmentedValue;
- onChange?: (value: SegmentedValue) => void;
+ onChange?: (
+ value: SegmentedValue,
+ option: SegmentedOption,
+ event: React.ChangeEvent
+ ) => void;
block?: boolean;
disabled?: boolean;
size?: SizeType;
diff --git a/packages/react/src/table/demo/Sizes.tsx b/packages/react/src/table/demo/Sizes.tsx
index da907fdf..486195a6 100644
--- a/packages/react/src/table/demo/Sizes.tsx
+++ b/packages/react/src/table/demo/Sizes.tsx
@@ -22,7 +22,11 @@ export default function SizesDemo() {
return (
setSize(String(val) as DemoSize)}
style={{ marginBottom: 16 }}
diff --git a/packages/tokens/source/components/segmented.json b/packages/tokens/source/components/segmented.json
index 9cb7828b..898543dd 100644
--- a/packages/tokens/source/components/segmented.json
+++ b/packages/tokens/source/components/segmented.json
@@ -1,9 +1,8 @@
{
- "segmented.active-bg": {
- "$value": "{color-bg-container}",
+ "segmented.item-bg": {
+ "$value": "transparent",
"$type": "color",
- "description": "Segmented active bg.",
- "fallback": "--ty-color-bg-container"
+ "description": "Segmented item background."
},
"segmented.bg": {
"$value": "#e9ecef",
@@ -26,42 +25,97 @@
"$type": "dimension",
"description": "Segmented item content gap."
},
+ "segmented.item-color": {
+ "$value": "{color-text-secondary}",
+ "$type": "color",
+ "description": "Segmented item color.",
+ "fallback": "--ty-color-text-secondary"
+ },
+ "segmented.item-bg-hover": {
+ "$value": "{color-fill}",
+ "$type": "color",
+ "description": "Segmented hover item background.",
+ "fallback": "--ty-color-fill"
+ },
"segmented.item-color-hover": {
"$value": "{color-text}",
"$type": "color",
"description": "Segmented hover item color.",
"fallback": "--ty-color-text"
},
- "segmented.item-color-active": {
+ "segmented.item-bg-selected": {
+ "$value": "{color-bg-container}",
+ "$type": "color",
+ "description": "Segmented selected item background.",
+ "fallback": "--ty-color-bg-container"
+ },
+ "segmented.item-color-selected": {
"$value": "{color-text}",
"$type": "color",
- "description": "Segmented active item color.",
+ "description": "Segmented selected item color.",
"fallback": "--ty-color-text"
},
- "segmented.item-shadow-active": {
+ "segmented.item-shadow-selected": {
"$value": "0 1px 2px 0 rgb(0 0 0 / 6%), 0 1px 3px 0 rgb(0 0 0 / 10%)",
"$type": "shadow",
- "description": "Segmented active item shadow."
+ "description": "Segmented selected item shadow."
+ },
+ "segmented.item-shadow-focus": {
+ "$value": "{shadow-focus}",
+ "$type": "shadow",
+ "description": "Segmented focus item shadow.",
+ "fallback": "--ty-shadow-focus"
},
- "segmented.item-font-weight-active": {
+ "segmented.item-font-weight-selected": {
"$value": "500",
"$type": "font-weight",
- "description": "Segmented active item font weight."
+ "description": "Segmented selected item font weight."
},
- "segmented.item-padding.sm": {
- "$value": "0 8px",
- "$type": "string",
- "description": "Small segmented item padding."
+ "segmented.item-color-disabled": {
+ "$value": "{color-text-quaternary}",
+ "$type": "color",
+ "description": "Segmented disabled item color.",
+ "fallback": "--ty-color-text-quaternary"
},
- "segmented.item-padding.md": {
- "$value": "0 12px",
- "$type": "string",
- "description": "Medium segmented item padding."
+ "segmented.item-bg-disabled": {
+ "$value": "transparent",
+ "$type": "color",
+ "description": "Segmented disabled item background."
},
- "segmented.item-padding.lg": {
- "$value": "0 16px",
- "$type": "string",
- "description": "Large segmented item padding."
+ "segmented.item-opacity-disabled": {
+ "$value": "0.5",
+ "$type": "number",
+ "description": "Segmented disabled item opacity."
+ },
+ "segmented.item-padding-inline.sm": {
+ "$value": "8px",
+ "$type": "dimension",
+ "description": "Small segmented item inline padding."
+ },
+ "segmented.item-padding-inline.md": {
+ "$value": "12px",
+ "$type": "dimension",
+ "description": "Medium segmented item inline padding."
+ },
+ "segmented.item-padding-inline.lg": {
+ "$value": "16px",
+ "$type": "dimension",
+ "description": "Large segmented item inline padding."
+ },
+ "segmented.item-padding-block.sm": {
+ "$value": "0",
+ "$type": "dimension",
+ "description": "Small segmented item block padding."
+ },
+ "segmented.item-padding-block.md": {
+ "$value": "0",
+ "$type": "dimension",
+ "description": "Medium segmented item block padding."
+ },
+ "segmented.item-padding-block.lg": {
+ "$value": "0",
+ "$type": "dimension",
+ "description": "Large segmented item block padding."
},
"segmented.item-height.sm": {
"$value": "calc({height-sm} - 4px)",
@@ -95,5 +149,10 @@
"$type": "dimension",
"description": "Large segmented font size.",
"fallback": "--ty-font-size-lg"
+ },
+ "segmented.icon-size": {
+ "$value": "14px",
+ "$type": "dimension",
+ "description": "Segmented icon size."
}
}
diff --git a/packages/tokens/source/themes/dark.json b/packages/tokens/source/themes/dark.json
index b7ae0375..9dc96402 100644
--- a/packages/tokens/source/themes/dark.json
+++ b/packages/tokens/source/themes/dark.json
@@ -175,8 +175,9 @@
"radio.disabled-border": "#424242",
"radio.disabled-dot": "rgba(255, 255, 255, 0.2)",
"result.content-bg": "#262626",
- "segmented.active-bg": "#1f1f1f",
"segmented.bg": "#2a2a2a",
+ "segmented.item-bg-hover": "#303030",
+ "segmented.item-bg-selected": "#1f1f1f",
"select.dropdown-bg": "#1f1f1f",
"select.option-active-bg": "#2a2a2a",
"select.option-disabled-bg": "#1f1f1f",