diff --git a/.changeset/skeleton-animation-api-refresh.md b/.changeset/skeleton-animation-api-refresh.md new file mode 100644 index 000000000..8ae72c0be --- /dev/null +++ b/.changeset/skeleton-animation-api-refresh.md @@ -0,0 +1,6 @@ +--- +"@tiny-design/react": major +"@tiny-design/tokens": major +--- + +Redesign Skeleton animation APIs, component structure, and related configuration. diff --git a/packages/react/src/config-provider/config-context.tsx b/packages/react/src/config-provider/config-context.tsx index 3a608db12..bf9db6ae6 100644 --- a/packages/react/src/config-provider/config-context.tsx +++ b/packages/react/src/config-provider/config-context.tsx @@ -2,14 +2,19 @@ import React from 'react'; import { SizeType } from '../_utils/props'; import { SpaceSize } from '../space/types'; import { Locale } from '../locale/types'; +import { SkeletonAnimation } from '../skeleton/types'; import { ThemeConfig } from './token-utils'; export type ThemeMode = 'light' | 'dark' | 'system'; +export interface SkeletonConfig { + animation?: SkeletonAnimation; +} + export interface ConfigContextProps { prefixCls?: string; componentSize?: SizeType; - shimmer?: boolean; + skeleton?: SkeletonConfig; space?: SpaceSize; theme?: ThemeMode; themeConfig?: ThemeConfig; @@ -21,7 +26,7 @@ export interface ConfigContextProps { export const ConfigContext = React.createContext({ prefixCls: 'ty', componentSize: 'md', - shimmer: false, + skeleton: undefined, space: 'sm', getPopupContainer: () => document.body, getTargetContainer: () => window, diff --git a/packages/react/src/config-provider/config-provider.tsx b/packages/react/src/config-provider/config-provider.tsx index f87375bd3..5b2d87195 100644 --- a/packages/react/src/config-provider/config-provider.tsx +++ b/packages/react/src/config-provider/config-provider.tsx @@ -16,7 +16,7 @@ const ConfigProviderImpl = (props: ConfigProviderProps): React.ReactElement => { locale, prefixCls, componentSize, - shimmer, + skeleton, space, getPopupContainer, getTargetContainer, @@ -49,7 +49,10 @@ const ConfigProviderImpl = (props: ConfigProviderProps): React.ReactElement => { () => ({ prefixCls: prefixCls ?? parentConfig.prefixCls, componentSize: componentSize ?? parentConfig.componentSize, - shimmer: shimmer ?? parentConfig.shimmer, + skeleton: { + ...parentConfig.skeleton, + ...skeleton, + }, space: space ?? parentConfig.space, theme: mode ?? parentConfig.theme, themeConfig: themeConfig ?? parentConfig.themeConfig, @@ -65,7 +68,7 @@ const ConfigProviderImpl = (props: ConfigProviderProps): React.ReactElement => { mode, parentConfig, prefixCls, - shimmer, + skeleton, space, themeConfig, ] diff --git a/packages/react/src/config-provider/index.md b/packages/react/src/config-provider/index.md index fe44d91f9..9192e6e2d 100644 --- a/packages/react/src/config-provider/index.md +++ b/packages/react/src/config-provider/index.md @@ -226,8 +226,8 @@ const { componentSize, getPopupContainer, locale } = ConfigProvider.useConfig(); | ----------------- | ------------------------------------------------------------- | ------------------------------------------------- | --------- | | prefixCls | set prefix class. | string | ty | | componentSize | component size. | enum: `lg` | `md` | `sm` | `md` | -| shimmer | display shimmer effect for [Skeleton](#/components/skeleton). | boolean | false | -| space | set Space size, ref [Space](#/components/space). | enum: `sm` | `md` | `lg` or `number`. | `sm` | +| skeleton | global config for [Skeleton](../components/skeleton), such as animation. | `{ animation?: false \| 'pulse' \| 'shimmer' }` | - | +| space | set Space size, ref [Space](../components/space). | enum: `sm` | `md` | `lg` or `number`. | `sm` | | locale | set locale for components (e.g. `en_US`, `zh_CN`). | Locale | - | | getPopupContainer | set the container for popup-based components within this provider scope. | `(trigger?: HTMLElement \| null) => HTMLElement` | provider popup holder | | getTargetContainer | set the default scroll target for components such as `Anchor`, `Sticky`, and `BackTop`, and the scroll-lock target for layers such as `Overlay` and `Tour`. | `() => HTMLElement \| Window` | `() => window` | diff --git a/packages/react/src/config-provider/index.zh_CN.md b/packages/react/src/config-provider/index.zh_CN.md index 91befa419..9c4314bd7 100644 --- a/packages/react/src/config-provider/index.zh_CN.md +++ b/packages/react/src/config-provider/index.zh_CN.md @@ -226,8 +226,8 @@ const { componentSize, getPopupContainer, locale } = ConfigProvider.useConfig(); | ----------------- | ------------------------------------------------------------- | ------------------------------------------------- | --------- | | prefixCls | 设置类名前缀 | string | ty | | componentSize | 组件大小 | enum: `lg` | `md` | `sm` | `md` | -| shimmer | 为 [Skeleton](#/components/skeleton) 显示微光动画效果 | boolean | false | -| space | 设置 Space 间距,参考 [Space](#/components/space) | enum: `sm` | `md` | `lg` or `number`. | `sm` | +| skeleton | 为 [Skeleton](../components/skeleton) 提供全局配置,例如动画效果 | `{ animation?: false \| 'pulse' \| 'shimmer' }` | - | +| space | 设置 Space 间距,参考 [Space](../components/space) | enum: `sm` | `md` | `lg` or `number`. | `sm` | | locale | 设置组件语言包(如 `en_US`、`zh_CN`) | Locale | - | | getPopupContainer | 为当前 provider 作用域内的弹层组件指定挂载容器 | `(trigger?: HTMLElement \| null) => HTMLElement` | provider popup holder | | getTargetContainer | 为 `Anchor`、`Sticky`、`BackTop` 等组件设置默认滚动容器,同时为 `Overlay`、`Tour` 这类层级组件设置滚动锁目标 | `() => HTMLElement \| Window` | `() => window` | diff --git a/packages/react/src/image/demo/Placeholder.tsx b/packages/react/src/image/demo/Placeholder.tsx index f5c22e0f8..5c11e4140 100644 --- a/packages/react/src/image/demo/Placeholder.tsx +++ b/packages/react/src/image/demo/Placeholder.tsx @@ -36,7 +36,7 @@ export default function PlaceholderDemo() { height={180} src={src} alt="Mountain lake" - placeholder={} + placeholder={} /> ); diff --git a/packages/react/src/skeleton/__tests__/__snapshots__/skeleton.test.tsx.snap b/packages/react/src/skeleton/__tests__/__snapshots__/skeleton.test.tsx.snap index b8556307f..bef373ed0 100644 --- a/packages/react/src/skeleton/__tests__/__snapshots__/skeleton.test.tsx.snap +++ b/packages/react/src/skeleton/__tests__/__snapshots__/skeleton.test.tsx.snap @@ -4,8 +4,12 @@ exports[` should match the snapshot 1`] = `
+ > + `; diff --git a/packages/react/src/skeleton/__tests__/skeleton.test.tsx b/packages/react/src/skeleton/__tests__/skeleton.test.tsx index 8f26872e2..0bee46f90 100644 --- a/packages/react/src/skeleton/__tests__/skeleton.test.tsx +++ b/packages/react/src/skeleton/__tests__/skeleton.test.tsx @@ -10,21 +10,30 @@ describe('', () => { it('should render correctly', () => { const { container } = render(); - expect(container.firstChild).toHaveClass('ty-skeleton'); + expect(container.firstChild).toHaveAttribute('role', 'status'); + expect(container.querySelector('.ty-skeleton')).toBeInTheDocument(); + expect(container.querySelector('.ty-skeleton')).toHaveClass('ty-skeleton_round'); }); - it('should render with active animation', () => { - const { container } = render(); - expect(container.firstChild).toHaveClass('ty-skeleton_active'); + it('should render with shimmer animation', () => { + const { container } = render(); + expect(container.querySelector('.ty-skeleton')).toHaveClass('ty-skeleton_animated_shimmer'); }); - it('should render rounded', () => { - const { container } = render(); - expect(container.firstChild).toHaveClass('ty-skeleton_rounded'); + it('should render shaped block', () => { + const { container } = render(); + expect(container.querySelector('.ty-skeleton')).toHaveClass('ty-skeleton_circle'); }); - it('should render children', () => { - const { getByText } = render(Loading content); + it('should render children when loading is false', () => { + const { getByText, queryByRole } = render(Loading content); expect(getByText('Loading content')).toBeInTheDocument(); + expect(queryByRole('status')).not.toBeInTheDocument(); + }); + + it('should render structured skeleton', () => { + const { container } = render(); + expect(container.querySelector('.ty-skeleton__group')).toBeInTheDocument(); + expect(container.querySelectorAll('.ty-skeleton__text-row')).toHaveLength(3); }); }); diff --git a/packages/react/src/skeleton/demo/Active.tsx b/packages/react/src/skeleton/demo/Active.tsx index 2217e4915..cd56eedaa 100644 --- a/packages/react/src/skeleton/demo/Active.tsx +++ b/packages/react/src/skeleton/demo/Active.tsx @@ -1,13 +1,24 @@ import React from 'react'; -import { Skeleton } from '@tiny-design/react'; +import { Flex, Radio, Skeleton } from '@tiny-design/react'; + +type AnimationMode = 'shimmer' | 'pulse'; export default function ActiveDemo() { + const [animation, setAnimation] = React.useState('shimmer'); + return ( - <> - - - - - + + setAnimation(value as AnimationMode)}> + shimmer + pulse + + +
+ + + + +
+
); -} \ No newline at end of file +} diff --git a/packages/react/src/skeleton/demo/Basic.tsx b/packages/react/src/skeleton/demo/Basic.tsx index 00b742db5..8c4947cfe 100644 --- a/packages/react/src/skeleton/demo/Basic.tsx +++ b/packages/react/src/skeleton/demo/Basic.tsx @@ -4,10 +4,10 @@ import { Skeleton } from '@tiny-design/react'; export default function BasicDemo() { return ( <> - + ); -} \ No newline at end of file +} diff --git a/packages/react/src/skeleton/demo/Combination.tsx b/packages/react/src/skeleton/demo/Combination.tsx index 9db824c23..6afa8a9c5 100644 --- a/packages/react/src/skeleton/demo/Combination.tsx +++ b/packages/react/src/skeleton/demo/Combination.tsx @@ -1,25 +1,20 @@ import React from 'react'; -import { Skeleton, ConfigProvider, Row, Col } from '@tiny-design/react'; +import { Skeleton, ConfigProvider, Flex } from '@tiny-design/react'; export default function CombinationDemo() { return ( - - - - - - + + + +
- + +
-
- -
- -
- - - + + + +
); -} \ No newline at end of file +} diff --git a/packages/react/src/skeleton/demo/Composed.tsx b/packages/react/src/skeleton/demo/Composed.tsx new file mode 100644 index 000000000..49277a6ca --- /dev/null +++ b/packages/react/src/skeleton/demo/Composed.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { Flex, Skeleton, useTheme } from '@tiny-design/react'; + +export default function ComposedDemo() { + const { resolvedTheme } = useTheme(); + const isDark = resolvedTheme === 'dark'; + + const shellStyle: React.CSSProperties = { + padding: 18, + border: `1px solid ${isDark ? '#303030' : '#e2e8f0'}`, + borderRadius: 20, + background: isDark + ? 'linear-gradient(180deg, #1f1f1f 0%, #262626 100%)' + : 'linear-gradient(180deg, #ffffff 0%, #f8fafc 100%)', + boxShadow: isDark ? 'none' : '0 10px 30px rgba(15, 23, 42, 0.04)', + }; + + const sectionLabelStyle: React.CSSProperties = { + marginBottom: 14, + fontSize: 11, + fontWeight: 700, + letterSpacing: '0.08em', + textTransform: 'uppercase', + color: isDark ? '#8f8f8f' : '#64748b', + }; + + const statCardStyle: React.CSSProperties = { + padding: 12, + border: `1px solid ${isDark ? '#3a3a3a' : '#e2e8f0'}`, + borderRadius: 16, + background: isDark ? '#242424' : 'rgba(255, 255, 255, 0.75)', + }; + + return ( +
+
Profile Summary
+ + + + + + + + + +
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ + + + + + + + +
+
+
+ ); +} diff --git a/packages/react/src/skeleton/demo/Loading.tsx b/packages/react/src/skeleton/demo/Loading.tsx new file mode 100644 index 000000000..f4639093a --- /dev/null +++ b/packages/react/src/skeleton/demo/Loading.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { Button, Card, Flex, Skeleton, Tag } from '@tiny-design/react'; + +export default function LoadingDemo() { + const [loading, setLoading] = React.useState(true); + + return ( + + + + + + + + +
+
+
API Sync Health
+
Updated 2 minutes ago
+
+ + Healthy + +
+ 14 upstream services are responding within the expected latency band. No manual action is + required for the current release window. +
+ + + + + + ); +} diff --git a/packages/react/src/skeleton/index.md b/packages/react/src/skeleton/index.md index 16acf67ed..790c18499 100644 --- a/packages/react/src/skeleton/index.md +++ b/packages/react/src/skeleton/index.md @@ -4,6 +4,10 @@ import ActiveDemo from './demo/Active'; import ActiveSource from './demo/Active.tsx?raw'; import CombinationDemo from './demo/Combination'; import CombinationSource from './demo/Combination.tsx?raw'; +import LoadingDemo from './demo/Loading'; +import LoadingSource from './demo/Loading.tsx?raw'; +import ComposedDemo from './demo/Composed'; +import ComposedSource from './demo/Composed.tsx?raw'; # Skeleton @@ -23,7 +27,9 @@ import { Skeleton } from '@tiny-design/react'; ## Examples - + + + ### Basic @@ -31,31 +37,65 @@ Simplest Skeleton usage. - - + + -### Active +### Animation -Set `active={true}` to activate the Shimmer effect. +Switch between `shimmer` and `pulse` to compare the two animation styles. - - + + -### Combination +### Structured Skeleton -A complex example. +A structured loading placeholder. -> Consider using `` to set `active` prop in once. +> Consider using `` to set skeleton animation globally. - + + + + + +### Loading Switch + +Use `loading` to switch between the skeleton state and the real content. + + + + + + +### Composed Blocks + +Use `Skeleton.Block`, `Skeleton.Text`, and `Skeleton.Avatar` to build custom loading layouts. + + + + + + ## Props -| Property | Description | Type | Default | -| ----------------- | ------------------------------------------------- | ----------------------------- | --------- | -| active | turn on Shimmer effect. | boolean | false | -| rounded | display a circle skeleton | boolean | false | \ No newline at end of file +| Property | Description | Type | Default | +| ----------------- | ------------------------------------------------- | --------------------------------- | --------- | +| loading | render skeleton or `children` | boolean | true | +| shape | base skeleton shape | `rect` \| `round` \| `circle` | `round` | +| width | base skeleton width | string \| number | - | +| height | base skeleton height | string \| number | - | +| animation | animation style | boolean \| `pulse` \| `shimmer` | - | +| avatar | render avatar skeleton or pass avatar config | boolean \| object | false | +| title | render title skeleton or pass title config | boolean \| object | false | +| paragraph | render paragraph skeleton or pass paragraph config | boolean \| object | false | + +## Subcomponents + +- `Skeleton.Block`: low-level placeholder block with `shape`, `width`, `height`, and `animation` +- `Skeleton.Text`: text skeleton with `rows` and `widths` +- `Skeleton.Avatar`: avatar skeleton with `shape` and `size` diff --git a/packages/react/src/skeleton/index.zh_CN.md b/packages/react/src/skeleton/index.zh_CN.md index 1d8b61b2f..e26933d3e 100644 --- a/packages/react/src/skeleton/index.zh_CN.md +++ b/packages/react/src/skeleton/index.zh_CN.md @@ -4,6 +4,10 @@ import ActiveDemo from './demo/Active'; import ActiveSource from './demo/Active.tsx?raw'; import CombinationDemo from './demo/Combination'; import CombinationSource from './demo/Combination.tsx?raw'; +import LoadingDemo from './demo/Loading'; +import LoadingSource from './demo/Loading.tsx?raw'; +import ComposedDemo from './demo/Composed'; +import ComposedSource from './demo/Composed.tsx?raw'; # Skeleton 骨架屏 @@ -23,7 +27,9 @@ import { Skeleton } from '@tiny-design/react'; ## 代码示例 - + + + ### 基础用法 @@ -31,31 +37,65 @@ import { Skeleton } from '@tiny-design/react'; - - + + -### 动画效果 +### 动画参数 -设置 `active={true}` 开启微光动画效果。 +切换 `shimmer` 和 `pulse`,对比两种动画效果。 - - + + -### 组合使用 +### 结构化骨架 -一个复杂示例。 +一个结构化的加载占位示例。 -> 可以使用 `` 一次性设置 `shimmer` 属性。 +> 可以使用 `` 一次性设置骨架屏动画。 - + + + + + +### 加载态切换 + +通过 `loading` 在骨架屏和真实内容之间切换。 + + + + + + +### 组合式搭建 + +使用 `Skeleton.Block`、`Skeleton.Text`、`Skeleton.Avatar` 自定义更复杂的加载结构。 + + + + + + ## Props -| 属性 | 说明 | 类型 | 默认值 | -| ----------------- | ----------------------------------------- | ----------------------------- | --------- | -| active | 是否开启微光动画效果 | boolean | false | -| rounded | 是否显示为圆形骨架屏 | boolean | false | \ No newline at end of file +| 属性 | 说明 | 类型 | 默认值 | +| ----------------- | ----------------------------------------- | ----------------------------------- | --------- | +| loading | 是否显示骨架屏;为 `false` 时渲染 `children` | boolean | true | +| shape | 基础骨架形状 | `rect` \| `round` \| `circle` | `round` | +| width | 基础骨架宽度 | string \| number | - | +| height | 基础骨架高度 | string \| number | - | +| animation | 动画效果 | boolean \| `pulse` \| `shimmer` | - | +| avatar | 是否显示头像骨架,或传入头像配置 | boolean \| object | false | +| title | 是否显示标题骨架,或传入标题配置 | boolean \| object | false | +| paragraph | 是否显示段落骨架,或传入段落配置 | boolean \| object | false | + +## 子组件 + +- `Skeleton.Block`: 低层占位块,可单独控制 `shape`、`width`、`height`、`animation` +- `Skeleton.Text`: 文本行骨架,支持 `rows` 和 `widths` +- `Skeleton.Avatar`: 头像骨架,支持 `shape` 和 `size` diff --git a/packages/react/src/skeleton/skeleton.tsx b/packages/react/src/skeleton/skeleton.tsx old mode 100755 new mode 100644 index f88b8fe96..91f2080e4 --- a/packages/react/src/skeleton/skeleton.tsx +++ b/packages/react/src/skeleton/skeleton.tsx @@ -2,33 +2,278 @@ import React, { useContext } from 'react'; import classNames from 'classnames'; import { ConfigContext } from '../config-provider/config-context'; import { getPrefixCls } from '../_utils/general'; -import { SkeletonProps } from './types'; +import { + SkeletonAnimation, + SkeletonAvatarProps, + SkeletonBlockProps, + SkeletonProps, + SkeletonSize, + SkeletonTextProps, +} from './types'; -const Skeleton = React.memo(React.forwardRef( - (props: SkeletonProps, ref): JSX.Element => { +const getAnimationName = ( + animation: SkeletonAnimation | undefined, + configAnimation: SkeletonAnimation | undefined +): 'pulse' | 'shimmer' | null => { + if (animation === false) { + return null; + } + + if (animation === 'pulse' || animation === 'shimmer') { + return animation; + } + + if (animation === true) { + return 'shimmer'; + } + + if (configAnimation === 'pulse' || configAnimation === 'shimmer') { + return configAnimation; + } + + return null; +}; + +const getBlockStyle = ( + style: React.CSSProperties | undefined, + width?: number | string, + height?: number | string +): React.CSSProperties | undefined => { + if (width === undefined && height === undefined) { + return style; + } + + return { + ...style, + ...(width !== undefined ? { width } : null), + ...(height !== undefined ? { height } : null), + }; +}; + +const getAvatarSize = (size: SkeletonSize | undefined): number => { + if (typeof size === 'number') { + return size; + } + + switch (size) { + case 'sm': + return 32; + case 'lg': + return 48; + case 'md': + default: + return 40; + } +}; + +const SkeletonBlock = React.memo(React.forwardRef( + (props: SkeletonBlockProps, ref): JSX.Element => { const { - active = false, - rounded = false, className, - children, + style, prefixCls: customisedCls, + shape = 'round', + width, + height, + animation, ...otherProps } = props; const configContext = useContext(ConfigContext); const prefixCls = getPrefixCls('skeleton', configContext.prefixCls, customisedCls); + const animationName = getAnimationName(animation, configContext.skeleton?.animation); const cls = classNames(prefixCls, className, { - [`${prefixCls}_active`]: configContext.shimmer || active, - [`${prefixCls}_rounded`]: rounded, + [`${prefixCls}_${shape}`]: shape !== 'rect', + [`${prefixCls}_animated`]: Boolean(animationName), + [`${prefixCls}_animated_${animationName}`]: Boolean(animationName), }); return ( -
- {children} +