From 5c74ff4bba316bffc347aa5c9d685b2f921723a3 Mon Sep 17 00:00:00 2001 From: wangdicoder Date: Wed, 15 Apr 2026 15:00:25 +1000 Subject: [PATCH 1/4] feat(collapse): redesign collapse API and styles --- .changeset/feat-collapse-redesign.md | 6 + apps/docs/src/components/demo-block/index.tsx | 43 +++- .../theme-studio/sidebar-content.tsx | 85 ++++--- .../collapse-transition.test.tsx.snap | 14 - .../__tests__/collapse-transition.test.tsx | 36 ++- .../collapse-transition.tsx | 149 +++++------ .../src/collapse-transition/style/index.scss | 4 +- .../__snapshots__/collapse.test.tsx.snap | 100 -------- .../src/collapse/__tests__/collapse.test.tsx | 119 ++++++--- .../react/src/collapse/collapse-context.ts | 10 - .../react/src/collapse/collapse-panel.tsx | 227 +++++++++++------ packages/react/src/collapse/collapse.tsx | 187 +++++++------- .../react/src/collapse/demo/Accordion.tsx | 38 +-- packages/react/src/collapse/demo/Basic.tsx | 42 +-- .../react/src/collapse/demo/Borderless.tsx | 44 ++-- .../react/src/collapse/demo/Deletable.tsx | 93 +++++-- packages/react/src/collapse/demo/Extra.tsx | 72 +++--- packages/react/src/collapse/demo/Nested.tsx | 50 ++-- packages/react/src/collapse/index.md | 103 ++++---- packages/react/src/collapse/index.tsx | 11 +- packages/react/src/collapse/index.zh_CN.md | 103 ++++---- packages/react/src/collapse/style/index.scss | 240 +++++++++++++----- packages/react/src/collapse/types.ts | 63 +++-- .../tokens/source/components/collapse.json | 184 ++++++++++---- packages/tokens/source/themes/dark.json | 11 +- 25 files changed, 1213 insertions(+), 821 deletions(-) create mode 100644 .changeset/feat-collapse-redesign.md delete mode 100644 packages/react/src/collapse-transition/__tests__/__snapshots__/collapse-transition.test.tsx.snap delete mode 100644 packages/react/src/collapse/__tests__/__snapshots__/collapse.test.tsx.snap delete mode 100644 packages/react/src/collapse/collapse-context.ts mode change 100755 => 100644 packages/react/src/collapse/collapse-panel.tsx mode change 100755 => 100644 packages/react/src/collapse/index.md mode change 100755 => 100644 packages/react/src/collapse/index.tsx diff --git a/.changeset/feat-collapse-redesign.md b/.changeset/feat-collapse-redesign.md new file mode 100644 index 00000000..5dd7be66 --- /dev/null +++ b/.changeset/feat-collapse-redesign.md @@ -0,0 +1,6 @@ +--- +'@tiny-design/react': minor +'@tiny-design/tokens': minor +--- + +Redesign the Collapse component API, styles, and docs, and align the related tokens. diff --git a/apps/docs/src/components/demo-block/index.tsx b/apps/docs/src/components/demo-block/index.tsx index 044d46c8..a5639da2 100644 --- a/apps/docs/src/components/demo-block/index.tsx +++ b/apps/docs/src/components/demo-block/index.tsx @@ -60,6 +60,28 @@ type DemoBlockProps = { description?: string; }; +const DemoPreview = React.memo(function DemoPreview({ + component: Component, + isEditing, + liveElement, + runnerError, +}: { + component: React.ComponentType; + isEditing: boolean; + liveElement: React.ReactNode; + runnerError: string | null | undefined; +}) { + if (isEditing) { + return runnerError ? ( +
{runnerError}
+ ) : ( + <>{liveElement} + ); + } + + return ; +}); + export const DemoBlock = ({ component: Component, source, title, description }: DemoBlockProps) => { const [showCode, setShowCode] = useState(false); const [editedCode, setEditedCode] = useState(null); @@ -95,6 +117,10 @@ export const DemoBlock = ({ component: Component, source, title, description }: setEditedCode(null); }, []); + const handleToggleCode = useCallback(() => { + setShowCode((current) => !current); + }, []); + const handleCopy = useCallback(async () => { try { await navigator.clipboard.writeText(displayCode); @@ -129,15 +155,12 @@ export const DemoBlock = ({ component: Component, source, title, description }:
{/* Preview area */}
- {isEditing ? ( - runnerError ? ( -
{runnerError}
- ) : ( - liveElement - ) - ) : ( - - )} +
{/* Action bar */} @@ -178,7 +201,7 @@ export const DemoBlock = ({ component: Component, source, title, description }: )} setShowCode(!showCode)}> + onClick={handleToggleCode}> {showCode ? s.codeBlock.hideCode : s.codeBlock.showCode}
diff --git a/apps/docs/src/containers/theme-studio/sidebar-content.tsx b/apps/docs/src/containers/theme-studio/sidebar-content.tsx index c22cd450..ba106fa1 100644 --- a/apps/docs/src/containers/theme-studio/sidebar-content.tsx +++ b/apps/docs/src/containers/theme-studio/sidebar-content.tsx @@ -37,7 +37,6 @@ export function ThemeStudioSidebarContent({ draft: ThemeEditorDraft; updateField: (key: FieldKey, value: string) => void; }): React.ReactElement | null { - const { Panel } = Collapse; const coreColorGroups = COLOR_GROUPS.filter((group) => CORE_COLOR_GROUP_TITLES.has(group.title)); const advancedColorGroups = COLOR_GROUPS.filter((group) => !CORE_COLOR_GROUP_TITLES.has(group.title)); @@ -58,22 +57,22 @@ export function ThemeStudioSidebarContent({ - Advanced Tokens Card, popover, sidebar, chart. - )} - > - {renderColorGroups(advancedColorGroups, draft, updateField)} - - + ), + children: renderColorGroups(advancedColorGroups, draft, updateField), + }, + ]} + /> ) : null} @@ -203,44 +202,60 @@ export function ThemeStudioSidebarContent({ Fields updateField('fieldHeightMd', next)} config={{ min: 20, max: 56, step: 1, unit: 'px' }} /> updateField('fieldPaddingMd', next)} config={{ min: 0, max: 32, step: 1, unit: 'px' }} /> - - Advanced Sizes Small and large field density. - )} - > - updateField('fieldHeightSm', next)} config={{ min: 20, max: 56, step: 1, unit: 'px' }} /> - updateField('fieldHeightLg', next)} config={{ min: 20, max: 56, step: 1, unit: 'px' }} /> - updateField('fieldPaddingSm', next)} config={{ min: 0, max: 32, step: 1, unit: 'px' }} /> - updateField('fieldPaddingLg', next)} config={{ min: 0, max: 32, step: 1, unit: 'px' }} /> - - + ), + children: ( + <> + updateField('fieldHeightSm', next)} config={{ min: 20, max: 56, step: 1, unit: 'px' }} /> + updateField('fieldHeightLg', next)} config={{ min: 20, max: 56, step: 1, unit: 'px' }} /> + updateField('fieldPaddingSm', next)} config={{ min: 0, max: 32, step: 1, unit: 'px' }} /> + updateField('fieldPaddingLg', next)} config={{ min: 0, max: 32, step: 1, unit: 'px' }} /> + + ), + }, + ]} + />
Buttons updateField('buttonHeightMd', next)} config={{ min: 20, max: 56, step: 1, unit: 'px' }} /> updateField('buttonPaddingMd', next)} config={{ min: 0, max: 32, step: 1, unit: 'px' }} /> - - Advanced Sizes Small and large button density.
- )} - > - updateField('buttonHeightSm', next)} config={{ min: 20, max: 56, step: 1, unit: 'px' }} /> - updateField('buttonHeightLg', next)} config={{ min: 20, max: 56, step: 1, unit: 'px' }} /> - updateField('buttonPaddingSm', next)} config={{ min: 0, max: 32, step: 1, unit: 'px' }} /> - updateField('buttonPaddingLg', next)} config={{ min: 0, max: 32, step: 1, unit: 'px' }} /> - - + ), + children: ( + <> + updateField('buttonHeightSm', next)} config={{ min: 20, max: 56, step: 1, unit: 'px' }} /> + updateField('buttonHeightLg', next)} config={{ min: 20, max: 56, step: 1, unit: 'px' }} /> + updateField('buttonPaddingSm', next)} config={{ min: 0, max: 32, step: 1, unit: 'px' }} /> + updateField('buttonPaddingLg', next)} config={{ min: 0, max: 32, step: 1, unit: 'px' }} /> + + ), + }, + ]} + />
diff --git a/packages/react/src/collapse-transition/__tests__/__snapshots__/collapse-transition.test.tsx.snap b/packages/react/src/collapse-transition/__tests__/__snapshots__/collapse-transition.test.tsx.snap deleted file mode 100644 index 3d7c6470..00000000 --- a/packages/react/src/collapse-transition/__tests__/__snapshots__/collapse-transition.test.tsx.snap +++ /dev/null @@ -1,14 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[` should match the snapshot 1`] = ` - -
-
- Content -
-
-
-`; diff --git a/packages/react/src/collapse-transition/__tests__/collapse-transition.test.tsx b/packages/react/src/collapse-transition/__tests__/collapse-transition.test.tsx index d73e3084..5b9e4c74 100644 --- a/packages/react/src/collapse-transition/__tests__/collapse-transition.test.tsx +++ b/packages/react/src/collapse-transition/__tests__/collapse-transition.test.tsx @@ -1,19 +1,37 @@ import React from 'react'; -import { render } from '@testing-library/react'; +import { fireEvent, render } from '@testing-library/react'; import CollapseTransition from '../index'; describe('', () => { - it('should match the snapshot', () => { - const { asFragment } = render( -
Content
+ it('should render open content', () => { + const { container } = render( + +
Visible Content
+
); - expect(asFragment()).toMatchSnapshot(); + + expect(container.firstChild).toHaveClass('ty-collapse-transition'); }); - it('should render children when visible', () => { - const { getByText } = render( -
Visible Content
+ it('should call onHidden after a close transition', () => { + const onHidden = jest.fn(); + const { container, rerender } = render( + +
Visible Content
+
+ ); + + rerender( + +
Visible Content
+
); - expect(getByText('Visible Content')).toBeInTheDocument(); + + const node = container.firstChild as Element; + const event = new Event('transitionend'); + Object.defineProperty(event, 'propertyName', { value: 'height' }); + fireEvent(node, event); + + expect(onHidden).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/react/src/collapse-transition/collapse-transition.tsx b/packages/react/src/collapse-transition/collapse-transition.tsx index eafa0843..0469caae 100644 --- a/packages/react/src/collapse-transition/collapse-transition.tsx +++ b/packages/react/src/collapse-transition/collapse-transition.tsx @@ -1,110 +1,85 @@ -import React, { useRef, useEffect, useCallback } from 'react'; +import React, { useEffect, useRef } from 'react'; +import classNames from 'classnames'; type CollapseTransitionProps = { - isShow: boolean; + open?: boolean; + isShow?: boolean; + className?: string; children: React.ReactNode; + onHidden?: () => void; }; -const COLLAPSE_DURATION = 250; - -const CollapseTransition = (props: CollapseTransitionProps): JSX.Element => { - const { isShow, children } = props; - const leaveTimerRef = useRef(null); - const enterTimerRef = useRef(null); +const CollapseTransition = ({ + open, + isShow, + className, + children, + onHidden, +}: CollapseTransitionProps): React.ReactElement => { const ref = useRef(null); + const isFirstRender = useRef(true); + const visible = open ?? isShow ?? false; - const beforeEnter = useCallback((): void => { - const el = ref.current; - if (el) { - el.style.display = 'block'; - el.style.height = '0px'; - } - }, []); - - const afterEnter = useCallback((): void => { - const el = ref.current; - if (el) { - el.style.display = 'block'; - el.style.height = ''; + useEffect(() => { + const node = ref.current; + if (!node) return; + + if (isFirstRender.current) { + isFirstRender.current = false; + node.style.display = visible ? 'block' : 'none'; + node.style.height = visible ? '' : '0px'; + return; } - }, []); - const enter = useCallback((): void => { - const el = ref.current; - if (el) { - if (el.scrollHeight !== 0) { - el.style.height = el.scrollHeight + 'px'; - } else { - el.style.height = ''; - } + let frameA = 0; + let frameB = 0; - enterTimerRef.current = window.setTimeout(() => afterEnter(), COLLAPSE_DURATION); - } - }, [afterEnter]); - - const beforeLeave = useCallback((): void => { - const el = ref.current; - if (el) { - el.style.display = 'block'; - if (el.scrollHeight !== 0) { - el.style.height = el.scrollHeight + 'px'; - } - } - }, []); + const handleTransitionEnd = (event: TransitionEvent) => { + if (event.target !== node || event.propertyName !== 'height') return; - const afterLeave = useCallback((): void => { - const el = ref.current; - if (el) { - el.style.display = 'none'; - el.style.height = ''; - } - }, []); + node.style.overflow = ''; - const leave = useCallback((): void => { - const el = ref.current; - if (el) { - if (el.scrollHeight !== 0) { - el.style.height = '0px'; - } - - leaveTimerRef.current = window.setTimeout(() => afterLeave(), COLLAPSE_DURATION); - } - }, [afterLeave]); - - const triggerChange = useCallback( - (isCollapsed: boolean): void => { - const enterTimer = enterTimerRef.current; - const leaveTimer = leaveTimerRef.current; - enterTimer && window.clearTimeout(enterTimer); - leaveTimer && window.clearTimeout(leaveTimer); - - if (isCollapsed) { - beforeEnter(); - enter(); + if (visible) { + node.style.height = ''; } else { - beforeLeave(); - leave(); + node.style.display = 'none'; + onHidden?.(); } - }, - [enter, leave, beforeEnter, beforeLeave] - ); + }; - useEffect(() => { - beforeEnter(); - enter(); + node.addEventListener('transitionend', handleTransitionEnd); + + if (visible) { + node.style.display = 'block'; + node.style.overflow = 'hidden'; + node.style.height = '0px'; + + frameA = window.requestAnimationFrame(() => { + frameB = window.requestAnimationFrame(() => { + node.style.height = `${node.scrollHeight}px`; + }); + }); + } else { + node.style.display = 'block'; + node.style.overflow = 'hidden'; + node.style.height = `${node.scrollHeight}px`; + + frameA = window.requestAnimationFrame(() => { + frameB = window.requestAnimationFrame(() => { + node.style.height = '0px'; + }); + }); + } return () => { - beforeLeave(); - leave(); + if (frameA) window.cancelAnimationFrame(frameA); + if (frameB) window.cancelAnimationFrame(frameB); + node.removeEventListener('transitionend', handleTransitionEnd); }; - }, [enter, leave, beforeEnter, beforeLeave]); - - useEffect(() => { - triggerChange(isShow); - }, [isShow, triggerChange]); + }, [visible, onHidden]); return ( -
+
{children}
); diff --git a/packages/react/src/collapse-transition/style/index.scss b/packages/react/src/collapse-transition/style/index.scss index 8c6bdf05..9806c82b 100644 --- a/packages/react/src/collapse-transition/style/index.scss +++ b/packages/react/src/collapse-transition/style/index.scss @@ -1,4 +1,6 @@ .ty-collapse-transition { overflow: hidden; - transition: 250ms height, 250ms padding-top, 250ms padding-bottom; + transition: + height var(--ty-collapse-motion-duration, 240ms) + var(--ty-collapse-motion-easing, ease); } diff --git a/packages/react/src/collapse/__tests__/__snapshots__/collapse.test.tsx.snap b/packages/react/src/collapse/__tests__/__snapshots__/collapse.test.tsx.snap deleted file mode 100644 index 28e3c24e..00000000 --- a/packages/react/src/collapse/__tests__/__snapshots__/collapse.test.tsx.snap +++ /dev/null @@ -1,100 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[` should match the snapshot 1`] = ` - -
-
-
- -
-
-
- Content 1 -
-
-
-
-
- -
-
-
- Content 2 -
-
-
-
-
-`; diff --git a/packages/react/src/collapse/__tests__/collapse.test.tsx b/packages/react/src/collapse/__tests__/collapse.test.tsx index ddb783de..9da75f53 100644 --- a/packages/react/src/collapse/__tests__/collapse.test.tsx +++ b/packages/react/src/collapse/__tests__/collapse.test.tsx @@ -1,45 +1,104 @@ import React from 'react'; -import { render, fireEvent } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import Collapse from '../index'; +const items = [ + { key: 'a', label: 'Header A', children: 'Content A' }, + { key: 'b', label: 'Header B', children: 'Content B' }, +]; + describe('', () => { - it('should match the snapshot', () => { - const { asFragment } = render( - - Content 1 - Content 2 - - ); - expect(asFragment()).toMatchSnapshot(); + it('should render correctly', () => { + const { container } = render(); + expect(container.firstChild).toHaveClass('ty-collapse'); }); - it('should render correctly', () => { - const { container } = render( - - Content - + it('should open from defaultValue using item keys', () => { + render(); + const trigger = screen.getByRole('button', { name: 'Header B' }); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + }); + + it('should toggle panels in uncontrolled mode', () => { + render(); + const trigger = screen.getByRole('button', { name: 'Header A' }); + + fireEvent.click(trigger); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + + fireEvent.click(trigger); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + }); + + it('should only emit changes in controlled mode', () => { + const onValueChange = jest.fn(); + + render(); + + const triggerA = screen.getByRole('button', { name: 'Header A' }); + const triggerB = screen.getByRole('button', { name: 'Header B' }); + + fireEvent.click(triggerB); + + expect(onValueChange).toHaveBeenCalledWith(['a', 'b']); + expect(triggerA).toHaveAttribute('aria-expanded', 'true'); + expect(triggerB).toHaveAttribute('aria-expanded', 'false'); + }); + + it('should keep only one expanded item when multiple is false', () => { + const onValueChange = jest.fn(); + + render( + ); - expect(container.firstChild).toHaveClass('ty-collapse'); + + fireEvent.click(screen.getByRole('button', { name: 'Header B' })); + + expect(onValueChange).toHaveBeenCalledWith(['b']); }); - it('should render panel headers', () => { - const { getByText } = render( - - Content 1 - Content 2 - + it('should respect disabled panels', () => { + render( + ); - expect(getByText('Header 1')).toBeInTheDocument(); - expect(getByText('Header 2')).toBeInTheDocument(); + + expect(screen.getByRole('button', { name: 'Header A' })).toBeDisabled(); }); - it('should toggle panel on click', () => { - const { getByText } = render( - - Content - + it('should support icon-only collapsible panels', () => { + render( + ); - fireEvent.click(getByText('Toggle')); - expect(getByText('Content')).toBeInTheDocument(); + + expect(screen.getByText('Header A').closest('button')).toBeNull(); + fireEvent.click(screen.getByRole('button')); + expect(screen.getByRole('region')).toBeInTheDocument(); + }); + + it('should call onItemClick before toggling', () => { + const onItemClick = jest.fn(); + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Header A' })); + expect(onItemClick).toHaveBeenCalledWith('a', expect.any(Object)); }); }); diff --git a/packages/react/src/collapse/collapse-context.ts b/packages/react/src/collapse/collapse-context.ts deleted file mode 100644 index 555082c0..00000000 --- a/packages/react/src/collapse/collapse-context.ts +++ /dev/null @@ -1,10 +0,0 @@ -import React from 'react'; - -type Props = { - activeKeys: string[]; - onItemClick?: (itemKey: string) => void; -}; - -export const CollapseContext = React.createContext({ - activeKeys: [], -}); diff --git a/packages/react/src/collapse/collapse-panel.tsx b/packages/react/src/collapse/collapse-panel.tsx old mode 100755 new mode 100644 index b1710f6b..82984443 --- a/packages/react/src/collapse/collapse-panel.tsx +++ b/packages/react/src/collapse/collapse-panel.tsx @@ -1,104 +1,175 @@ -import React, { useContext, useId, useRef } from 'react'; +import React, { useEffect, useId, useState } from 'react'; import classNames from 'classnames'; -import { ConfigContext } from '../config-provider/config-context'; -import { getPrefixCls } from '../_utils/general'; -import { CollapsePanelProps } from './types'; import { ArrowDown } from '../_utils/components'; import CollapseTransition from '../collapse-transition'; -import { CollapseContext } from './collapse-context'; - -/** - * Allow to parse active status to a node - * @param node - * @param isActive - */ -const richNode = (node: React.ReactNode | ((isActive: boolean) => React.ReactNode), isActive: boolean) => { - return typeof node === 'function' ? node(isActive) : node; +import { + CollapseCollapsible, + CollapseExpandIconRender, + CollapseItem, + CollapseRenderState, +} from './types'; + +type CollapsePanelProps = { + prefixCls: string; + item: CollapseItem; + active: boolean; + disabled?: boolean; + collapsible?: CollapseCollapsible; + showArrow: boolean; + expandIcon?: React.ReactNode | CollapseExpandIconRender; + expandIconPosition: 'start' | 'end'; + forceRender?: boolean; + destroyOnHidden?: boolean; + itemClassName?: string; + itemStyle?: React.CSSProperties; + headerClassName?: string; + bodyClassName?: string; + onItemClick?: (key: string, event: React.MouseEvent) => void; + onToggle: (key: string, event: React.MouseEvent) => void; +}; + +const renderContent = React.ReactNode)>( + content: T, + state: CollapseRenderState +) => { + return typeof content === 'function' ? content(state) : content; }; -const CollapsePanel = (props: CollapsePanelProps): React.ReactElement => { - const { - showArrow = true, - itemKey, - header, - disabled, - extra, - deletable, - onHeaderOnClick, - className, - style, - children, - prefixCls: customisedCls, - } = props; - const itemRef = useRef(null); +const CollapsePanel = ({ + prefixCls, + item, + active, + disabled = false, + collapsible = 'header', + showArrow, + expandIcon, + expandIconPosition, + forceRender = false, + destroyOnHidden = false, + itemClassName, + itemStyle, + headerClassName, + bodyClassName, + onItemClick, + onToggle, +}: CollapsePanelProps): React.ReactElement => { + const [bodyMounted, setBodyMounted] = useState(active || forceRender); const panelId = useId(); const headerId = useId(); - const configContext = useContext(ConfigContext); - const { activeKeys, onItemClick } = useContext(CollapseContext); - const prefixCls = getPrefixCls('collapse-item', configContext.prefixCls, customisedCls); - const active = activeKeys.includes(itemKey); - const cls = classNames(prefixCls, className, { - [`${prefixCls}_active`]: active, - }); - const headerOnClick = (e: React.MouseEvent) => { - if (!disabled) { - onHeaderOnClick && onHeaderOnClick(e); - onItemClick && onItemClick(itemKey); + useEffect(() => { + if (active || forceRender) { + setBodyMounted(true); } + }, [active, forceRender]); + + const requestedCollapsible = showArrow || collapsible !== 'icon' ? collapsible : 'header'; + const itemDisabled = disabled || item.disabled || requestedCollapsible === 'disabled'; + const layoutCollapsible: CollapseCollapsible = + requestedCollapsible === 'icon' ? 'icon' : 'header'; + const renderState: CollapseRenderState = { + active, + disabled: itemDisabled, + panelKey: item.key, }; - /** - * Remove a item from collapse only the header is enabled - * @param e - * @private - */ - const removeItem = (e: React.MouseEvent) => { - e.stopPropagation(); - if (!disabled) { - const node = itemRef.current; - node && node.parentNode?.removeChild(node); - } + const itemCls = classNames(`${prefixCls}-item`, itemClassName, item.className, { + [`${prefixCls}-item_active`]: active, + [`${prefixCls}-item_disabled`]: itemDisabled, + [`${prefixCls}-item_icon-only`]: layoutCollapsible === 'icon', + }); + + const iconCls = classNames(`${prefixCls}-item__arrow`, { + [`${prefixCls}-item__arrow_active`]: active, + }); + + const triggerToggle = (event: React.MouseEvent) => { + if (itemDisabled) return; + onItemClick?.(item.key, event); + onToggle(item.key, event); }; - const renderHeader = () => { - const headerCls = classNames(`${prefixCls}__header`, { - [`${prefixCls}__header_disabled`]: disabled, - }); - const arrowCls = classNames(`${prefixCls}__arrow`, { - [`${prefixCls}__arrow_active`]: active, - }); + const renderExpandIcon = () => { + if (!showArrow) return null; - const hasExtra = deletable || extra; + const iconNode = + typeof expandIcon === 'function' + ? expandIcon(renderState) + : expandIcon ?? ; - return ( -
+ const iconButtonCls = classNames(`${prefixCls}-item__icon-button`, { + [`${prefixCls}-item__icon-button_disabled`]: itemDisabled, + }); + + if (layoutCollapsible === 'icon') { + return ( - {hasExtra && ( -
- {deletable ? : richNode(extra, active)} -
- )} -
- ); + ); + } + + return {iconNode}; }; + const headerContent = renderContent(item.label, renderState); + const extraContent = item.extra ? renderContent(item.extra, renderState) : null; + const shouldRenderBody = bodyMounted || active || forceRender; + return ( -
- {renderHeader()} - -
{richNode(children, active)}
-
+
+
+ {layoutCollapsible === 'header' ? ( + + ) : ( + <> + {expandIconPosition === 'start' && renderExpandIcon()} +
+ {headerContent} +
+ {expandIconPosition === 'end' && renderExpandIcon()} + + )} + + {extraContent &&
{extraContent}
} +
+ + {shouldRenderBody && ( + setBodyMounted(false) : undefined} + > +
+ {item.children} +
+
+ )}
); }; diff --git a/packages/react/src/collapse/collapse.tsx b/packages/react/src/collapse/collapse.tsx index e7776637..547744fd 100644 --- a/packages/react/src/collapse/collapse.tsx +++ b/packages/react/src/collapse/collapse.tsx @@ -1,99 +1,116 @@ -import React, { useState, useEffect, useContext } from 'react'; +import React, { useContext, useEffect, useMemo, useState } from 'react'; import classNames from 'classnames'; import { ConfigContext } from '../config-provider/config-context'; -import { CollapseContext } from './collapse-context'; import { getPrefixCls } from '../_utils/general'; -import { CollapsePanelProps, CollapseProps } from './types'; +import CollapsePanel from './collapse-panel'; +import { CollapseProps, CollapseValue } from './types'; -/** - * Format active key to array - * @param activeKey - */ -const toArray = (activeKey: string | string[]): string[] => { - return Array.isArray(activeKey) ? activeKey : [activeKey]; +const normalizeValue = (value: string[] | undefined, multiple: boolean): CollapseValue => { + if (!value || value.length === 0) return []; + return multiple ? [...new Set(value)] : [value[0]]; }; -const Collapse = React.forwardRef( - (props: CollapseProps, ref): React.ReactElement => { - const { - showArrow = true, - bordered = true, - deletable = false, - accordion = false, - defaultActiveKey = [], - prefixCls: customisedCls, - activeKey, - onChange, - className, - children, - ...otherProps - } = props; - let currentActiveKey: string | string[] = defaultActiveKey; - if (activeKey) { - currentActiveKey = activeKey; +const Collapse = React.forwardRef((props, ref): React.ReactElement => { + const { + items, + value, + defaultValue, + onValueChange, + multiple = true, + bordered = true, + size = 'md', + showArrow = true, + expandIcon, + expandIconPosition = 'start', + disabled = false, + collapsible = 'header', + destroyOnHidden = false, + forceRender = false, + itemClassName, + itemStyle, + headerClassName, + bodyClassName, + onItemClick, + prefixCls: customisedCls, + className, + ...otherProps + } = props; + const configContext = useContext(ConfigContext); + const prefixCls = getPrefixCls('collapse', configContext.prefixCls, customisedCls); + const isControlled = value !== undefined; + const [innerValue, setInnerValue] = useState(() => + normalizeValue(defaultValue, multiple) + ); + + useEffect(() => { + if (!isControlled) return; + setInnerValue(normalizeValue(value, multiple)); + }, [isControlled, value, multiple]); + + const currentValue = isControlled ? normalizeValue(value, multiple) : innerValue; + + const itemMap = useMemo(() => new Set(items.map((item) => item.key)), [items]); + const activeValue = useMemo( + () => currentValue.filter((key) => itemMap.has(key)), + [currentValue, itemMap] + ); + + const cls = classNames(prefixCls, className, { + [`${prefixCls}_borderless`]: !bordered, + [`${prefixCls}_${size}`]: size, + }); + + const updateValue = (nextValue: CollapseValue) => { + const normalized = normalizeValue(nextValue, multiple); + + if (!isControlled) { + setInnerValue(normalized); } - const [activeItems, setActiveItems] = useState(toArray(currentActiveKey)); - const configContext = useContext(ConfigContext); - const prefixCls = getPrefixCls('collapse', configContext.prefixCls, customisedCls); - const cls = classNames(prefixCls, className, { - [`${prefixCls}_borderless`]: !bordered, - }); - const updateActiveItems = (items: string[]) => { - if (!('activeKey' in props)) { - // only for defaultKey - setActiveItems(items); - } - onChange && onChange(items); - }; + onValueChange?.(normalized); + }; - const handleOnItemClick = (itemKey: string) => { - let items = activeItems; - if (accordion) { - items = items[0] === itemKey ? [] : [itemKey]; - } else { - items = [...activeItems]; - const index = items.indexOf(itemKey); - const isActive = index > -1; - if (isActive) { - // remove active state - items.splice(index, 1); - } else { - items.push(itemKey); - } - } - updateActiveItems(items); - }; + const handleToggle = (key: string) => { + const isActive = activeValue.includes(key); + + if (multiple) { + updateValue( + isActive + ? activeValue.filter((activeKey) => activeKey !== key) + : [...activeValue, key] + ); + return; + } - useEffect(() => { - // Update state from updated props - activeKey && setActiveItems(toArray(activeKey)); - }, [activeKey]); + updateValue(isActive ? [] : [key]); + }; - return ( -
- - {React.Children.map(children, (child, idx) => { - const childElement = child as React.FunctionComponentElement; - if (childElement.type.displayName === 'CollapsePanel') { - const itemProps: Partial = { - deletable, - showArrow, - itemKey: `${idx}`, - }; - return React.cloneElement(childElement, itemProps); - } - return child; - })} - -
- ); - } -); + return ( +
+ {items.map((item) => ( + + ))} +
+ ); +}); Collapse.displayName = 'Collapse'; diff --git a/packages/react/src/collapse/demo/Accordion.tsx b/packages/react/src/collapse/demo/Accordion.tsx index ed23e4e9..5b3872d4 100644 --- a/packages/react/src/collapse/demo/Accordion.tsx +++ b/packages/react/src/collapse/demo/Accordion.tsx @@ -1,24 +1,32 @@ import React from 'react'; import { Collapse } from '@tiny-design/react'; -export default function AccordionDemo() { - const { Panel } = Collapse; - - const text = `A dog is a type of domesticated animal. +const text = `A dog is a type of domesticated animal. Known for its loyalty and faithfulness, it can be found as a welcome guest in many households across the world.`; +export default function AccordionDemo() { return ( - - -

{text}

-
- -

{text}

-
- -

{text}

-
-
+ {text}

, + }, + { + key: 'chapter-2', + label: 'Chapter 2', + children:

{text}

, + }, + { + key: 'chapter-3', + label: 'Chapter 3', + children:

{text}

, + }, + ]} + /> ); } diff --git a/packages/react/src/collapse/demo/Basic.tsx b/packages/react/src/collapse/demo/Basic.tsx index 7dcdad8e..e32b1bd8 100644 --- a/packages/react/src/collapse/demo/Basic.tsx +++ b/packages/react/src/collapse/demo/Basic.tsx @@ -1,28 +1,32 @@ import React from 'react'; import { Collapse } from '@tiny-design/react'; -export default function BasicDemo() { - const { Panel } = Collapse; - - const callback = (key: string | string[]) => { - console.log(key); - }; - - const text = `A dog is a type of domesticated animal. +const text = `A dog is a type of domesticated animal. Known for its loyalty and faithfulness, it can be found as a welcome guest in many households across the world.`; +export default function BasicDemo() { return ( - - -

{text}

-
- -

{text}

-
- -

{text}

-
-
+ {text}

, + }, + { + key: 'details', + label: 'Details', + children:

{text}

, + }, + { + key: 'disabled', + label: 'Disabled panel', + disabled: true, + children:

{text}

, + }, + ]} + /> ); } diff --git a/packages/react/src/collapse/demo/Borderless.tsx b/packages/react/src/collapse/demo/Borderless.tsx index c02b7aad..a49666a2 100644 --- a/packages/react/src/collapse/demo/Borderless.tsx +++ b/packages/react/src/collapse/demo/Borderless.tsx @@ -1,24 +1,30 @@ import React from 'react'; -import { Collapse } from '@tiny-design/react'; +import { Collapse, Tag } from '@tiny-design/react'; export default function BorderlessDemo() { - const { Panel } = Collapse; - - const text = `A dog is a type of domesticated animal. -Known for its loyalty and faithfulness, -it can be found as a welcome guest in many households across the world.`; - return ( - - -

{text}

-
- -

{text}

-
- -

{text}

-
-
+ Stable, + children: 'Borderless mode keeps the layout lighter when Collapse is used inside cards or side panels.', + }, + { + key: 'spacing', + label: 'Spacing rhythm', + children: 'Size presets affect both the trigger row and the content body spacing.', + }, + { + key: 'motion', + label: 'Motion tokens', + children: 'Motion is driven by the shared transition token instead of a hard-coded timeout.', + }, + ]} + /> ); -} \ No newline at end of file +} diff --git a/packages/react/src/collapse/demo/Deletable.tsx b/packages/react/src/collapse/demo/Deletable.tsx index 8a86bbba..20ebf54c 100644 --- a/packages/react/src/collapse/demo/Deletable.tsx +++ b/packages/react/src/collapse/demo/Deletable.tsx @@ -1,24 +1,83 @@ import React from 'react'; -import { Collapse } from '@tiny-design/react'; +import { Collapse, Button } from '@tiny-design/react'; + +type DemoItem = { + key: string; + label: string; + children: string; +}; export default function DeletableDemo() { - const { Panel } = Collapse; + const [items, setItems] = React.useState([ + { + key: 'architecture', + label: 'Architecture', + children: 'Use controlled state to remove items from the source array instead of mutating DOM nodes.', + }, + { + key: 'performance', + label: 'Performance', + children: 'The active value is filtered alongside the item list so removed panels do not leave stale state behind.', + }, + { + key: 'preload', + label: 'Preload', + children: 'This pattern works with any async source because the Collapse only consumes items data.', + }, + ]); + const [value, setValue] = React.useState(['architecture', 'performance']); + + const removeItem = (key: string) => { + setItems((currentItems) => currentItems.filter((item) => item.key !== key)); + setValue((currentValue) => currentValue.filter((activeKey) => activeKey !== key)); + }; - const text = `A dog is a type of domesticated animal. -Known for its loyalty and faithfulness, -it can be found as a welcome guest in many households across the world.`; + const resetItems = () => { + setItems([ + { + key: 'architecture', + label: 'Architecture', + children: 'Use controlled state to remove items from the source array instead of mutating DOM nodes.', + }, + { + key: 'performance', + label: 'Performance', + children: 'The active value is filtered alongside the item list so removed panels do not leave stale state behind.', + }, + { + key: 'preload', + label: 'Preload', + children: 'This pattern works with any async source because the Collapse only consumes items data.', + }, + ]); + setValue(['architecture', 'performance']); + }; return ( - - -

{text}

-
- -

{text}

-
- -

{text}

-
-
+ <> + +
+ ({ + ...item, + extra: ( + + ), + }))} + /> + ); -} \ No newline at end of file +} diff --git a/packages/react/src/collapse/demo/Extra.tsx b/packages/react/src/collapse/demo/Extra.tsx index b82143d9..536724af 100644 --- a/packages/react/src/collapse/demo/Extra.tsx +++ b/packages/react/src/collapse/demo/Extra.tsx @@ -1,42 +1,40 @@ import React from 'react'; -import { Collapse, Button, Badge, Tag } from '@tiny-design/react'; +import { Collapse, Badge, Button, Tag } from '@tiny-design/react'; export default function ExtraDemo() { - const { Panel } = Collapse; - return ( - - { - e.stopPropagation(); - alert('Settings clicked'); - }} - > - Settings - - } - > - Panel content with an extra action button. - - } - > - Panel content with a badge indicator. - - New} - > - Panel content with a tag. - - + `Recent activity${active ? ' opened' : ''}`, + extra: , + children: 'Header and extra content can be rendered from the item definition without composition wrappers.', + }, + { + key: 'release', + label: 'Release notes', + extra: ({ active }) => {active ? 'Live' : 'Draft'}, + children: 'Both label and extra accept render functions that receive the active and disabled state.', + }, + { + key: 'settings', + label: 'Panel with action', + extra: ( + + ), + children: 'Interactive controls inside extra should stop propagation if they should not toggle the panel.', + }, + ]} + /> ); -} \ No newline at end of file +} diff --git a/packages/react/src/collapse/demo/Nested.tsx b/packages/react/src/collapse/demo/Nested.tsx index 5c7f2cde..10f4cc7a 100644 --- a/packages/react/src/collapse/demo/Nested.tsx +++ b/packages/react/src/collapse/demo/Nested.tsx @@ -2,27 +2,33 @@ import React from 'react'; import { Collapse } from '@tiny-design/react'; export default function NestedDemo() { - const { Panel } = Collapse; - - const text = `A dog is a type of domesticated animal. -Known for its loyalty and faithfulness, -it can be found as a welcome guest in many households across the world.`; - return ( - - - - -

{text}

-
-
-
- -

{text}

-
- -

{text}

-
-
+ + ), + }, + { + key: 'parent-2', + label: 'Independent panel', + children: 'Nested content no longer relies on child index cloning, so ordering stays stable.', + }, + ]} + /> ); -} \ No newline at end of file +} diff --git a/packages/react/src/collapse/index.md b/packages/react/src/collapse/index.md old mode 100755 new mode 100644 index dce24145..fdd1cdf6 --- a/packages/react/src/collapse/index.md +++ b/packages/react/src/collapse/index.md @@ -13,49 +13,39 @@ import NestedSource from './demo/Nested.tsx?raw'; # Collapse -A content area which can be collapsed and expanded. - -## Scenario - -Can be used to group or hide complex regions to keep the page clean. - -`Accordion` is a special kind of `Collapse`, which allows only one panel to be expanded at a time. +Structured disclosure for dense information. ## Usage -```jsx +```tsx import { Collapse } from 'tiny-design'; - -const { Panel } = Collapse; ``` +`Collapse` is now fully items-driven. Each panel is defined by an item object and expanded state is modeled as `string[]`. + ## Examples -### Basic Collapse - -By default, any number of panels can be expanded at a time. The first panel is expanded in this example. +### Basic -### Accordion +### Single Open -Only one panel can be expanded at a time. +Set `multiple={false}` to limit the expanded state to one panel. -### Nested panel - -`Collapse` is nested inside the `Collapse`. +### Nested @@ -66,8 +56,6 @@ Only one panel can be expanded at a time. ### Borderless -A borderless style of Collapse. - @@ -75,16 +63,16 @@ A borderless style of Collapse. ### Deletable -`Panel` can be deleted. +Use `extra` plus controlled state to remove panels from the source array. -### Extra Content +### Dynamic Header Content -Add extra elements in the panel header corner with the `extra` prop. +`label`, `extra`, and `expandIcon` can react to panel state. @@ -96,24 +84,51 @@ Add extra elements in the panel header corner with the `extra` prop. ### Collapse -| Property | Description | Type | Default | -| ----------------- | --------------------------------------------------------- | --------------------------------- | --------- | -| defaultActiveKey | initial active panel | string | string[] | [] | -| activeKey | keys of the active panel | string | string[] | - | -| accordion | accordion mode | boolean | false | -| deletable | panel can be deleted | boolean | false | -| showArrow | display arrow icon | boolean | true | -| bordered | render borders around the collapse block | boolean | true | -| onChange | callback function executed when active panel is changed | (keys: string | string[]) => void | - | - -### Collapse.Panel - -| Property | Description | Type | Default | -| ----------------- | --------------------------------------------- | --------------------------------- | --------- | -| itemKey | unique key identifying the panel | string | - | -| header | title of the panel | ReactNode | - | -| disabled | panel cannot be opened or closed if set true | boolean | - | -| extra | extra element in the corner | ReactNode | - | -| deletable | whether the panel can be deleted | boolean | - | -| showArrow | display arrow icon | boolean | - | -| onHeaderOnClick | callback when the header is clicked | (e: React.MouseEvent) => void | - | \ No newline at end of file +| Property | Description | Type | Default | +| --- | --- | --- | --- | +| items | Panel definitions | `CollapseItem[]` | - | +| value | Controlled expanded keys | `string[]` | - | +| defaultValue | Initial expanded keys | `string[]` | `[]` | +| onValueChange | Callback when expanded keys change | `(value: string[]) => void` | - | +| multiple | Allow more than one panel to stay open | `boolean` | `true` | +| bordered | Show the outer border | `boolean` | `true` | +| size | Preset spacing and font size | `'sm' \| 'md' \| 'lg'` | `'md'` | +| showArrow | Render the expand icon slot | `boolean` | `true` | +| expandIcon | Custom expand icon node or render function | `ReactNode \| ((state) => ReactNode)` | - | +| expandIconPosition | Placement of the expand icon | `'start' \| 'end'` | `'start'` | +| disabled | Disable all panels | `boolean` | `false` | +| collapsible | Default trigger area for panels | `'header' \| 'icon' \| 'disabled'` | `'header'` | +| destroyOnHidden | Unmount panel content after close transition | `boolean` | `false` | +| forceRender | Pre-render every panel body | `boolean` | `false` | +| itemClassName | Shared class applied to every panel item | `string` | - | +| itemStyle | Shared style applied to every panel item | `CSSProperties` | - | +| headerClassName | Shared class applied to every panel header | `string` | - | +| bodyClassName | Shared class applied to every panel body | `string` | - | +| onItemClick | Callback when a panel trigger is activated | `(key: string, event: React.MouseEvent) => void` | - | + +### CollapseItem + +| Property | Description | Type | Default | +| --- | --- | --- | --- | +| key | Unique panel identifier | `string` | - | +| label | Header content or render function | `ReactNode \| ((state) => ReactNode)` | - | +| children | Panel content | `ReactNode` | - | +| extra | Extra content rendered at the header edge | `ReactNode \| ((state) => ReactNode)` | - | +| disabled | Disable a single panel | `boolean` | `false` | +| collapsible | Override the trigger area for a single panel | `'header' \| 'icon' \| 'disabled'` | inherited | +| forceRender | Pre-render this panel body | `boolean` | inherited | +| destroyOnHidden | Unmount this panel after close transition | `boolean` | inherited | +| className | Panel item class name | `string` | - | +| style | Panel item inline style | `CSSProperties` | - | + +### Render State + +Render callbacks receive: + +```ts +type CollapseRenderState = { + active: boolean; + disabled: boolean; + panelKey: string; +}; +``` diff --git a/packages/react/src/collapse/index.tsx b/packages/react/src/collapse/index.tsx old mode 100755 new mode 100644 index d716365a..fa64adf8 --- a/packages/react/src/collapse/index.tsx +++ b/packages/react/src/collapse/index.tsx @@ -1,11 +1,4 @@ import Collapse from './collapse'; -import CollapsePanel from './collapse-panel'; -type ICollapse = typeof Collapse & { - Panel: typeof CollapsePanel; -}; - -const DefaultCollapse = Collapse as ICollapse; -DefaultCollapse.Panel = CollapsePanel; - -export default DefaultCollapse; +export default Collapse; +export type * from './types'; diff --git a/packages/react/src/collapse/index.zh_CN.md b/packages/react/src/collapse/index.zh_CN.md index 6ed5cdb9..45d79993 100644 --- a/packages/react/src/collapse/index.zh_CN.md +++ b/packages/react/src/collapse/index.zh_CN.md @@ -13,49 +13,39 @@ import NestedSource from './demo/Nested.tsx?raw'; # Collapse 折叠面板 -可以折叠/展开的内容区域。 - -## 使用场景 - -可用于对复杂区域进行分组或隐藏,保持页面简洁。 - -`Accordion`(手风琴)是一种特殊的 `Collapse`,同一时间只允许展开一个面板。 +用于组织密集信息的分层展开组件。 ## 使用方式 -```jsx +```tsx import { Collapse } from 'tiny-design'; - -const { Panel } = Collapse; ``` +新版 `Collapse` 完全采用 `items` 数据驱动,展开状态统一建模为 `string[]`。 + ## 代码示例 -### 基本折叠 - -默认情况下,可以同时展开任意数量的面板。此示例中第一个面板是展开的。 +### 基本用法 -### 手风琴 +### 单项展开 -同一时间只能展开一个面板。 +设置 `multiple={false}` 后,同一时间只保留一个展开项。 -### 嵌套面板 - -`Collapse` 可以嵌套在 `Collapse` 内部。 +### 嵌套 @@ -66,8 +56,6 @@ const { Panel } = Collapse; ### 无边框 -无边框样式的折叠面板。 - @@ -75,16 +63,16 @@ const { Panel } = Collapse; ### 可删除 -面板可以被删除。 +通过 `extra` 配合受控状态,从数据源中移除面板。 -### 额外内容 +### 动态头部内容 -使用 `extra` 属性在面板头部角落添加额外元素。 +`label`、`extra` 和 `expandIcon` 都可以根据面板状态动态渲染。 @@ -96,24 +84,51 @@ const { Panel } = Collapse; ### Collapse -| 属性 | 说明 | 类型 | 默认值 | -| ----------------- | ----------------------------------------------------- | --------------------------------- | --------- | -| defaultActiveKey | 初始展开的面板 | string | string[] | [] | -| activeKey | 当前展开面板的 key | string | string[] | - | -| accordion | 手风琴模式 | boolean | false | -| deletable | 面板可删除 | boolean | false | -| showArrow | 显示箭头图标 | boolean | true | -| bordered | 在折叠区域周围渲染边框 | boolean | true | -| onChange | 展开面板变化时的回调函数 | (keys: string | string[]) => void | - | - -### Collapse.Panel - -| 属性 | 说明 | 类型 | 默认值 | -| ----------------- | --------------------------------------------- | --------------------------------- | --------- | -| itemKey | 面板的唯一标识 key | string | - | -| header | 面板标题 | ReactNode | - | -| disabled | 设为 true 时面板无法展开或折叠 | boolean | - | -| extra | 角落的额外元素 | ReactNode | - | -| deletable | 面板是否可删除 | boolean | - | -| showArrow | 显示箭头图标 | boolean | - | -| onHeaderOnClick | 点击头部时的回调 | (e: React.MouseEvent) => void | - | \ No newline at end of file +| 属性 | 说明 | 类型 | 默认值 | +| --- | --- | --- | --- | +| items | 面板配置数组 | `CollapseItem[]` | - | +| value | 当前展开项,受控模式 | `string[]` | - | +| defaultValue | 初始展开项 | `string[]` | `[]` | +| onValueChange | 展开项变化回调 | `(value: string[]) => void` | - | +| multiple | 是否允许同时展开多个面板 | `boolean` | `true` | +| bordered | 是否显示外层边框 | `boolean` | `true` | +| size | 预设尺寸 | `'sm' \| 'md' \| 'lg'` | `'md'` | +| showArrow | 是否渲染展开图标区域 | `boolean` | `true` | +| expandIcon | 自定义展开图标节点或渲染函数 | `ReactNode \| ((state) => ReactNode)` | - | +| expandIconPosition | 展开图标位置 | `'start' \| 'end'` | `'start'` | +| disabled | 是否禁用全部面板 | `boolean` | `false` | +| collapsible | 默认触发区域 | `'header' \| 'icon' \| 'disabled'` | `'header'` | +| destroyOnHidden | 收起后在过渡结束时卸载内容 | `boolean` | `false` | +| forceRender | 是否预渲染全部面板内容 | `boolean` | `false` | +| itemClassName | 统一的面板项类名 | `string` | - | +| itemStyle | 统一的面板项样式 | `CSSProperties` | - | +| headerClassName | 统一的头部类名 | `string` | - | +| bodyClassName | 统一的内容区类名 | `string` | - | +| onItemClick | 点击面板触发器时的回调 | `(key: string, event: React.MouseEvent) => void` | - | + +### CollapseItem + +| 属性 | 说明 | 类型 | 默认值 | +| --- | --- | --- | --- | +| key | 面板唯一标识 | `string` | - | +| label | 头部内容或渲染函数 | `ReactNode \| ((state) => ReactNode)` | - | +| children | 面板内容 | `ReactNode` | - | +| extra | 头部右侧额外内容 | `ReactNode \| ((state) => ReactNode)` | - | +| disabled | 是否禁用当前面板 | `boolean` | `false` | +| collapsible | 覆盖当前面板的触发区域 | `'header' \| 'icon' \| 'disabled'` | 继承父级 | +| forceRender | 预渲染当前面板内容 | `boolean` | 继承父级 | +| destroyOnHidden | 收起后在过渡结束时卸载当前面板内容 | `boolean` | 继承父级 | +| className | 面板项类名 | `string` | - | +| style | 面板项内联样式 | `CSSProperties` | - | + +### Render State + +渲染函数会收到: + +```ts +type CollapseRenderState = { + active: boolean; + disabled: boolean; + panelKey: string; +}; +``` diff --git a/packages/react/src/collapse/style/index.scss b/packages/react/src/collapse/style/index.scss index 9a00e2ec..910b21a6 100644 --- a/packages/react/src/collapse/style/index.scss +++ b/packages/react/src/collapse/style/index.scss @@ -1,118 +1,242 @@ @use "../../style/variables" as *; .#{$prefix}-collapse { + --ty-collapse-bg: var(--ty-color-bg-container); + --ty-collapse-border-color: var(--ty-color-border); + --ty-collapse-borderless-divider-color: var(--ty-color-border-secondary); + --ty-collapse-radius: var(--ty-border-radius); + --ty-collapse-header-bg: transparent; + --ty-collapse-header-hover-bg: var(--ty-color-fill); + --ty-collapse-header-active-bg: var(--ty-color-fill); + --ty-collapse-header-disabled-bg: transparent; + --ty-collapse-header-color: var(--ty-color-text); + --ty-collapse-header-disabled-color: var(--ty-color-text-quaternary); + --ty-collapse-header-min-height: 44px; + --ty-collapse-header-padding-inline: 16px; + --ty-collapse-header-padding-block: 14px; + --ty-collapse-header-gap: 12px; + --ty-collapse-icon-color: currentcolor; + --ty-collapse-icon-active-color: currentcolor; + --ty-collapse-icon-disabled-color: var(--ty-color-text-quaternary); + --ty-collapse-icon-slot-size: 20px; + --ty-collapse-icon-size: 10px; + --ty-collapse-extra-color: var(--ty-color-text-secondary); + --ty-collapse-extra-disabled-color: var(--ty-color-text-quaternary); + --ty-collapse-extra-gap: 8px; + --ty-collapse-extra-font-size: 12px; + --ty-collapse-body-bg: var(--ty-color-bg-container); + --ty-collapse-body-color: var(--ty-color-text-secondary); + --ty-collapse-body-padding-inline: 16px; + --ty-collapse-body-padding-block: 16px; + --ty-collapse-font-size: 14px; + --ty-collapse-line-height: 1.5; + --ty-collapse-focus-ring: var(--ty-control-outline, var(--ty-color-primary)); + --ty-collapse-motion-duration: var(--ty-motion-duration-mid, 240ms); + --ty-collapse-motion-easing: var(--ty-motion-ease-standard, ease); + box-sizing: border-box; - border-radius: var(--ty-collapse-radius, var(--ty-border-radius)); - color: var(--ty-collapse-color, var(--ty-color-text)); - font-size: var(--ty-collapse-font-size, 14px); - border: 1px solid var(--ty-collapse-border, var(--ty-color-border)); - border-bottom: 0; - background-color: var(--ty-collapse-bg, var(--ty-color-fill)); + border: 1px solid var(--ty-collapse-border-color); + border-radius: var(--ty-collapse-radius); + background: var(--ty-collapse-bg); + color: var(--ty-collapse-header-color); + font-size: var(--ty-collapse-font-size); + line-height: var(--ty-collapse-line-height); overflow: hidden; &-item { - box-sizing: border-box; - border-bottom: 1px solid var(--ty-collapse-border, var(--ty-color-border)); + border-bottom: 1px solid var(--ty-collapse-border-color); + background: inherit; &:last-child { - border-radius: 0 0 var(--ty-collapse-radius, var(--ty-border-radius)) var(--ty-collapse-radius, var(--ty-border-radius)); + border-bottom: 0; + } + + &_disabled { + .#{$prefix}-collapse-item__header { + cursor: not-allowed; + background: var(--ty-collapse-header-disabled-bg); + + &:hover { + background: var(--ty-collapse-header-disabled-bg); + } + } + + .#{$prefix}-collapse-item__label { + color: var(--ty-collapse-header-disabled-color); + } - .#{$prefix}-collapse-item__content { - border-radius: 0 0 var(--ty-collapse-radius, var(--ty-border-radius)) var(--ty-collapse-radius, var(--ty-border-radius)); + .#{$prefix}-collapse-item__arrow, + .#{$prefix}-collapse-item__extra { + color: var(--ty-collapse-icon-disabled-color); } } &__header { display: flex; align-items: center; - box-sizing: border-box; - position: relative; - color: var(--ty-collapse-header-color, var(--ty-color-text)); - line-height: var(--ty-collapse-header-line-height, 22px); - transition: all var(--ty-collapse-content-transition-duration, 300ms); + gap: var(--ty-collapse-header-gap); + min-height: var(--ty-collapse-header-min-height); + min-width: 0; + padding-inline-end: var(--ty-collapse-header-padding-inline); + background: var(--ty-collapse-header-bg); + transition: + background-color var(--ty-collapse-motion-duration) var(--ty-collapse-motion-easing), + color var(--ty-collapse-motion-duration) var(--ty-collapse-motion-easing); &:hover { - background-color: var(--ty-collapse-header-hover-bg, #efefef); + background: var(--ty-collapse-header-hover-bg); } + } - &_disabled { - color: var(--ty-collapse-header-disabled-color, var(--ty-color-text-quaternary)); - cursor: not-allowed; - } + &_active > .#{$prefix}-collapse-item__header { + background: var(--ty-collapse-header-active-bg); } &__toggle { - display: flex; - align-items: center; flex: 1; min-width: 0; - box-sizing: border-box; - padding: var(--ty-collapse-toggle-padding, 12px 16px); - cursor: pointer; + display: flex; + align-items: center; + gap: var(--ty-collapse-header-gap); + min-height: var(--ty-collapse-header-min-height); + padding: + var(--ty-collapse-header-padding-block) + var(--ty-collapse-header-padding-inline); background: none; - border: none; - width: 100%; - text-align: left; - font-size: inherit; - font-family: inherit; + border: 0; color: inherit; + font: inherit; line-height: inherit; + text-align: left; + cursor: pointer; + + &:disabled { + cursor: not-allowed; + color: inherit; + } + + &:focus-visible { + outline: 2px solid var(--ty-collapse-focus-ring); + outline-offset: -2px; + } } - &__header_disabled &__toggle { - cursor: not-allowed; + &__icon-slot, + &__icon-button { + flex: 0 0 auto; + width: var(--ty-collapse-icon-slot-size); + height: var(--ty-collapse-icon-slot-size); + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--ty-collapse-icon-color); + } + + &__icon-button { + padding: 0; + background: none; + border: 0; + color: inherit; + cursor: pointer; + border-radius: 50%; + + &:focus-visible { + outline: 2px solid var(--ty-collapse-focus-ring); + outline-offset: 2px; + } + + &_disabled { + cursor: not-allowed; + } } &__arrow { - margin-right: var(--ty-collapse-arrow-gap, 10px); + color: inherit; transform: rotate(-90deg); - text-align: center; - color: currentcolor; - transition: all var(--ty-collapse-content-transition-duration, 300ms); + transition: transform var(--ty-collapse-motion-duration) var(--ty-collapse-motion-easing); &_active { + color: var(--ty-collapse-icon-active-color); transform: rotate(0deg); } } - &__title { + &__label { flex: 1; + min-width: 0; + font-size: inherit; + line-height: inherit; + color: var(--ty-collapse-header-color); + + &_static { + padding-block: var(--ty-collapse-header-padding-block); + } } &__extra { - display: flex; + flex: 0 0 auto; + display: inline-flex; align-items: center; - color: inherit; - font-size: var(--ty-collapse-extra-font-size, 11px); - padding-right: var(--ty-collapse-extra-padding-inline-end, 16px); + gap: var(--ty-collapse-extra-gap); margin-left: auto; + color: var(--ty-collapse-extra-color); + font-size: var(--ty-collapse-extra-font-size); } - &__content { - overflow: hidden; - color: var(--ty-collapse-content-color, var(--ty-color-text-secondary)); - background-color: var(--ty-collapse-content-bg, var(--ty-color-bg-container)); - border-top: 1px solid var(--ty-collapse-border, var(--ty-color-border)); - padding: var(--ty-collapse-content-padding, 16px); + &__body-wrapper { + border-top: 1px solid var(--ty-collapse-border-color); + } + + &__body { + padding: + var(--ty-collapse-body-padding-block) + var(--ty-collapse-body-padding-inline); + color: var(--ty-collapse-body-color); + background: var(--ty-collapse-body-bg); box-sizing: border-box; - transition: height var(--ty-collapse-content-transition-duration, 300ms); } } &_borderless { border: 0; - background-color: var(--ty-collapse-borderless-bg, var(--ty-color-bg-container)); - } + background: var(--ty-collapse-borderless-bg, transparent); - &_borderless > .#{$prefix}-collapse-item { - &:last-child { - border-radius: 0; + > .#{$prefix}-collapse-item { + border-bottom-color: var(--ty-collapse-borderless-divider-color); + } + + > .#{$prefix}-collapse-item > .#{$prefix}-collapse-item__body-wrapper { + border-top: 0; } } - &_borderless > .#{$prefix}-collapse-item > .#{$prefix}-collapse-item__content { - border-radius: 0; - border-top: 0; - padding-top: var(--ty-collapse-borderless-content-padding-top, 4px); + &_sm { + --ty-collapse-font-size: 13px; + --ty-collapse-header-min-height: 36px; + --ty-collapse-header-padding-inline: 12px; + --ty-collapse-header-padding-block: 10px; + --ty-collapse-body-padding-inline: 12px; + --ty-collapse-body-padding-block: 12px; + --ty-collapse-icon-slot-size: 18px; + } + + &_md { + --ty-collapse-font-size: 14px; + --ty-collapse-header-min-height: 44px; + --ty-collapse-header-padding-inline: 16px; + --ty-collapse-header-padding-block: 14px; + --ty-collapse-body-padding-inline: 16px; + --ty-collapse-body-padding-block: 16px; + --ty-collapse-icon-slot-size: 20px; + } + + &_lg { + --ty-collapse-font-size: 15px; + --ty-collapse-header-min-height: 52px; + --ty-collapse-header-padding-inline: 18px; + --ty-collapse-header-padding-block: 16px; + --ty-collapse-body-padding-inline: 18px; + --ty-collapse-body-padding-block: 18px; + --ty-collapse-icon-slot-size: 22px; } } diff --git a/packages/react/src/collapse/types.ts b/packages/react/src/collapse/types.ts index 9e189b0e..858bad32 100644 --- a/packages/react/src/collapse/types.ts +++ b/packages/react/src/collapse/types.ts @@ -1,27 +1,52 @@ -import React, { ReactNode } from 'react'; -import { BaseProps } from '../_utils/props'; +import React, { CSSProperties, ReactNode } from 'react'; +import { BaseProps, SizeType } from '../_utils/props'; -export interface CollapsePanelProps extends BaseProps { - itemKey: string; - header: ReactNode; +export type CollapseValue = string[]; +export type CollapseCollapsible = 'header' | 'icon' | 'disabled'; + +export type CollapseRenderState = { + active: boolean; + disabled: boolean; + panelKey: string; +}; + +export type CollapseExpandIconRender = (args: CollapseRenderState) => ReactNode; +export type CollapseHeaderRender = (args: CollapseRenderState) => ReactNode; +export type CollapseExtraRender = (args: CollapseRenderState) => ReactNode; + +export interface CollapseItem { + key: string; + label: ReactNode | CollapseHeaderRender; + children: ReactNode; + extra?: ReactNode | CollapseExtraRender; disabled?: boolean; - extra?: ReactNode; - deletable?: boolean; - showArrow?: boolean; - onHeaderOnClick?: (e: React.MouseEvent) => void; - children?: ReactNode; + collapsible?: CollapseCollapsible; + forceRender?: boolean; + destroyOnHidden?: boolean; + className?: string; + style?: CSSProperties; } export interface CollapseProps extends BaseProps, - Omit, 'onChange'> { - defaultActiveKey?: string | string[]; - activeKey?: string | string[]; - /** Only open one panel */ - accordion?: boolean; - /** Allow to delete */ - deletable?: boolean; - showArrow?: boolean; + Omit, 'children' | 'onChange' | 'defaultValue'> { + items: CollapseItem[]; + value?: CollapseValue; + defaultValue?: CollapseValue; + onValueChange?: (value: CollapseValue) => void; + multiple?: boolean; bordered?: boolean; - onChange?: (keys: string | string[]) => void; + size?: SizeType; + expandIcon?: ReactNode | CollapseExpandIconRender; + expandIconPosition?: 'start' | 'end'; + showArrow?: boolean; + disabled?: boolean; + collapsible?: CollapseCollapsible; + destroyOnHidden?: boolean; + forceRender?: boolean; + itemClassName?: string; + itemStyle?: CSSProperties; + headerClassName?: string; + bodyClassName?: string; + onItemClick?: (key: string, event: React.MouseEvent) => void; } diff --git a/packages/tokens/source/components/collapse.json b/packages/tokens/source/components/collapse.json index 87b94b8e..c65f6d0b 100644 --- a/packages/tokens/source/components/collapse.json +++ b/packages/tokens/source/components/collapse.json @@ -1,32 +1,49 @@ { + "collapse.bg": { + "$value": "{color-bg-container}", + "$type": "color", + "description": "Collapse container background.", + "fallback": "--ty-color-bg-container" + }, + "collapse.border-color": { + "$value": "{color-border}", + "$type": "color", + "description": "Collapse container border color.", + "fallback": "--ty-color-border" + }, + "collapse.borderless-divider-color": { + "$value": "{color-border-secondary}", + "$type": "color", + "description": "Divider color used by borderless collapse panels.", + "fallback": "--ty-color-border-secondary" + }, "collapse.radius": { "$value": "{border-radius}", "$type": "dimension", - "description": "Collapse container border radius.", + "description": "Collapse container radius.", "fallback": "--ty-border-radius" }, - "collapse.color": { - "$value": "{color-text}", + "collapse.header-bg": { + "$value": "transparent", "$type": "color", - "description": "Collapse text color.", - "fallback": "--ty-color-text" + "description": "Default collapse header background." }, - "collapse.font-size": { - "$value": "14px", - "$type": "dimension", - "description": "Collapse font size." + "collapse.header-hover-bg": { + "$value": "{color-fill}", + "$type": "color", + "description": "Hover background for collapse headers.", + "fallback": "--ty-color-fill" }, - "collapse.bg": { + "collapse.header-active-bg": { "$value": "{color-fill}", "$type": "color", - "description": "Collapse background color.", + "description": "Background for expanded collapse headers.", "fallback": "--ty-color-fill" }, - "collapse.border": { - "$value": "{color-border}", + "collapse.header-disabled-bg": { + "$value": "transparent", "$type": "color", - "description": "Collapse border color.", - "fallback": "--ty-color-border" + "description": "Background for disabled collapse headers." }, "collapse.header-color": { "$value": "{color-text}", @@ -34,73 +51,128 @@ "description": "Collapse header text color.", "fallback": "--ty-color-text" }, - "collapse.header-line-height": { - "$value": "22px", + "collapse.header-disabled-color": { + "$value": "{color-text-quaternary}", + "$type": "color", + "description": "Text color for disabled collapse headers.", + "fallback": "--ty-color-text-quaternary" + }, + "collapse.header-min-height": { + "$value": "44px", "$type": "dimension", - "description": "Collapse header line height." + "description": "Minimum height of a medium collapse header." }, - "collapse.header-hover-bg": { - "$value": "#efefef", + "collapse.header-padding-inline": { + "$value": "16px", + "$type": "dimension", + "description": "Horizontal padding of the medium collapse header." + }, + "collapse.header-padding-block": { + "$value": "14px", + "$type": "dimension", + "description": "Vertical padding of the medium collapse header." + }, + "collapse.header-gap": { + "$value": "12px", + "$type": "dimension", + "description": "Gap between icon and header content." + }, + "collapse.icon-color": { + "$value": "{collapse.header-color}", "$type": "color", - "description": "Collapse header hover background color." + "description": "Default collapse icon color.", + "fallback": "--ty-collapse-header-color" }, - "collapse.header-disabled-color": { + "collapse.icon-active-color": { + "$value": "{collapse.header-color}", + "$type": "color", + "description": "Expanded collapse icon color.", + "fallback": "--ty-collapse-header-color" + }, + "collapse.icon-disabled-color": { "$value": "{color-text-quaternary}", "$type": "color", - "description": "Disabled collapse header color.", + "description": "Disabled collapse icon color.", "fallback": "--ty-color-text-quaternary" }, - "collapse.toggle-padding": { - "$value": "12px 16px", - "$type": "string", - "description": "Collapse toggle padding." + "collapse.icon-slot-size": { + "$value": "20px", + "$type": "dimension", + "description": "Reserved width and height for the collapse icon slot." }, - "collapse.arrow-gap": { + "collapse.icon-size": { "$value": "10px", "$type": "dimension", - "description": "Gap between collapse arrow and title." + "description": "Rendered collapse icon size." }, - "collapse.extra-font-size": { - "$value": "11px", + "collapse.extra-color": { + "$value": "{color-text-secondary}", + "$type": "color", + "description": "Collapse extra content color.", + "fallback": "--ty-color-text-secondary" + }, + "collapse.extra-disabled-color": { + "$value": "{color-text-quaternary}", + "$type": "color", + "description": "Collapse extra content color for disabled panels.", + "fallback": "--ty-color-text-quaternary" + }, + "collapse.extra-gap": { + "$value": "8px", "$type": "dimension", - "description": "Collapse extra content font size." + "description": "Gap between extra content items." }, - "collapse.extra-padding-inline-end": { - "$value": "16px", + "collapse.extra-font-size": { + "$value": "12px", "$type": "dimension", - "description": "Collapse extra content trailing padding." + "description": "Font size for collapse extra content." }, - "collapse.content-color": { + "collapse.body-bg": { + "$value": "{color-bg-container}", + "$type": "color", + "description": "Collapse body background.", + "fallback": "--ty-color-bg-container" + }, + "collapse.body-color": { "$value": "{color-text-secondary}", "$type": "color", - "description": "Collapse content text color.", + "description": "Collapse body text color.", "fallback": "--ty-color-text-secondary" }, - "collapse.content-bg": { - "$value": "{color-bg-container}", - "$type": "color", - "description": "Collapse content background color.", - "fallback": "--ty-color-bg-container" + "collapse.body-padding-inline": { + "$value": "16px", + "$type": "dimension", + "description": "Horizontal padding for the collapse body." }, - "collapse.content-padding": { + "collapse.body-padding-block": { "$value": "16px", "$type": "dimension", - "description": "Collapse content padding." + "description": "Vertical padding for the collapse body." }, - "collapse.content-transition-duration": { - "$value": "300ms", - "$type": "duration", - "description": "Collapse content height transition duration." + "collapse.font-size": { + "$value": "14px", + "$type": "dimension", + "description": "Collapse font size." }, - "collapse.borderless-bg": { - "$value": "{color-bg-container}", + "collapse.line-height": { + "$value": "1.5", + "$type": "number", + "description": "Collapse line height." + }, + "collapse.focus-ring": { + "$value": "{color-primary}", "$type": "color", - "description": "Borderless collapse background color.", - "fallback": "--ty-color-bg-container" + "description": "Focus ring color used by collapse triggers.", + "fallback": "--ty-color-primary" }, - "collapse.borderless-content-padding-top": { - "$value": "4px", - "$type": "dimension", - "description": "Borderless collapse content top padding." + "collapse.motion-duration": { + "$value": "240ms", + "$type": "duration", + "description": "Collapse expand and collapse motion duration." + }, + "collapse.motion-easing": { + "$value": "ease", + "$type": "string", + "description": "Collapse expand and collapse motion easing." } } diff --git a/packages/tokens/source/themes/dark.json b/packages/tokens/source/themes/dark.json index 120007d4..b7ae0375 100644 --- a/packages/tokens/source/themes/dark.json +++ b/packages/tokens/source/themes/dark.json @@ -98,10 +98,15 @@ "checkbox.border": "#424242", "checkbox.disabled-bg": "#2a2a2a", "collapse.bg": "#262626", - "collapse.border": "#424242", - "collapse.borderless-bg": "#1f1f1f", - "collapse.content-bg": "#1f1f1f", + "collapse.border-color": "#424242", + "collapse.borderless-divider-color": "#363636", + "collapse.body-bg": "#1f1f1f", "collapse.header-hover-bg": "#303030", + "collapse.header-active-bg": "#303030", + "collapse.header-disabled-color": "rgba(255, 255, 255, 0.35)", + "collapse.icon-disabled-color": "rgba(255, 255, 255, 0.35)", + "collapse.extra-color": "rgba(255, 255, 255, 0.65)", + "collapse.extra-disabled-color": "rgba(255, 255, 255, 0.35)", "descriptions.border": "#363636", "descriptions.label-bg": "#262626", "divider.color": "#363636", From e5439f56e29f8083d01b3b63311119b3a0112d88 Mon Sep 17 00:00:00 2001 From: wangdicoder Date: Wed, 15 Apr 2026 15:13:03 +1000 Subject: [PATCH 2/4] test: update snapshot --- .../react/src/tree/__tests__/__snapshots__/tree.test.tsx.snap | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/tree/__tests__/__snapshots__/tree.test.tsx.snap b/packages/react/src/tree/__tests__/__snapshots__/tree.test.tsx.snap index fabac959..82a7ffed 100644 --- a/packages/react/src/tree/__tests__/__snapshots__/tree.test.tsx.snap +++ b/packages/react/src/tree/__tests__/__snapshots__/tree.test.tsx.snap @@ -39,7 +39,7 @@ exports[` should match the snapshot 1`] = `
    Date: Wed, 15 Apr 2026 16:20:40 +1000 Subject: [PATCH 3/4] fix: refactor segment --- apps/docs/public/llms-full.txt | 20 +- apps/docs/public/llms.txt | 2 +- .../theme-studio/theme-document-adapter.ts | 6 +- packages/mcp/src/data/components.json | 178 ++++++++++++------ packages/react/src/flex/demo/Align.tsx | 18 +- .../__snapshots__/segmented.test.tsx.snap | 39 ++-- .../segmented/__tests__/segmented.test.tsx | 102 ++++++++-- packages/react/src/segmented/demo/Basic.tsx | 20 +- .../react/src/segmented/demo/Controlled.tsx | 23 +++ .../react/src/segmented/demo/DefaultValue.tsx | 18 ++ .../react/src/segmented/demo/Disabled.tsx | 11 +- .../react/src/segmented/demo/NoSelection.tsx | 17 ++ packages/react/src/segmented/demo/Size.tsx | 14 +- packages/react/src/segmented/index.md | 58 ++++-- packages/react/src/segmented/index.zh_CN.md | 68 +++++-- packages/react/src/segmented/segmented.tsx | 72 +++---- packages/react/src/segmented/style/index.scss | 63 +++++-- packages/react/src/segmented/types.ts | 20 +- packages/react/src/table/demo/Sizes.tsx | 6 +- .../tokens/source/components/segmented.json | 103 +++++++--- packages/tokens/source/themes/dark.json | 3 +- 21 files changed, 646 insertions(+), 215 deletions(-) create mode 100644 packages/react/src/segmented/demo/Controlled.tsx create mode 100644 packages/react/src/segmented/demo/DefaultValue.tsx create mode 100644 packages/react/src/segmented/demo/NoSelection.tsx 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
    \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 \n \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 \n \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 \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
\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 $182,400\n
\n \n
\n
\n
\n
\n \n \n
\n Conversion\n \n +1.8%\n \n
\n
\n 18.4%\n
\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" > 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 ( ); })} 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", From 535b5b463de6ecf83a36d2465335af81b6d9bd73 Mon Sep 17 00:00:00 2001 From: wangdicoder Date: Wed, 15 Apr 2026 16:23:43 +1000 Subject: [PATCH 4/4] chore: align changeset for collapse redesign --- .changeset/feat-collapse-redesign.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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.