From 59ba21b83c5b8eca19bd5ef816c39f8e94d6f3eb Mon Sep 17 00:00:00 2001 From: Fengbao Li Date: Mon, 3 Aug 2026 23:46:30 +0800 Subject: [PATCH 01/39] docs(components): extract all component docs into .md via ?raw - Move every component's API doc (including the 8 previously inline strings and VirtualTextViewer's i18n key) into .apidoc.md, imported with Vite ?raw. - Remove the now-duplicated VirtualTextViewer.docs.component key from en-US/zh-CN. - Each doc is organized by the component's own characteristics (overview, when-to-use, API table, notes/usage) rather than a fixed template. - Preserves the existing autodocs render pipeline and EN/CN language toggle. Co-Authored-By: Hermes --- .storybook/locales/langs/en-US.ts | 2 - .storybook/locales/langs/zh-CN.ts | 2 - .../stories/components/BreakLines.apidoc.md | 24 ++++++++ .../stories/components/BreakLines.stories.tsx | 7 ++- .../components/ColumnSetting.apidoc.md | 39 +++++++++++++ .../components/ColumnSetting.stories.tsx | 13 +++-- .../components/ConfigProvider.apidoc.md | 2 + .../components/ConfigProvider.stories.tsx | 4 +- .../components/ConfirmAction.apidoc.md | 2 + .../components/ConfirmAction.stories.tsx | 4 +- .../stories/components/ContextMenu.apidoc.md | 56 +++++++++++++++++++ .../components/ContextMenu.stories.tsx | 10 +++- .../components/DeleteConfirmAction.apidoc.md | 2 + .../DeleteConfirmAction.stories.tsx | 4 +- .../stories/components/EditableText.apidoc.md | 3 + .../components/EditableText.stories.tsx | 5 +- .../components/EllipsisTypography.apidoc.md | 3 + .../components/EllipsisTypography.stories.tsx | 5 +- .../stories/components/FloatDrawer.apidoc.md | 39 +++++++++++++ .../components/FloatDrawer.stories.tsx | 8 +++ .../components/FormItemControl.apidoc.md | 28 ++++++++++ .../components/FormItemControl.stories.tsx | 8 +++ .../stories/components/Loading.apidoc.md | 29 ++++++++++ .../stories/components/Loading.stories.tsx | 8 +++ .../stories/components/ModalAction.apidoc.md | 2 + .../components/ModalAction.stories.tsx | 8 +-- .../stories/components/OverflowTags.apidoc.md | 31 ++++++++++ .../components/OverflowTags.stories.tsx | 8 +++ .../components/PulseAnimation.apidoc.md | 7 +++ .../components/PulseAnimation.stories.tsx | 4 +- .../components/VirtualTextViewer.apidoc.md | 2 + .../components/VirtualTextViewer.stories.tsx | 5 +- 32 files changed, 344 insertions(+), 30 deletions(-) create mode 100644 .storybook/stories/components/BreakLines.apidoc.md create mode 100644 .storybook/stories/components/ColumnSetting.apidoc.md create mode 100644 .storybook/stories/components/ConfigProvider.apidoc.md create mode 100644 .storybook/stories/components/ConfirmAction.apidoc.md create mode 100644 .storybook/stories/components/ContextMenu.apidoc.md create mode 100644 .storybook/stories/components/DeleteConfirmAction.apidoc.md create mode 100644 .storybook/stories/components/EditableText.apidoc.md create mode 100644 .storybook/stories/components/EllipsisTypography.apidoc.md create mode 100644 .storybook/stories/components/FloatDrawer.apidoc.md create mode 100644 .storybook/stories/components/FormItemControl.apidoc.md create mode 100644 .storybook/stories/components/Loading.apidoc.md create mode 100644 .storybook/stories/components/ModalAction.apidoc.md create mode 100644 .storybook/stories/components/OverflowTags.apidoc.md create mode 100644 .storybook/stories/components/PulseAnimation.apidoc.md create mode 100644 .storybook/stories/components/VirtualTextViewer.apidoc.md diff --git a/.storybook/locales/langs/en-US.ts b/.storybook/locales/langs/en-US.ts index 20ae27d..e708e44 100644 --- a/.storybook/locales/langs/en-US.ts +++ b/.storybook/locales/langs/en-US.ts @@ -80,8 +80,6 @@ const enUS = { 'storybook.stories.PulseAnimation.audioTrack': 'Starlight — Studio Session', 'storybook.stories.PulseAnimation.audioPlaying': 'Audio activity', 'storybook.stories.PulseAnimation.loadingTip': 'Loading...', - 'storybook.stories.VirtualTextViewer.docs.component': - '- **EN:** A simplified large-text virtualization component built on top of Pretext. It predicts wrapped line geometry without DOM text measurement and only materializes the visible line window.\n- **CN:** 一个基于 Pretext 的简化版大文本虚拟查看组件。它不依赖 DOM 文本测量来预测换行几何,并且只物化可见行窗口。', 'storybook.stories.VirtualTextViewer.header.title': 'Pretext Virtual Text Viewer', 'storybook.stories.VirtualTextViewer.header.subtitle': 'Scroll through a very large plain string while only rendering the visible lines.', diff --git a/.storybook/locales/langs/zh-CN.ts b/.storybook/locales/langs/zh-CN.ts index ef9fb6a..78c8703 100644 --- a/.storybook/locales/langs/zh-CN.ts +++ b/.storybook/locales/langs/zh-CN.ts @@ -79,8 +79,6 @@ const zhCN = { 'storybook.stories.PulseAnimation.audioTrack': '星光 — 录音室现场', 'storybook.stories.PulseAnimation.audioPlaying': '音频活动', 'storybook.stories.PulseAnimation.loadingTip': '加载中...', - 'storybook.stories.VirtualTextViewer.docs.component': - '- **EN:** A simplified large-text virtualization component built on top of Pretext. It predicts wrapped line geometry without DOM text measurement and only materializes the visible line window.\n- **CN:** 一个基于 Pretext 的简化版大文本虚拟查看组件。它不依赖 DOM 文本测量来预测换行几何,并且只物化可见行窗口。', 'storybook.stories.VirtualTextViewer.header.title': 'Pretext 虚拟文本查看器', 'storybook.stories.VirtualTextViewer.header.subtitle': '滚动浏览超大纯文本字符串,同时只渲染当前可见行。', 'storybook.stories.VirtualTextViewer.metrics.chars': '字符数:{{value}}', diff --git a/.storybook/stories/components/BreakLines.apidoc.md b/.storybook/stories/components/BreakLines.apidoc.md new file mode 100644 index 0000000..a4b53d2 --- /dev/null +++ b/.storybook/stories/components/BreakLines.apidoc.md @@ -0,0 +1,24 @@ +# BreakLines + +- **EN:** Renders plain text with preserved line breaks by splitting with a configurable EOL marker and output tag. +- **CN:** 按指定换行符拆分文本并保留换行展示,可自定义输出标签。 + +## When to use | 适用场景 + +- **EN:** Display backend-provided text that contains `\n` (or another EOL marker) and you want it shown as multiple visual lines instead of a single wrapped paragraph. +- **CN:** 展示后端返回、包含 `\n`(或其它换行符)的文本,希望按原换行分段显示,而不是被当作一整段自动折行。 + +## API + +| Prop | Type | Default | Description | +| --- | --- | --- | --- | +| `value` | `string \| undefined` | - | Text content to render | +| `enabled` | `boolean` | `true` | Whether line-break conversion is enabled | +| `EOL` | `string` | `'\n'` | The end-of-line character used to split lines | +| `tagName` | `false \| 'span' \| 'div' \| 'i' \| 'pre' \| (string & {})` | `false` | HTML tag used to render the content. When `false`, content is rendered as a React fragment | +| `className` | `string` | - | CSS class of the DOM node. Ignored when `tagName` is `false` | + +## Notes | 说明 + +- **EN:** When `tagName` is `false` (default), the content is returned as a fragment with `
` between segments, so it can be embedded inline. +- **CN:** `tagName` 为 `false`(默认)时,内容以 React fragment 返回,段间用 `
`,可内联嵌入其它文本中。 diff --git a/.storybook/stories/components/BreakLines.stories.tsx b/.storybook/stories/components/BreakLines.stories.tsx index 3f755a7..ebc80aa 100644 --- a/.storybook/stories/components/BreakLines.stories.tsx +++ b/.storybook/stories/components/BreakLines.stories.tsx @@ -1,12 +1,17 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import BreakLines, { type BreakLinesProps } from '../../../src/components/BreakLines'; import { storyT } from '../../locales'; +import apidoc from './BreakLines.apidoc.md?raw'; const meta: Meta = { title: 'Components/BreakLines', component: BreakLines, parameters: { - docs: {}, + docs: { + description: { + component: apidoc, + }, + }, }, args: { value: storyT('storybook.stories.BreakLines.args.value'), diff --git a/.storybook/stories/components/ColumnSetting.apidoc.md b/.storybook/stories/components/ColumnSetting.apidoc.md new file mode 100644 index 0000000..608b4c7 --- /dev/null +++ b/.storybook/stories/components/ColumnSetting.apidoc.md @@ -0,0 +1,39 @@ +# ColumnSetting + +- **EN:** Helps users manage table columns such as visibility and ordering, and can persist preferences with a storage key. +- **CN:** 用于管理表格列的显示与顺序,并可通过 `storageKey` 持久化用户偏好。 + +## When to use | 适用场景 + +- **EN:** You have an Ant Design `Table` and want to let users toggle column visibility / reorder columns, optionally remembering their choice across sessions. +- **CN:** 你使用 Ant Design 的 `Table`,希望让用户开关列显示 / 调整列顺序,并可选地在会话间记住其选择。 + +## API + +| Prop | Type | Default | Description | +| --- | --- | --- | --- | +| `columns` | `ColumnSettingItem[]` | - | Column definitions including visibility, ordering, and `disabled` state | +| `onChange` | `(nextColumns: T[]) => void` | - | Called when the selected columns change; returns columns with `hidden` updated | +| `storageKey` | `string` | - | Local storage key for persisting column settings. If unset, persistence is disabled | +| `renderColumnTitle` | `(col: ColumnSettingItem, index: number) => ReactNode` | - | Custom renderer for column titles | +| `triggerProps` | `ButtonProps` | - | Props for the button that opens the dropdown | +| `dropdownProps` | `DropdownProps` | - | Props for the dropdown component | +| `popupProps` | `React.HTMLAttributes` | - | Props for the dropdown popup container | +| `checkAllProps` | `ButtonProps` | - | Props for the "Check All" button | +| `resetProps` | `ButtonProps` | - | Props for the "Reset" button | +| `prefixCls` | `string` | - | Custom CSS class prefix | + +### `ColumnSettingItem` + +Extends Ant Design `ColumnType` and adds: + +| Field | Type | Description | +| --- | --- | --- | +| `disabled` | `boolean` | Disable toggling visibility for this column | + +## Notes | 说明 + +- **EN:** At least one column always remains visible — the last visible column's checkbox is disabled to prevent hiding everything. +- **CN:** 至少会保留一列可见:最后一列可见项的勾选框会被禁用,避免把所有列都隐藏掉。 +- **EN:** When `storageKey` is set, the selection is read from `localStorage` on mount and written back on every change. +- **CN:** 设置 `storageKey` 后,挂载时会从 `localStorage` 读取选择,并在每次变更时写回。 diff --git a/.storybook/stories/components/ColumnSetting.stories.tsx b/.storybook/stories/components/ColumnSetting.stories.tsx index 29b0a56..e2a9001 100644 --- a/.storybook/stories/components/ColumnSetting.stories.tsx +++ b/.storybook/stories/components/ColumnSetting.stories.tsx @@ -1,17 +1,22 @@ import { useEffect, useMemo, useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; +import { action } from 'storybook/actions'; import { Table } from 'antd'; import type { ColumnsType } from 'antd/es/table'; -import { action } from 'storybook/actions'; import ColumnSetting from '../../../src/components/ColumnSetting'; import type { ColumnSettingItem, ColumnSettingProps } from '../../../src/components/ColumnSetting'; import { storyT, useStoryT } from '../../locales'; +import apidoc from './ColumnSetting.apidoc.md?raw'; const meta: Meta = { title: 'Components/ColumnSetting', component: ColumnSetting, parameters: { - docs: {}, + docs: { + description: { + component: apidoc, + }, + }, }, args: { storageKey: 'storybook:column-setting', @@ -51,7 +56,7 @@ export const Playground: Story = { role: t('storybook.stories.ColumnSetting.data.viewer'), }, ], - [t] + [t], ); useEffect(() => { @@ -62,7 +67,7 @@ export const Playground: Story = { const tableColumns = useMemo>( () => columns.filter((col) => !col.hidden) as ColumnsType, - [columns] + [columns], ); return ( diff --git a/.storybook/stories/components/ConfigProvider.apidoc.md b/.storybook/stories/components/ConfigProvider.apidoc.md new file mode 100644 index 0000000..dc3ccb4 --- /dev/null +++ b/.storybook/stories/components/ConfigProvider.apidoc.md @@ -0,0 +1,2 @@ +- **EN:** Provides shared configuration such as locale-aware copy and default action text for components in ReactEasy. +- **CN:** 为ReactEasy提供共享配置,例如本地化文案和确认类组件的默认文本。 diff --git a/.storybook/stories/components/ConfigProvider.stories.tsx b/.storybook/stories/components/ConfigProvider.stories.tsx index bd1814c..e0b3a18 100644 --- a/.storybook/stories/components/ConfigProvider.stories.tsx +++ b/.storybook/stories/components/ConfigProvider.stories.tsx @@ -3,6 +3,7 @@ import { App as AntdApp, Space, Typography } from 'antd'; import ConfigProvider, { type ConfigProviderProps } from '../../../src/components/ConfigProvider'; import ConfirmAction from '../../../src/components/ConfirmAction'; import { storyT, useStoryT } from '../../locales'; +import apidoc from './ConfigProvider.apidoc.md?raw'; type ConfigProviderStoryArgs = ConfigProviderProps; @@ -12,8 +13,7 @@ const meta: Meta = { parameters: { docs: { description: { - component: `- **EN:** Provides shared configuration such as locale-aware copy and default action text for components in ReactEasy. -- **CN:** 为ReactEasy提供共享配置,例如本地化文案和确认类组件的默认文本。`, + component: apidoc, }, }, }, diff --git a/.storybook/stories/components/ConfirmAction.apidoc.md b/.storybook/stories/components/ConfirmAction.apidoc.md new file mode 100644 index 0000000..8d2e536 --- /dev/null +++ b/.storybook/stories/components/ConfirmAction.apidoc.md @@ -0,0 +1,2 @@ +- **EN:** Wraps a trigger component and opens a confirm-style modal before executing the action. Can switch between Button, Switch, and Link trigger types. +- **CN:** 对触发器组件进行封装,在真正执行操作前弹出确认对话框,可切换 Button、Switch、Link 三种触发方式。 diff --git a/.storybook/stories/components/ConfirmAction.stories.tsx b/.storybook/stories/components/ConfirmAction.stories.tsx index 5a3b281..b0eb7aa 100644 --- a/.storybook/stories/components/ConfirmAction.stories.tsx +++ b/.storybook/stories/components/ConfirmAction.stories.tsx @@ -6,6 +6,7 @@ import type { LinkProps } from 'antd/es/typography/Link'; import type { ConfirmActionProps } from '../../../src/components/ConfirmAction'; import ConfirmAction from '../../../src/components/ConfirmAction'; import { storyT } from '../../locales'; +import apidoc from './ConfirmAction.apidoc.md?raw'; type TriggerType = 'Button' | 'Switch' | 'Link'; @@ -18,8 +19,7 @@ const meta: Meta = { parameters: { docs: { description: { - component: `- **EN:** Wraps a trigger component and opens a confirm-style modal before executing the action. Can switch between Button, Switch, and Link trigger types. -- **CN:** 对触发器组件进行封装,在真正执行操作前弹出确认对话框,可切换 Button、Switch、Link 三种触发方式。`, + component: apidoc, }, }, }, diff --git a/.storybook/stories/components/ContextMenu.apidoc.md b/.storybook/stories/components/ContextMenu.apidoc.md new file mode 100644 index 0000000..0955ef4 --- /dev/null +++ b/.storybook/stories/components/ContextMenu.apidoc.md @@ -0,0 +1,56 @@ +# ContextMenu + +- **EN:** Renders a configurable context menu with support for shortcuts, separators, and nested submenu items. +- **CN:** 提供可配置的右键菜单能力,支持快捷键、分隔线和嵌套子菜单。 + +## When to use | 适用场景 + +- **EN:** You need a right-click (or other trigger) menu on an element, with keyboard shortcuts, separators and multi-level submenus. +- **CN:** 你需要在元素上提供右键(或其它触发方式)菜单,并支持快捷键、分隔线与多级子菜单。 + +## API + +| Prop | Type | Default | Description | +| --- | --- | --- | --- | +| `items` | `(ContextMenuItem \| ContextMenuSeparator \| ContextMenuSubmenu)[]` | - | Menu items to display | +| `trigger` | `('click' \| 'doubleClick' \| 'hover' \| 'contextMenu')[]` | `['contextMenu']` | How the menu is triggered | +| `triggerProps` | `{ className?: string; style?: CSSProperties }` | - | Props for the trigger element | +| `prefixCls` | `string` | - | Custom CSS class prefix | + +> Other `MenuProps` (from `react-contexify`) are forwarded to the underlying ``. + +### `ContextMenuItem` + +| Field | Type | Description | +| --- | --- | --- | +| `key` | `string` | Unique key | +| `label` | `ReactNode` | Item label | +| `icon` | `ReactNode` | Leading icon | +| `shortcutKey` | `Partial \| keyMatcher` | Keyboard shortcut matcher | +| `children` | `ReactNode` | Custom content (overrides `label`/`icon`) | + +### `ContextMenuSubmenu` + +| Field | Type | Description | +| --- | --- | --- | +| `key` | `string` | Unique key | +| `type` | `'submenu'` | Discriminator | +| `items` | `(ContextMenuItem \| ContextMenuSeparator \| ContextMenuSubmenu)[]` | Nested items | + +### `ContextMenuSeparator` + +| Field | Type | Description | +| --- | --- | --- | +| `type` | `'separator'` | Discriminator | + +### Ref — `ContextMenuRef` + +| Method | Signature | Description | +| --- | --- | --- | +| `show` | `(event: React.MouseEvent) => void` | Open the menu at the event position | +| `hideAll` | `() => void` | Close all context menus | + +## Notes | 说明 + +- **EN:** The `show`/`hideAll` methods are exposed via `ref`, useful for programmatic control. +- **CN:** `show` / `hideAll` 通过 `ref` 暴露,便于以编程方式控制菜单。 diff --git a/.storybook/stories/components/ContextMenu.stories.tsx b/.storybook/stories/components/ContextMenu.stories.tsx index 13f756f..0aff764 100644 --- a/.storybook/stories/components/ContextMenu.stories.tsx +++ b/.storybook/stories/components/ContextMenu.stories.tsx @@ -1,12 +1,20 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; -import { Button } from 'antd'; import { fn } from 'storybook/test'; +import { Button } from 'antd'; import ContextMenu from '../../../src/components/ContextMenu'; import { storyT, useStoryT } from '../../locales'; +import apidoc from './ContextMenu.apidoc.md?raw'; const meta: Meta = { title: 'Components/ContextMenu', component: ContextMenu, + parameters: { + docs: { + description: { + component: apidoc, + }, + }, + }, args: { trigger: ['contextMenu'], items: [ diff --git a/.storybook/stories/components/DeleteConfirmAction.apidoc.md b/.storybook/stories/components/DeleteConfirmAction.apidoc.md new file mode 100644 index 0000000..ff98ea8 --- /dev/null +++ b/.storybook/stories/components/DeleteConfirmAction.apidoc.md @@ -0,0 +1,2 @@ +- **EN:** Wraps a trigger component and opens a delete confirmation modal before executing the action. Can switch between Button, Switch, and Link trigger types. +- **CN:** 对触发器组件进行封装,在真正执行操作前弹出删除确认框,可切换 Button、Switch、Link 三种触发方式。 diff --git a/.storybook/stories/components/DeleteConfirmAction.stories.tsx b/.storybook/stories/components/DeleteConfirmAction.stories.tsx index 35d2e55..5808db1 100644 --- a/.storybook/stories/components/DeleteConfirmAction.stories.tsx +++ b/.storybook/stories/components/DeleteConfirmAction.stories.tsx @@ -6,6 +6,7 @@ import type { LinkProps } from 'antd/es/typography/Link'; import type { ConfirmActionProps } from '../../../src/components/ConfirmAction'; import DeleteConfirmAction from '../../../src/components/DeleteConfirmAction'; import { storyT } from '../../locales'; +import apidoc from './DeleteConfirmAction.apidoc.md?raw'; type TriggerType = 'Button' | 'Switch' | 'Link'; @@ -18,8 +19,7 @@ const meta: Meta = { parameters: { docs: { description: { - component: `- **EN:** Wraps a trigger component and opens a delete confirmation modal before executing the action. Can switch between Button, Switch, and Link trigger types. -- **CN:** 对触发器组件进行封装,在真正执行操作前弹出删除确认框,可切换 Button、Switch、Link 三种触发方式。`, + component: apidoc, }, }, }, diff --git a/.storybook/stories/components/EditableText.apidoc.md b/.storybook/stories/components/EditableText.apidoc.md new file mode 100644 index 0000000..10db6c1 --- /dev/null +++ b/.storybook/stories/components/EditableText.apidoc.md @@ -0,0 +1,3 @@ +- **EN:** Editable text component, providing a read-only display and an edit mode, supporting +inline text editing, with edit state, validation-related switches, and edit lifecycle callbacks. +- **CN:** 可编辑文本组件,提供只读显示和编辑模式,支持行内文本编辑,提供编辑态、校验相关开关和编辑生命周期回调。 diff --git a/.storybook/stories/components/EditableText.stories.tsx b/.storybook/stories/components/EditableText.stories.tsx index 4e08d07..98421c3 100644 --- a/.storybook/stories/components/EditableText.stories.tsx +++ b/.storybook/stories/components/EditableText.stories.tsx @@ -2,6 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { fn } from 'storybook/test'; import EditableText, { type EditableTextProps } from '../../../src/components/EditableText'; import { storyT } from '../../locales'; +import apidoc from './EditableText.apidoc.md?raw'; const meta: Meta = { title: 'Components/EditableText', @@ -9,9 +10,7 @@ const meta: Meta = { parameters: { docs: { description: { - component: `- **EN:** Editable text component, providing a read-only display and an edit mode, supporting -inline text editing, with edit state, validation-related switches, and edit lifecycle callbacks. -- **CN:** 可编辑文本组件,提供只读显示和编辑模式,支持行内文本编辑,提供编辑态、校验相关开关和编辑生命周期回调。`, + component: apidoc, }, }, }, diff --git a/.storybook/stories/components/EllipsisTypography.apidoc.md b/.storybook/stories/components/EllipsisTypography.apidoc.md new file mode 100644 index 0000000..90ec4e6 --- /dev/null +++ b/.storybook/stories/components/EllipsisTypography.apidoc.md @@ -0,0 +1,3 @@ +- **EN:** A set of typography wrapper components with ellipsis capabilities, covering text, paragraph, title, and link variants. +When the text overflows, the ellipsis effect is displayed; if the text does not overflow, the tooltip is not shown. +- **CN:** 一组带省略能力的排版封装组件,覆盖文本、段落、标题和链接几种形态。在文本溢出时显示省略效果,如果文本没有溢出,则不显示tooltip。 diff --git a/.storybook/stories/components/EllipsisTypography.stories.tsx b/.storybook/stories/components/EllipsisTypography.stories.tsx index 6c81e05..0e378f9 100644 --- a/.storybook/stories/components/EllipsisTypography.stories.tsx +++ b/.storybook/stories/components/EllipsisTypography.stories.tsx @@ -4,6 +4,7 @@ import EllipsisParagraphComponent from '../../../src/components/EllipsisTypograp import EllipsisTextComponent from '../../../src/components/EllipsisTypography/EllipsisText'; import EllipsisTitleComponent from '../../../src/components/EllipsisTypography/EllipsisTitle'; import { storyT } from '../../locales'; +import apidoc from './EllipsisTypography.apidoc.md?raw'; const longText = storyT('storybook.stories.EllipsisTypography.longText'); @@ -12,9 +13,7 @@ const meta: Meta = { parameters: { docs: { description: { - component: `- **EN:** A set of typography wrapper components with ellipsis capabilities, covering text, paragraph, title, and link variants. -When the text overflows, the ellipsis effect is displayed; if the text does not overflow, the tooltip is not shown. -- **CN:** 一组带省略能力的排版封装组件,覆盖文本、段落、标题和链接几种形态。在文本溢出时显示省略效果,如果文本没有溢出,则不显示tooltip。`, + component: apidoc, }, }, }, diff --git a/.storybook/stories/components/FloatDrawer.apidoc.md b/.storybook/stories/components/FloatDrawer.apidoc.md new file mode 100644 index 0000000..24ece75 --- /dev/null +++ b/.storybook/stories/components/FloatDrawer.apidoc.md @@ -0,0 +1,39 @@ +# FloatDrawer + +- **EN:** FloatDrawer is a draggable, resizable drawer that can sit at any edge of a parent container, with a toggle handle and optional size persistence. +- **CN:** FloatDrawer 是一个可拖动、可调整大小的浮动抽屉,可停靠在父容器的任意边缘,带展开/收起手柄,并可选记忆宽度。 + +## When to use | 适用场景 + +- **EN:** You want a side panel that overlays part of a container (not a full-screen modal), can be collapsed, resized by dragging, and optionally remembers its size. +- **CN:** 你需要一个覆盖在容器局部的侧边面板(而非全屏弹窗),可收起、可通过拖拽调整大小,并可选择性记忆尺寸。 + +## API + +| Prop | Type | Default | Description | +| --- | --- | --- | --- | +| `open` | `boolean` | - | Whether the drawer is open (controlled) | +| `position` | `'left' \| 'right' \| 'top' \| 'bottom'` | `'right'` | Drawer position | +| `defaultSize` | `number` | - | Default size (width for left/right, height otherwise); when unset, adapts to content | +| `minSize` | `number` | `0` | Minimum size | +| `maxSize` | `number` | `Infinity` | Maximum size | +| `edgeOffset` | `CSSProperties['width']` | `0` | Offset from the parent edge; tune so the drawer hides fully outside the container | +| `showToggle` | `boolean` | `true` | Whether to show the toggle handle | +| `resizable` | `boolean` | `true` | Whether the drawer is resizable | +| `destroyOnClose` | `boolean` | `false` | Destroy content when closed | +| `cacheKey` | `string` | - | localStorage key to persist drawer size | +| `cardProps` | `Omit` | - | Props for the inner `Card` | +| `className` / `style` | `string` / `CSSProperties` | - | Root class / style | +| `classNames` | `{ drawer; toggle; resizeHandle; handleIcon; content; card }` | - | Class names for specific parts | +| `styles` | `{ drawer; toggle; resizeHandle; handleIcon; content; card }` | - | Styles for specific parts | +| `onOpenChange` | `(open: boolean) => void` | - | Called when open state changes | +| `onResize` | `(size: number) => void` | - | Called when the drawer is resized | +| `onClick` | `(e: React.MouseEvent) => void` | - | Click handler for the drawer container | +| `children` | `ReactNode` | - | Drawer content | + +## Notes | 说明 + +- **EN:** Size is applied as `width` for left/right and `height` for top/bottom. Use `edgeOffset` to fully hide the drawer behind the parent's padding/border. +- **CN:** 尺寸对左右为 `width`、上下为 `height`。若抽屉未完全隐藏在父容器外,可用 `edgeOffset` 调整偏移。 +- **EN:** When `cacheKey` is set, the size is stored in `localStorage` and restored on next mount. +- **CN:** 设置 `cacheKey` 后,抽屉尺寸会存入 `localStorage`,下次挂载时恢复。 diff --git a/.storybook/stories/components/FloatDrawer.stories.tsx b/.storybook/stories/components/FloatDrawer.stories.tsx index d531ba7..a1ecaf9 100644 --- a/.storybook/stories/components/FloatDrawer.stories.tsx +++ b/.storybook/stories/components/FloatDrawer.stories.tsx @@ -2,10 +2,18 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { fn } from 'storybook/test'; import FloatDrawer from '../../../src/components/FloatDrawer'; import { storyT, useStoryT } from '../../locales'; +import apidoc from './FloatDrawer.apidoc.md?raw'; const meta: Meta = { title: 'Components/FloatDrawer', component: FloatDrawer, + parameters: { + docs: { + description: { + component: apidoc, + }, + }, + }, args: { open: true, position: 'right', diff --git a/.storybook/stories/components/FormItemControl.apidoc.md b/.storybook/stories/components/FormItemControl.apidoc.md new file mode 100644 index 0000000..a146eb2 --- /dev/null +++ b/.storybook/stories/components/FormItemControl.apidoc.md @@ -0,0 +1,28 @@ +# FormItemControl + +- **EN:** FormItemControl wraps custom content into a valid `Form.Item` control. It must be used as a direct child of `Form.Item`, and provides its children with `value` and `onChange` to interact with the form state. +- **CN:** FormItemControl 将自定义内容包装成有效的 `Form.Item` 控件,必须作为 `Form.Item` 的直接子节点使用,并向子组件提供 `value` 与 `onChange` 以与表单状态交互。 + +## When to use | 适用场景 + +- **EN:** You have a custom widget (slider, color picker, rich editor, …) that you want to plug into an Ant Design `Form` without reimplementing `value`/`onChange` wiring. +- **CN:** 你有一个自定义控件(滑块、取色器、富文本编辑器等),希望接入 Ant Design 的 `Form`,而不必自己重新实现 `value`/`onChange` 的对接。 + +## API + +| Prop | Type | Description | +| --- | --- | --- | +| `children` | `(options: { value: any; onChange: (value: any) => void }) => ReactNode` | Render prop receiving the current `value` and a setter `onChange` | + +## Usage | 用法 + +- **EN:** Place it inside ``. The render prop receives the form value and a change handler, so your custom control becomes a controlled form field. +- **CN:** 把它放在 `` 内部。渲染函数会收到表单 `value` 与一个变更回调,自定义控件即可成为受控表单字段。 + +```tsx + + + {({ value, onChange }) => } + + +``` diff --git a/.storybook/stories/components/FormItemControl.stories.tsx b/.storybook/stories/components/FormItemControl.stories.tsx index aa32e53..be36bbd 100644 --- a/.storybook/stories/components/FormItemControl.stories.tsx +++ b/.storybook/stories/components/FormItemControl.stories.tsx @@ -2,10 +2,18 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { Form, Slider, Typography } from 'antd'; import FormItemControl from '../../../src/components/FormItemControl'; import { useStoryT } from '../../locales'; +import apidoc from './FormItemControl.apidoc.md?raw'; const meta: Meta = { title: 'Components/FormItemControl', component: FormItemControl, + parameters: { + docs: { + description: { + component: apidoc, + }, + }, + }, argTypes: {}, }; diff --git a/.storybook/stories/components/Loading.apidoc.md b/.storybook/stories/components/Loading.apidoc.md new file mode 100644 index 0000000..3144649 --- /dev/null +++ b/.storybook/stories/components/Loading.apidoc.md @@ -0,0 +1,29 @@ +# Loading + +- **EN:** Displays a loading indicator that can work as an overlay (wrapping children) or as a standalone visual state that fills and centers within its parent. +- **CN:** 用于展示加载状态,可作为覆盖层(包裹 children),也可单独作为视觉占位:自动撑满父容器并居中显示。 + +## When to use | 适用场景 + +- **EN:** Show a spinner over existing content, or render a standalone centered loading placeholder inside a relatively-positioned container. +- **CN:** 在已有内容上叠加 spinner,或在相对定位的容器内渲染一个独立、居中、撑满的加载占位。 + +## Two usage modes | 两种用法 + +1. **Spin (overlay)** — pass `children`; the component wraps them with Ant Design `Spin` and toggles the animation via `spinning`. +2. **Standalone** — no children; renders a self-filled, centered loading animation. Hidden automatically when `spinning` is `false`. + +## API + +Extends Ant Design `SpinProps` and adds: + +| Prop | Type | Default | Description | +| --- | --- | --- | --- | +| `mode` | `'absolute' \| 'flex'` | `'flex'` | Positioning of the standalone animation. `absolute` centers via absolute positioning; `flex` fills the parent with flexbox | +| `rootClassName` | `string` | - | Class name for the mask parent container (standalone mode) | +| `rootStyle` | `CSSProperties` | - | Style for the mask parent container (standalone mode) | + +## Notes | 说明 + +- **EN:** In standalone mode the component fills its parent, so the parent should be `position: relative` (or otherwise sized) for correct layout. +- **CN:** 独立使用时组件会撑满父容器,因此父容器应为 `position: relative`(或具备确定尺寸)才能正确布局。 diff --git a/.storybook/stories/components/Loading.stories.tsx b/.storybook/stories/components/Loading.stories.tsx index b402d73..a2cf118 100644 --- a/.storybook/stories/components/Loading.stories.tsx +++ b/.storybook/stories/components/Loading.stories.tsx @@ -2,10 +2,18 @@ import type { ComponentProps } from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; import Loading from '../../../src/components/Loading'; import { storyT, useStoryT } from '../../locales'; +import apidoc from './Loading.apidoc.md?raw'; const meta: Meta = { title: 'Components/Loading', component: Loading, + parameters: { + docs: { + description: { + component: apidoc, + }, + }, + }, args: { mode: 'absolute', spinning: true, diff --git a/.storybook/stories/components/ModalAction.apidoc.md b/.storybook/stories/components/ModalAction.apidoc.md new file mode 100644 index 0000000..00984b0 --- /dev/null +++ b/.storybook/stories/components/ModalAction.apidoc.md @@ -0,0 +1,2 @@ +- **EN:** A modal-based action component that wraps a trigger and opens a form dialog before executing the action. The editing form is provided by the caller via `formComp`; the parent automatically creates the form instance and wires save/close lifecycle. Can switch between Button, Switch, and Link trigger types. +- **CN:** 基于弹框的动作组件,封装触发器并在真正执行操作前打开表单对话框。编辑表单由调用方通过 `formComp` 提供,父组件自动创建表单实例并接管保存/关闭生命周期。可切换 Button、Switch、Link 三种触发方式。 diff --git a/.storybook/stories/components/ModalAction.stories.tsx b/.storybook/stories/components/ModalAction.stories.tsx index 25db747..01951d1 100644 --- a/.storybook/stories/components/ModalAction.stories.tsx +++ b/.storybook/stories/components/ModalAction.stories.tsx @@ -1,11 +1,12 @@ import { type ComponentType, type FC, type RefAttributes, useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; +import { fn } from 'storybook/test'; import { type ButtonProps, Form, Input, message, Space, type SwitchProps } from 'antd'; import type { LinkProps } from 'antd/es/typography/Link'; -import { fn } from 'storybook/test'; import type { FormCompPropsConstraint, ModalActionProps } from '../../../src/components/ModalAction'; import { withModalAction } from '../../../src/components/ModalAction'; import { storyT, useStoryT } from '../../locales'; +import apidoc from './ModalAction.apidoc.md?raw'; type ModalActionStoryArgs = ModalActionProps< UserForm, @@ -46,7 +47,7 @@ const UserFormComp: FC = ({ data, form, onSave }) => { }; const UserModalAction = withModalAction( - UserFormComp as ComponentType> + UserFormComp as ComponentType>, ); const meta: Meta = { @@ -54,8 +55,7 @@ const meta: Meta = { parameters: { docs: { description: { - component: `- **EN:** A dynamic form modal component that supports multiple trigger variants. It is ideal for reusing the same form component in create and edit flows. -- **CN:** 一个动态表单的弹窗组件,并支持多种触发器形态。非常适合在创建和编辑流程中复用同一个表单组件。`, + component: apidoc, }, }, }, diff --git a/.storybook/stories/components/OverflowTags.apidoc.md b/.storybook/stories/components/OverflowTags.apidoc.md new file mode 100644 index 0000000..3f7b989 --- /dev/null +++ b/.storybook/stories/components/OverflowTags.apidoc.md @@ -0,0 +1,31 @@ +# OverflowTags + +- **EN:** Renders a tag collection with overflow collapsing, suitable for dense metadata or filter summaries. When the number of tags exceeds the maximum count, an ellipsis tag is shown; custom tag rendering and per-tag props are supported. +- **CN:** 用于渲染可折叠溢出的标签集合,适合密集元信息或筛选条件摘要场景。当标签数量超过最大显示数时,会显示省略号标签,并支持自定义标签渲染与属性。 + +## When to use | 适用场景 + +- **EN:** You show many tags in a tight space and want the extras collapsed into a "+N" popover instead of wrapping or scrolling. +- **CN:** 在有限空间内展示大量标签,希望多余的标签折叠成 "+N" 弹出层,而不是换行或滚动。 + +## API + +`OverflowTagsProps` extends `rc-overflow`'s `OverflowProps` (minus `renderItem`) and adds: + +| Prop | Type | Default | Description | +| --- | --- | --- | --- | +| `tags` | `T[]` | `[]` | Tag data collection | +| `getTagName` | `(tag: T) => ReactNode` | `tag.label` or `tag.name` | Resolve the tag display name | +| `getTagKey` | `(tag: T) => React.Key` | `tag.value` or `tag.id` | Resolve the tag unique key | +| `renderTag` | `OverflowProps['renderItem']` | - | Custom tag renderer | +| `tagProps` | `TagProps \| ((tag: T, { tags }) => TagProps)` | - | Props for the `Tag` component | +| `ellipsisTagProps` | `TagProps \| ((tag, { omittedItems, allTags }) => TagProps)` | - | Props for the ellipsis ("+N") tag | +| `ellipsisDropdownProps` | `DropdownProps` | - | Props for the overflow dropdown | +| `randomColors` | `boolean` | `false` | Use random preset colors. A `color` field on the tag takes precedence | + +## Notes | 说明 + +- **EN:** The overflow threshold is controlled by `maxCount` (from `rc-overflow`, default `'responsive'`), which you can pass through the base props. +- **CN:** 溢出阈值由 `maxCount`(来自 `rc-overflow`,默认 `'responsive'`)控制,可通过基础属性透传。 +- **EN:** If a tag carries its own `color`, that color wins over `randomColors`. +- **CN:** 若 tag 自身带 `color` 字段,则其优先级高于 `randomColors`。 diff --git a/.storybook/stories/components/OverflowTags.stories.tsx b/.storybook/stories/components/OverflowTags.stories.tsx index 231c1fb..a91b652 100644 --- a/.storybook/stories/components/OverflowTags.stories.tsx +++ b/.storybook/stories/components/OverflowTags.stories.tsx @@ -2,6 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { Typography } from 'antd'; import OverflowTags from '../../../src/components/OverflowTags'; import { storyT } from '../../locales'; +import apidoc from './OverflowTags.apidoc.md?raw'; interface TagItem { id: number; @@ -28,6 +29,13 @@ const tags: TagItem[] = [ const meta: Meta> = { title: 'Components/OverflowTags', component: OverflowTags, + parameters: { + docs: { + description: { + component: apidoc, + }, + }, + }, args: { tags, maxCount: 'responsive', diff --git a/.storybook/stories/components/PulseAnimation.apidoc.md b/.storybook/stories/components/PulseAnimation.apidoc.md new file mode 100644 index 0000000..05fe7c3 --- /dev/null +++ b/.storybook/stories/components/PulseAnimation.apidoc.md @@ -0,0 +1,7 @@ +- **EN:** A pulse bar animation component, suitable for audio activity or lightweight loading +feedback scenarios. + +> Use `token.AnimationPulse` to globally configure `barMinSize` and `barMaxSize` properties +- **CN:** 一个脉冲柱状动画组件,适合音频活动态或轻量加载反馈场景。 + +> 使用 `token.AnimationPulse` 来全局配置 `barMinSize` 和 `barMaxSize` 属性 diff --git a/.storybook/stories/components/PulseAnimation.stories.tsx b/.storybook/stories/components/PulseAnimation.stories.tsx index 30caca8..28707d3 100644 --- a/.storybook/stories/components/PulseAnimation.stories.tsx +++ b/.storybook/stories/components/PulseAnimation.stories.tsx @@ -2,6 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { Card, Space, Typography } from 'antd'; import PulseAnimation from '../../../src/components/Animation/Pulse'; import { useStoryT } from '../../locales'; +import apidoc from './PulseAnimation.apidoc.md?raw'; const meta: Meta = { title: 'Components/PulseAnimation', @@ -9,8 +10,7 @@ const meta: Meta = { parameters: { docs: { description: { - component: `- **EN:** An animated pulse-bar indicator suitable for audio-style activity or lightweight loading feedback. -- **CN:** 一个脉冲柱状动画组件,适合音频活动态或轻量加载反馈场景。`, + component: apidoc, }, }, }, diff --git a/.storybook/stories/components/VirtualTextViewer.apidoc.md b/.storybook/stories/components/VirtualTextViewer.apidoc.md new file mode 100644 index 0000000..08289b5 --- /dev/null +++ b/.storybook/stories/components/VirtualTextViewer.apidoc.md @@ -0,0 +1,2 @@ +- **EN:** A simplified large-text virtualization component built on top of Pretext. It predicts wrapped line geometry without DOM text measurement and only materializes the visible line window. +- **CN:** 一个基于 Pretext 的简化版大文本虚拟查看组件。它不依赖 DOM 文本测量来预测换行几何,并且只物化可见行窗口。 diff --git a/.storybook/stories/components/VirtualTextViewer.stories.tsx b/.storybook/stories/components/VirtualTextViewer.stories.tsx index f7581b8..d3c78af 100644 --- a/.storybook/stories/components/VirtualTextViewer.stories.tsx +++ b/.storybook/stories/components/VirtualTextViewer.stories.tsx @@ -4,6 +4,7 @@ import type { ThemeVars } from 'storybook/theming'; import VirtualTextViewer from '../../../src/components/VirtualTextViewer'; import { storyT, useStoryT } from '../../locales'; import { getGlobalValueFromUrl } from '../../utils/global'; +import apidoc from './VirtualTextViewer.apidoc.md?raw'; const demoText = buildLargeText(120, 60); const background = getGlobalValueFromUrl('backgrounds.value'); @@ -63,7 +64,7 @@ const meta: Meta = { parameters: { docs: { description: { - component: storyT('storybook.stories.VirtualTextViewer.docs.component'), + component: apidoc, }, }, }, @@ -155,7 +156,7 @@ function buildLargeText(sectionCount: number, rowsPerSection: number): string { storyT('storybook.stories.VirtualTextViewer.demo.viewerLine'), storyT('storybook.stories.VirtualTextViewer.demo.wrappingLine'), `https://example.com/reports/${section + 1}/${row + 1}?lang=zh-CN&mode=full&virtualization=on`, - ].join(' ') + ].join(' '), ); } From 3ee561fd8c6938fde73929619c5e8612cfd594c6 Mon Sep 17 00:00:00 2001 From: Fengbao Li Date: Tue, 4 Aug 2026 10:52:01 +0800 Subject: [PATCH 02/39] refactor(storybook): move component docs into per-component folders with index.stories.tsx - Split each flat XXX.stories.tsx + XXX.apidoc.md into a per-component folder - Rename main story to index.stories.tsx (matches Iconfont convention) - Split apidoc.md at '## API' into introduce.md (intro/usage) + api-doc.md (API tables) - Update import depths and docs description (introduce + apiDoc) - 15 components migrated: BreakLines, ColumnSetting, ConfigProvider, ConfirmAction, ContextMenu, DeleteConfirmAction, EditableText, EllipsisTypography, FloatDrawer, FormItemControl, Loading, ModalAction, OverflowTags, PulseAnimation, VirtualTextViewer - Verified via build-storybook (all index.stories.tsx scanned) and prettier --- .../stories/components/BreakLines.apidoc.md | 24 -------- .../stories/components/BreakLines/api-doc.md | 14 +++++ .../index.stories.tsx} | 9 +-- .../components/BreakLines/introduce.md | 9 +++ .../components/ColumnSetting.apidoc.md | 39 ------------- .../components/ColumnSetting/api-doc.md | 29 ++++++++++ .../index.stories.tsx} | 11 ++-- .../components/ColumnSetting/introduce.md | 9 +++ .../index.stories.tsx} | 10 ++-- .../introduce.md} | 0 .../index.stories.tsx} | 10 ++-- .../introduce.md} | 0 .../stories/components/ContextMenu.apidoc.md | 56 ------------------- .../stories/components/ContextMenu/api-doc.md | 46 +++++++++++++++ .../index.stories.tsx} | 9 +-- .../components/ContextMenu/introduce.md | 9 +++ .../index.stories.tsx} | 10 ++-- .../introduce.md} | 0 .../index.stories.tsx} | 8 +-- .../introduce.md} | 2 +- .../index.stories.tsx} | 14 ++--- .../introduce.md} | 2 +- .../stories/components/FloatDrawer.apidoc.md | 39 ------------- .../stories/components/FloatDrawer/api-doc.md | 29 ++++++++++ .../index.stories.tsx} | 9 +-- .../components/FloatDrawer/introduce.md | 9 +++ .../components/FormItemControl/api-doc.md | 16 ++++++ .../index.stories.tsx} | 9 +-- .../introduce.md} | 19 ------- .../stories/components/Loading/api-doc.md | 14 +++++ .../index.stories.tsx} | 9 +-- .../introduce.md} | 15 ----- .../index.stories.tsx} | 10 ++-- .../introduce.md} | 0 .../stories/components/OverflowTags.apidoc.md | 31 ---------- .../components/OverflowTags/api-doc.md | 21 +++++++ .../index.stories.tsx} | 9 +-- .../components/OverflowTags/introduce.md | 9 +++ .../index.stories.tsx} | 8 +-- .../introduce.md} | 3 +- .../index.stories.tsx} | 10 ++-- .../introduce.md} | 0 42 files changed, 294 insertions(+), 295 deletions(-) delete mode 100644 .storybook/stories/components/BreakLines.apidoc.md create mode 100644 .storybook/stories/components/BreakLines/api-doc.md rename .storybook/stories/components/{BreakLines.stories.tsx => BreakLines/index.stories.tsx} (68%) create mode 100644 .storybook/stories/components/BreakLines/introduce.md delete mode 100644 .storybook/stories/components/ColumnSetting.apidoc.md create mode 100644 .storybook/stories/components/ColumnSetting/api-doc.md rename .storybook/stories/components/{ColumnSetting.stories.tsx => ColumnSetting/index.stories.tsx} (92%) create mode 100644 .storybook/stories/components/ColumnSetting/introduce.md rename .storybook/stories/components/{ConfigProvider.stories.tsx => ConfigProvider/index.stories.tsx} (84%) rename .storybook/stories/components/{ConfigProvider.apidoc.md => ConfigProvider/introduce.md} (100%) rename .storybook/stories/components/{ConfirmAction.stories.tsx => ConfirmAction/index.stories.tsx} (94%) rename .storybook/stories/components/{ConfirmAction.apidoc.md => ConfirmAction/introduce.md} (100%) delete mode 100644 .storybook/stories/components/ContextMenu.apidoc.md create mode 100644 .storybook/stories/components/ContextMenu/api-doc.md rename .storybook/stories/components/{ContextMenu.stories.tsx => ContextMenu/index.stories.tsx} (85%) create mode 100644 .storybook/stories/components/ContextMenu/introduce.md rename .storybook/stories/components/{DeleteConfirmAction.stories.tsx => DeleteConfirmAction/index.stories.tsx} (93%) rename .storybook/stories/components/{DeleteConfirmAction.apidoc.md => DeleteConfirmAction/introduce.md} (100%) rename .storybook/stories/components/{EditableText.stories.tsx => EditableText/index.stories.tsx} (91%) rename .storybook/stories/components/{EditableText.apidoc.md => EditableText/introduce.md} (72%) rename .storybook/stories/components/{EllipsisTypography.stories.tsx => EllipsisTypography/index.stories.tsx} (89%) rename .storybook/stories/components/{EllipsisTypography.apidoc.md => EllipsisTypography/introduce.md} (73%) delete mode 100644 .storybook/stories/components/FloatDrawer.apidoc.md create mode 100644 .storybook/stories/components/FloatDrawer/api-doc.md rename .storybook/stories/components/{FloatDrawer.stories.tsx => FloatDrawer/index.stories.tsx} (81%) create mode 100644 .storybook/stories/components/FloatDrawer/introduce.md create mode 100644 .storybook/stories/components/FormItemControl/api-doc.md rename .storybook/stories/components/{FormItemControl.stories.tsx => FormItemControl/index.stories.tsx} (85%) rename .storybook/stories/components/{FormItemControl.apidoc.md => FormItemControl/introduce.md} (52%) create mode 100644 .storybook/stories/components/Loading/api-doc.md rename .storybook/stories/components/{Loading.stories.tsx => Loading/index.stories.tsx} (82%) rename .storybook/stories/components/{Loading.apidoc.md => Loading/introduce.md} (54%) rename .storybook/stories/components/{ModalAction.stories.tsx => ModalAction/index.stories.tsx} (96%) rename .storybook/stories/components/{ModalAction.apidoc.md => ModalAction/introduce.md} (100%) delete mode 100644 .storybook/stories/components/OverflowTags.apidoc.md create mode 100644 .storybook/stories/components/OverflowTags/api-doc.md rename .storybook/stories/components/{OverflowTags.stories.tsx => OverflowTags/index.stories.tsx} (91%) create mode 100644 .storybook/stories/components/OverflowTags/introduce.md rename .storybook/stories/components/{PulseAnimation.stories.tsx => PulseAnimation/index.stories.tsx} (95%) rename .storybook/stories/components/{PulseAnimation.apidoc.md => PulseAnimation/introduce.md} (94%) rename .storybook/stories/components/{VirtualTextViewer.stories.tsx => VirtualTextViewer/index.stories.tsx} (94%) rename .storybook/stories/components/{VirtualTextViewer.apidoc.md => VirtualTextViewer/introduce.md} (100%) diff --git a/.storybook/stories/components/BreakLines.apidoc.md b/.storybook/stories/components/BreakLines.apidoc.md deleted file mode 100644 index a4b53d2..0000000 --- a/.storybook/stories/components/BreakLines.apidoc.md +++ /dev/null @@ -1,24 +0,0 @@ -# BreakLines - -- **EN:** Renders plain text with preserved line breaks by splitting with a configurable EOL marker and output tag. -- **CN:** 按指定换行符拆分文本并保留换行展示,可自定义输出标签。 - -## When to use | 适用场景 - -- **EN:** Display backend-provided text that contains `\n` (or another EOL marker) and you want it shown as multiple visual lines instead of a single wrapped paragraph. -- **CN:** 展示后端返回、包含 `\n`(或其它换行符)的文本,希望按原换行分段显示,而不是被当作一整段自动折行。 - -## API - -| Prop | Type | Default | Description | -| --- | --- | --- | --- | -| `value` | `string \| undefined` | - | Text content to render | -| `enabled` | `boolean` | `true` | Whether line-break conversion is enabled | -| `EOL` | `string` | `'\n'` | The end-of-line character used to split lines | -| `tagName` | `false \| 'span' \| 'div' \| 'i' \| 'pre' \| (string & {})` | `false` | HTML tag used to render the content. When `false`, content is rendered as a React fragment | -| `className` | `string` | - | CSS class of the DOM node. Ignored when `tagName` is `false` | - -## Notes | 说明 - -- **EN:** When `tagName` is `false` (default), the content is returned as a fragment with `
` between segments, so it can be embedded inline. -- **CN:** `tagName` 为 `false`(默认)时,内容以 React fragment 返回,段间用 `
`,可内联嵌入其它文本中。 diff --git a/.storybook/stories/components/BreakLines/api-doc.md b/.storybook/stories/components/BreakLines/api-doc.md new file mode 100644 index 0000000..4b4fba0 --- /dev/null +++ b/.storybook/stories/components/BreakLines/api-doc.md @@ -0,0 +1,14 @@ +## API + +| Prop | Type | Default | Description | +| ----------- | ----------------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------ | +| `value` | `string \| undefined` | - | Text content to render | +| `enabled` | `boolean` | `true` | Whether line-break conversion is enabled | +| `EOL` | `string` | `'\n'` | The end-of-line character used to split lines | +| `tagName` | `false \| 'span' \| 'div' \| 'i' \| 'pre' \| (string & {})` | `false` | HTML tag used to render the content. When `false`, content is rendered as a React fragment | +| `className` | `string` | - | CSS class of the DOM node. Ignored when `tagName` is `false` | + +## Notes | 说明 + +- **EN:** When `tagName` is `false` (default), the content is returned as a fragment with `
` between segments, so it can be embedded inline. +- **CN:** `tagName` 为 `false`(默认)时,内容以 React fragment 返回,段间用 `
`,可内联嵌入其它文本中。 diff --git a/.storybook/stories/components/BreakLines.stories.tsx b/.storybook/stories/components/BreakLines/index.stories.tsx similarity index 68% rename from .storybook/stories/components/BreakLines.stories.tsx rename to .storybook/stories/components/BreakLines/index.stories.tsx index ebc80aa..fb4f908 100644 --- a/.storybook/stories/components/BreakLines.stories.tsx +++ b/.storybook/stories/components/BreakLines/index.stories.tsx @@ -1,7 +1,8 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; -import BreakLines, { type BreakLinesProps } from '../../../src/components/BreakLines'; -import { storyT } from '../../locales'; -import apidoc from './BreakLines.apidoc.md?raw'; +import BreakLines, { type BreakLinesProps } from '../../../../src/components/BreakLines'; +import { storyT } from '../../../locales'; +import apiDoc from './api-doc.md?raw'; +import introduce from './introduce.md?raw'; const meta: Meta = { title: 'Components/BreakLines', @@ -9,7 +10,7 @@ const meta: Meta = { parameters: { docs: { description: { - component: apidoc, + component: introduce + apiDoc, }, }, }, diff --git a/.storybook/stories/components/BreakLines/introduce.md b/.storybook/stories/components/BreakLines/introduce.md new file mode 100644 index 0000000..190ef93 --- /dev/null +++ b/.storybook/stories/components/BreakLines/introduce.md @@ -0,0 +1,9 @@ +# BreakLines + +- **EN:** Renders plain text with preserved line breaks by splitting with a configurable EOL marker and output tag. +- **CN:** 按指定换行符拆分文本并保留换行展示,可自定义输出标签。 + +## When to use | 适用场景 + +- **EN:** Display backend-provided text that contains `\n` (or another EOL marker) and you want it shown as multiple visual lines instead of a single wrapped paragraph. +- **CN:** 展示后端返回、包含 `\n`(或其它换行符)的文本,希望按原换行分段显示,而不是被当作一整段自动折行。 diff --git a/.storybook/stories/components/ColumnSetting.apidoc.md b/.storybook/stories/components/ColumnSetting.apidoc.md deleted file mode 100644 index 608b4c7..0000000 --- a/.storybook/stories/components/ColumnSetting.apidoc.md +++ /dev/null @@ -1,39 +0,0 @@ -# ColumnSetting - -- **EN:** Helps users manage table columns such as visibility and ordering, and can persist preferences with a storage key. -- **CN:** 用于管理表格列的显示与顺序,并可通过 `storageKey` 持久化用户偏好。 - -## When to use | 适用场景 - -- **EN:** You have an Ant Design `Table` and want to let users toggle column visibility / reorder columns, optionally remembering their choice across sessions. -- **CN:** 你使用 Ant Design 的 `Table`,希望让用户开关列显示 / 调整列顺序,并可选地在会话间记住其选择。 - -## API - -| Prop | Type | Default | Description | -| --- | --- | --- | --- | -| `columns` | `ColumnSettingItem[]` | - | Column definitions including visibility, ordering, and `disabled` state | -| `onChange` | `(nextColumns: T[]) => void` | - | Called when the selected columns change; returns columns with `hidden` updated | -| `storageKey` | `string` | - | Local storage key for persisting column settings. If unset, persistence is disabled | -| `renderColumnTitle` | `(col: ColumnSettingItem, index: number) => ReactNode` | - | Custom renderer for column titles | -| `triggerProps` | `ButtonProps` | - | Props for the button that opens the dropdown | -| `dropdownProps` | `DropdownProps` | - | Props for the dropdown component | -| `popupProps` | `React.HTMLAttributes` | - | Props for the dropdown popup container | -| `checkAllProps` | `ButtonProps` | - | Props for the "Check All" button | -| `resetProps` | `ButtonProps` | - | Props for the "Reset" button | -| `prefixCls` | `string` | - | Custom CSS class prefix | - -### `ColumnSettingItem` - -Extends Ant Design `ColumnType` and adds: - -| Field | Type | Description | -| --- | --- | --- | -| `disabled` | `boolean` | Disable toggling visibility for this column | - -## Notes | 说明 - -- **EN:** At least one column always remains visible — the last visible column's checkbox is disabled to prevent hiding everything. -- **CN:** 至少会保留一列可见:最后一列可见项的勾选框会被禁用,避免把所有列都隐藏掉。 -- **EN:** When `storageKey` is set, the selection is read from `localStorage` on mount and written back on every change. -- **CN:** 设置 `storageKey` 后,挂载时会从 `localStorage` 读取选择,并在每次变更时写回。 diff --git a/.storybook/stories/components/ColumnSetting/api-doc.md b/.storybook/stories/components/ColumnSetting/api-doc.md new file mode 100644 index 0000000..9bd09d2 --- /dev/null +++ b/.storybook/stories/components/ColumnSetting/api-doc.md @@ -0,0 +1,29 @@ +## API + +| Prop | Type | Default | Description | +| ------------------- | ------------------------------------------------------ | ------- | ----------------------------------------------------------------------------------- | +| `columns` | `ColumnSettingItem[]` | - | Column definitions including visibility, ordering, and `disabled` state | +| `onChange` | `(nextColumns: T[]) => void` | - | Called when the selected columns change; returns columns with `hidden` updated | +| `storageKey` | `string` | - | Local storage key for persisting column settings. If unset, persistence is disabled | +| `renderColumnTitle` | `(col: ColumnSettingItem, index: number) => ReactNode` | - | Custom renderer for column titles | +| `triggerProps` | `ButtonProps` | - | Props for the button that opens the dropdown | +| `dropdownProps` | `DropdownProps` | - | Props for the dropdown component | +| `popupProps` | `React.HTMLAttributes` | - | Props for the dropdown popup container | +| `checkAllProps` | `ButtonProps` | - | Props for the "Check All" button | +| `resetProps` | `ButtonProps` | - | Props for the "Reset" button | +| `prefixCls` | `string` | - | Custom CSS class prefix | + +### `ColumnSettingItem` + +Extends Ant Design `ColumnType` and adds: + +| Field | Type | Description | +| ---------- | --------- | ------------------------------------------- | +| `disabled` | `boolean` | Disable toggling visibility for this column | + +## Notes | 说明 + +- **EN:** At least one column always remains visible — the last visible column's checkbox is disabled to prevent hiding everything. +- **CN:** 至少会保留一列可见:最后一列可见项的勾选框会被禁用,避免把所有列都隐藏掉。 +- **EN:** When `storageKey` is set, the selection is read from `localStorage` on mount and written back on every change. +- **CN:** 设置 `storageKey` 后,挂载时会从 `localStorage` 读取选择,并在每次变更时写回。 diff --git a/.storybook/stories/components/ColumnSetting.stories.tsx b/.storybook/stories/components/ColumnSetting/index.stories.tsx similarity index 92% rename from .storybook/stories/components/ColumnSetting.stories.tsx rename to .storybook/stories/components/ColumnSetting/index.stories.tsx index e2a9001..7b30686 100644 --- a/.storybook/stories/components/ColumnSetting.stories.tsx +++ b/.storybook/stories/components/ColumnSetting/index.stories.tsx @@ -3,10 +3,11 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { action } from 'storybook/actions'; import { Table } from 'antd'; import type { ColumnsType } from 'antd/es/table'; -import ColumnSetting from '../../../src/components/ColumnSetting'; -import type { ColumnSettingItem, ColumnSettingProps } from '../../../src/components/ColumnSetting'; -import { storyT, useStoryT } from '../../locales'; -import apidoc from './ColumnSetting.apidoc.md?raw'; +import ColumnSetting from '../../../../src/components/ColumnSetting'; +import type { ColumnSettingItem, ColumnSettingProps } from '../../../../src/components/ColumnSetting'; +import { storyT, useStoryT } from '../../../locales'; +import apiDoc from './api-doc.md?raw'; +import introduce from './introduce.md?raw'; const meta: Meta = { title: 'Components/ColumnSetting', @@ -14,7 +15,7 @@ const meta: Meta = { parameters: { docs: { description: { - component: apidoc, + component: introduce + apiDoc, }, }, }, diff --git a/.storybook/stories/components/ColumnSetting/introduce.md b/.storybook/stories/components/ColumnSetting/introduce.md new file mode 100644 index 0000000..16c2a49 --- /dev/null +++ b/.storybook/stories/components/ColumnSetting/introduce.md @@ -0,0 +1,9 @@ +# ColumnSetting + +- **EN:** Helps users manage table columns such as visibility and ordering, and can persist preferences with a storage key. +- **CN:** 用于管理表格列的显示与顺序,并可通过 `storageKey` 持久化用户偏好。 + +## When to use | 适用场景 + +- **EN:** You have an Ant Design `Table` and want to let users toggle column visibility / reorder columns, optionally remembering their choice across sessions. +- **CN:** 你使用 Ant Design 的 `Table`,希望让用户开关列显示 / 调整列顺序,并可选地在会话间记住其选择。 diff --git a/.storybook/stories/components/ConfigProvider.stories.tsx b/.storybook/stories/components/ConfigProvider/index.stories.tsx similarity index 84% rename from .storybook/stories/components/ConfigProvider.stories.tsx rename to .storybook/stories/components/ConfigProvider/index.stories.tsx index e0b3a18..a3d39ad 100644 --- a/.storybook/stories/components/ConfigProvider.stories.tsx +++ b/.storybook/stories/components/ConfigProvider/index.stories.tsx @@ -1,9 +1,9 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { App as AntdApp, Space, Typography } from 'antd'; -import ConfigProvider, { type ConfigProviderProps } from '../../../src/components/ConfigProvider'; -import ConfirmAction from '../../../src/components/ConfirmAction'; -import { storyT, useStoryT } from '../../locales'; -import apidoc from './ConfigProvider.apidoc.md?raw'; +import ConfigProvider, { type ConfigProviderProps } from '../../../../src/components/ConfigProvider'; +import ConfirmAction from '../../../../src/components/ConfirmAction'; +import { storyT, useStoryT } from '../../../locales'; +import introduce from './introduce.md?raw'; type ConfigProviderStoryArgs = ConfigProviderProps; @@ -13,7 +13,7 @@ const meta: Meta = { parameters: { docs: { description: { - component: apidoc, + component: introduce, }, }, }, diff --git a/.storybook/stories/components/ConfigProvider.apidoc.md b/.storybook/stories/components/ConfigProvider/introduce.md similarity index 100% rename from .storybook/stories/components/ConfigProvider.apidoc.md rename to .storybook/stories/components/ConfigProvider/introduce.md diff --git a/.storybook/stories/components/ConfirmAction.stories.tsx b/.storybook/stories/components/ConfirmAction/index.stories.tsx similarity index 94% rename from .storybook/stories/components/ConfirmAction.stories.tsx rename to .storybook/stories/components/ConfirmAction/index.stories.tsx index b0eb7aa..9657d58 100644 --- a/.storybook/stories/components/ConfirmAction.stories.tsx +++ b/.storybook/stories/components/ConfirmAction/index.stories.tsx @@ -3,10 +3,10 @@ import { fn } from 'storybook/test'; import type { ButtonProps, SwitchProps } from 'antd'; import { App as AntdApp } from 'antd'; import type { LinkProps } from 'antd/es/typography/Link'; -import type { ConfirmActionProps } from '../../../src/components/ConfirmAction'; -import ConfirmAction from '../../../src/components/ConfirmAction'; -import { storyT } from '../../locales'; -import apidoc from './ConfirmAction.apidoc.md?raw'; +import type { ConfirmActionProps } from '../../../../src/components/ConfirmAction'; +import ConfirmAction from '../../../../src/components/ConfirmAction'; +import { storyT } from '../../../locales'; +import introduce from './introduce.md?raw'; type TriggerType = 'Button' | 'Switch' | 'Link'; @@ -19,7 +19,7 @@ const meta: Meta = { parameters: { docs: { description: { - component: apidoc, + component: introduce, }, }, }, diff --git a/.storybook/stories/components/ConfirmAction.apidoc.md b/.storybook/stories/components/ConfirmAction/introduce.md similarity index 100% rename from .storybook/stories/components/ConfirmAction.apidoc.md rename to .storybook/stories/components/ConfirmAction/introduce.md diff --git a/.storybook/stories/components/ContextMenu.apidoc.md b/.storybook/stories/components/ContextMenu.apidoc.md deleted file mode 100644 index 0955ef4..0000000 --- a/.storybook/stories/components/ContextMenu.apidoc.md +++ /dev/null @@ -1,56 +0,0 @@ -# ContextMenu - -- **EN:** Renders a configurable context menu with support for shortcuts, separators, and nested submenu items. -- **CN:** 提供可配置的右键菜单能力,支持快捷键、分隔线和嵌套子菜单。 - -## When to use | 适用场景 - -- **EN:** You need a right-click (or other trigger) menu on an element, with keyboard shortcuts, separators and multi-level submenus. -- **CN:** 你需要在元素上提供右键(或其它触发方式)菜单,并支持快捷键、分隔线与多级子菜单。 - -## API - -| Prop | Type | Default | Description | -| --- | --- | --- | --- | -| `items` | `(ContextMenuItem \| ContextMenuSeparator \| ContextMenuSubmenu)[]` | - | Menu items to display | -| `trigger` | `('click' \| 'doubleClick' \| 'hover' \| 'contextMenu')[]` | `['contextMenu']` | How the menu is triggered | -| `triggerProps` | `{ className?: string; style?: CSSProperties }` | - | Props for the trigger element | -| `prefixCls` | `string` | - | Custom CSS class prefix | - -> Other `MenuProps` (from `react-contexify`) are forwarded to the underlying ``. - -### `ContextMenuItem` - -| Field | Type | Description | -| --- | --- | --- | -| `key` | `string` | Unique key | -| `label` | `ReactNode` | Item label | -| `icon` | `ReactNode` | Leading icon | -| `shortcutKey` | `Partial \| keyMatcher` | Keyboard shortcut matcher | -| `children` | `ReactNode` | Custom content (overrides `label`/`icon`) | - -### `ContextMenuSubmenu` - -| Field | Type | Description | -| --- | --- | --- | -| `key` | `string` | Unique key | -| `type` | `'submenu'` | Discriminator | -| `items` | `(ContextMenuItem \| ContextMenuSeparator \| ContextMenuSubmenu)[]` | Nested items | - -### `ContextMenuSeparator` - -| Field | Type | Description | -| --- | --- | --- | -| `type` | `'separator'` | Discriminator | - -### Ref — `ContextMenuRef` - -| Method | Signature | Description | -| --- | --- | --- | -| `show` | `(event: React.MouseEvent) => void` | Open the menu at the event position | -| `hideAll` | `() => void` | Close all context menus | - -## Notes | 说明 - -- **EN:** The `show`/`hideAll` methods are exposed via `ref`, useful for programmatic control. -- **CN:** `show` / `hideAll` 通过 `ref` 暴露,便于以编程方式控制菜单。 diff --git a/.storybook/stories/components/ContextMenu/api-doc.md b/.storybook/stories/components/ContextMenu/api-doc.md new file mode 100644 index 0000000..5adac95 --- /dev/null +++ b/.storybook/stories/components/ContextMenu/api-doc.md @@ -0,0 +1,46 @@ +## API + +| Prop | Type | Default | Description | +| -------------- | ------------------------------------------------------------------- | ----------------- | ----------------------------- | +| `items` | `(ContextMenuItem \| ContextMenuSeparator \| ContextMenuSubmenu)[]` | - | Menu items to display | +| `trigger` | `('click' \| 'doubleClick' \| 'hover' \| 'contextMenu')[]` | `['contextMenu']` | How the menu is triggered | +| `triggerProps` | `{ className?: string; style?: CSSProperties }` | - | Props for the trigger element | +| `prefixCls` | `string` | - | Custom CSS class prefix | + +> Other `MenuProps` (from `react-contexify`) are forwarded to the underlying ``. + +### `ContextMenuItem` + +| Field | Type | Description | +| ------------- | -------------------------------------- | ----------------------------------------- | +| `key` | `string` | Unique key | +| `label` | `ReactNode` | Item label | +| `icon` | `ReactNode` | Leading icon | +| `shortcutKey` | `Partial \| keyMatcher` | Keyboard shortcut matcher | +| `children` | `ReactNode` | Custom content (overrides `label`/`icon`) | + +### `ContextMenuSubmenu` + +| Field | Type | Description | +| ------- | ------------------------------------------------------------------- | ------------- | +| `key` | `string` | Unique key | +| `type` | `'submenu'` | Discriminator | +| `items` | `(ContextMenuItem \| ContextMenuSeparator \| ContextMenuSubmenu)[]` | Nested items | + +### `ContextMenuSeparator` + +| Field | Type | Description | +| ------ | ------------- | ------------- | +| `type` | `'separator'` | Discriminator | + +### Ref — `ContextMenuRef` + +| Method | Signature | Description | +| --------- | ----------------------------------- | ----------------------------------- | +| `show` | `(event: React.MouseEvent) => void` | Open the menu at the event position | +| `hideAll` | `() => void` | Close all context menus | + +## Notes | 说明 + +- **EN:** The `show`/`hideAll` methods are exposed via `ref`, useful for programmatic control. +- **CN:** `show` / `hideAll` 通过 `ref` 暴露,便于以编程方式控制菜单。 diff --git a/.storybook/stories/components/ContextMenu.stories.tsx b/.storybook/stories/components/ContextMenu/index.stories.tsx similarity index 85% rename from .storybook/stories/components/ContextMenu.stories.tsx rename to .storybook/stories/components/ContextMenu/index.stories.tsx index 0aff764..3b6da93 100644 --- a/.storybook/stories/components/ContextMenu.stories.tsx +++ b/.storybook/stories/components/ContextMenu/index.stories.tsx @@ -1,9 +1,10 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { fn } from 'storybook/test'; import { Button } from 'antd'; -import ContextMenu from '../../../src/components/ContextMenu'; -import { storyT, useStoryT } from '../../locales'; -import apidoc from './ContextMenu.apidoc.md?raw'; +import ContextMenu from '../../../../src/components/ContextMenu'; +import { storyT, useStoryT } from '../../../locales'; +import apiDoc from './api-doc.md?raw'; +import introduce from './introduce.md?raw'; const meta: Meta = { title: 'Components/ContextMenu', @@ -11,7 +12,7 @@ const meta: Meta = { parameters: { docs: { description: { - component: apidoc, + component: introduce + apiDoc, }, }, }, diff --git a/.storybook/stories/components/ContextMenu/introduce.md b/.storybook/stories/components/ContextMenu/introduce.md new file mode 100644 index 0000000..73dfcaf --- /dev/null +++ b/.storybook/stories/components/ContextMenu/introduce.md @@ -0,0 +1,9 @@ +# ContextMenu + +- **EN:** Renders a configurable context menu with support for shortcuts, separators, and nested submenu items. +- **CN:** 提供可配置的右键菜单能力,支持快捷键、分隔线和嵌套子菜单。 + +## When to use | 适用场景 + +- **EN:** You need a right-click (or other trigger) menu on an element, with keyboard shortcuts, separators and multi-level submenus. +- **CN:** 你需要在元素上提供右键(或其它触发方式)菜单,并支持快捷键、分隔线与多级子菜单。 diff --git a/.storybook/stories/components/DeleteConfirmAction.stories.tsx b/.storybook/stories/components/DeleteConfirmAction/index.stories.tsx similarity index 93% rename from .storybook/stories/components/DeleteConfirmAction.stories.tsx rename to .storybook/stories/components/DeleteConfirmAction/index.stories.tsx index 5808db1..0cac380 100644 --- a/.storybook/stories/components/DeleteConfirmAction.stories.tsx +++ b/.storybook/stories/components/DeleteConfirmAction/index.stories.tsx @@ -3,10 +3,10 @@ import { fn } from 'storybook/test'; import type { ButtonProps, SwitchProps } from 'antd'; import { App as AntdApp } from 'antd'; import type { LinkProps } from 'antd/es/typography/Link'; -import type { ConfirmActionProps } from '../../../src/components/ConfirmAction'; -import DeleteConfirmAction from '../../../src/components/DeleteConfirmAction'; -import { storyT } from '../../locales'; -import apidoc from './DeleteConfirmAction.apidoc.md?raw'; +import type { ConfirmActionProps } from '../../../../src/components/ConfirmAction'; +import DeleteConfirmAction from '../../../../src/components/DeleteConfirmAction'; +import { storyT } from '../../../locales'; +import introduce from './introduce.md?raw'; type TriggerType = 'Button' | 'Switch' | 'Link'; @@ -19,7 +19,7 @@ const meta: Meta = { parameters: { docs: { description: { - component: apidoc, + component: introduce, }, }, }, diff --git a/.storybook/stories/components/DeleteConfirmAction.apidoc.md b/.storybook/stories/components/DeleteConfirmAction/introduce.md similarity index 100% rename from .storybook/stories/components/DeleteConfirmAction.apidoc.md rename to .storybook/stories/components/DeleteConfirmAction/introduce.md diff --git a/.storybook/stories/components/EditableText.stories.tsx b/.storybook/stories/components/EditableText/index.stories.tsx similarity index 91% rename from .storybook/stories/components/EditableText.stories.tsx rename to .storybook/stories/components/EditableText/index.stories.tsx index 98421c3..77e7665 100644 --- a/.storybook/stories/components/EditableText.stories.tsx +++ b/.storybook/stories/components/EditableText/index.stories.tsx @@ -1,8 +1,8 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { fn } from 'storybook/test'; -import EditableText, { type EditableTextProps } from '../../../src/components/EditableText'; -import { storyT } from '../../locales'; -import apidoc from './EditableText.apidoc.md?raw'; +import EditableText, { type EditableTextProps } from '../../../../src/components/EditableText'; +import { storyT } from '../../../locales'; +import introduce from './introduce.md?raw'; const meta: Meta = { title: 'Components/EditableText', @@ -10,7 +10,7 @@ const meta: Meta = { parameters: { docs: { description: { - component: apidoc, + component: introduce, }, }, }, diff --git a/.storybook/stories/components/EditableText.apidoc.md b/.storybook/stories/components/EditableText/introduce.md similarity index 72% rename from .storybook/stories/components/EditableText.apidoc.md rename to .storybook/stories/components/EditableText/introduce.md index 10db6c1..3bf9f91 100644 --- a/.storybook/stories/components/EditableText.apidoc.md +++ b/.storybook/stories/components/EditableText/introduce.md @@ -1,3 +1,3 @@ - **EN:** Editable text component, providing a read-only display and an edit mode, supporting -inline text editing, with edit state, validation-related switches, and edit lifecycle callbacks. + inline text editing, with edit state, validation-related switches, and edit lifecycle callbacks. - **CN:** 可编辑文本组件,提供只读显示和编辑模式,支持行内文本编辑,提供编辑态、校验相关开关和编辑生命周期回调。 diff --git a/.storybook/stories/components/EllipsisTypography.stories.tsx b/.storybook/stories/components/EllipsisTypography/index.stories.tsx similarity index 89% rename from .storybook/stories/components/EllipsisTypography.stories.tsx rename to .storybook/stories/components/EllipsisTypography/index.stories.tsx index 0e378f9..e78c9ee 100644 --- a/.storybook/stories/components/EllipsisTypography.stories.tsx +++ b/.storybook/stories/components/EllipsisTypography/index.stories.tsx @@ -1,10 +1,10 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; -import EllipsisLinkComponent from '../../../src/components/EllipsisTypography/EllipsisLink'; -import EllipsisParagraphComponent from '../../../src/components/EllipsisTypography/EllipsisParagraph'; -import EllipsisTextComponent from '../../../src/components/EllipsisTypography/EllipsisText'; -import EllipsisTitleComponent from '../../../src/components/EllipsisTypography/EllipsisTitle'; -import { storyT } from '../../locales'; -import apidoc from './EllipsisTypography.apidoc.md?raw'; +import EllipsisLinkComponent from '../../../../src/components/EllipsisTypography/EllipsisLink'; +import EllipsisParagraphComponent from '../../../../src/components/EllipsisTypography/EllipsisParagraph'; +import EllipsisTextComponent from '../../../../src/components/EllipsisTypography/EllipsisText'; +import EllipsisTitleComponent from '../../../../src/components/EllipsisTypography/EllipsisTitle'; +import { storyT } from '../../../locales'; +import introduce from './introduce.md?raw'; const longText = storyT('storybook.stories.EllipsisTypography.longText'); @@ -13,7 +13,7 @@ const meta: Meta = { parameters: { docs: { description: { - component: apidoc, + component: introduce, }, }, }, diff --git a/.storybook/stories/components/EllipsisTypography.apidoc.md b/.storybook/stories/components/EllipsisTypography/introduce.md similarity index 73% rename from .storybook/stories/components/EllipsisTypography.apidoc.md rename to .storybook/stories/components/EllipsisTypography/introduce.md index 90ec4e6..afe1dfb 100644 --- a/.storybook/stories/components/EllipsisTypography.apidoc.md +++ b/.storybook/stories/components/EllipsisTypography/introduce.md @@ -1,3 +1,3 @@ - **EN:** A set of typography wrapper components with ellipsis capabilities, covering text, paragraph, title, and link variants. -When the text overflows, the ellipsis effect is displayed; if the text does not overflow, the tooltip is not shown. + When the text overflows, the ellipsis effect is displayed; if the text does not overflow, the tooltip is not shown. - **CN:** 一组带省略能力的排版封装组件,覆盖文本、段落、标题和链接几种形态。在文本溢出时显示省略效果,如果文本没有溢出,则不显示tooltip。 diff --git a/.storybook/stories/components/FloatDrawer.apidoc.md b/.storybook/stories/components/FloatDrawer.apidoc.md deleted file mode 100644 index 24ece75..0000000 --- a/.storybook/stories/components/FloatDrawer.apidoc.md +++ /dev/null @@ -1,39 +0,0 @@ -# FloatDrawer - -- **EN:** FloatDrawer is a draggable, resizable drawer that can sit at any edge of a parent container, with a toggle handle and optional size persistence. -- **CN:** FloatDrawer 是一个可拖动、可调整大小的浮动抽屉,可停靠在父容器的任意边缘,带展开/收起手柄,并可选记忆宽度。 - -## When to use | 适用场景 - -- **EN:** You want a side panel that overlays part of a container (not a full-screen modal), can be collapsed, resized by dragging, and optionally remembers its size. -- **CN:** 你需要一个覆盖在容器局部的侧边面板(而非全屏弹窗),可收起、可通过拖拽调整大小,并可选择性记忆尺寸。 - -## API - -| Prop | Type | Default | Description | -| --- | --- | --- | --- | -| `open` | `boolean` | - | Whether the drawer is open (controlled) | -| `position` | `'left' \| 'right' \| 'top' \| 'bottom'` | `'right'` | Drawer position | -| `defaultSize` | `number` | - | Default size (width for left/right, height otherwise); when unset, adapts to content | -| `minSize` | `number` | `0` | Minimum size | -| `maxSize` | `number` | `Infinity` | Maximum size | -| `edgeOffset` | `CSSProperties['width']` | `0` | Offset from the parent edge; tune so the drawer hides fully outside the container | -| `showToggle` | `boolean` | `true` | Whether to show the toggle handle | -| `resizable` | `boolean` | `true` | Whether the drawer is resizable | -| `destroyOnClose` | `boolean` | `false` | Destroy content when closed | -| `cacheKey` | `string` | - | localStorage key to persist drawer size | -| `cardProps` | `Omit` | - | Props for the inner `Card` | -| `className` / `style` | `string` / `CSSProperties` | - | Root class / style | -| `classNames` | `{ drawer; toggle; resizeHandle; handleIcon; content; card }` | - | Class names for specific parts | -| `styles` | `{ drawer; toggle; resizeHandle; handleIcon; content; card }` | - | Styles for specific parts | -| `onOpenChange` | `(open: boolean) => void` | - | Called when open state changes | -| `onResize` | `(size: number) => void` | - | Called when the drawer is resized | -| `onClick` | `(e: React.MouseEvent) => void` | - | Click handler for the drawer container | -| `children` | `ReactNode` | - | Drawer content | - -## Notes | 说明 - -- **EN:** Size is applied as `width` for left/right and `height` for top/bottom. Use `edgeOffset` to fully hide the drawer behind the parent's padding/border. -- **CN:** 尺寸对左右为 `width`、上下为 `height`。若抽屉未完全隐藏在父容器外,可用 `edgeOffset` 调整偏移。 -- **EN:** When `cacheKey` is set, the size is stored in `localStorage` and restored on next mount. -- **CN:** 设置 `cacheKey` 后,抽屉尺寸会存入 `localStorage`,下次挂载时恢复。 diff --git a/.storybook/stories/components/FloatDrawer/api-doc.md b/.storybook/stories/components/FloatDrawer/api-doc.md new file mode 100644 index 0000000..3cc445b --- /dev/null +++ b/.storybook/stories/components/FloatDrawer/api-doc.md @@ -0,0 +1,29 @@ +## API + +| Prop | Type | Default | Description | +| --------------------- | ------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------ | +| `open` | `boolean` | - | Whether the drawer is open (controlled) | +| `position` | `'left' \| 'right' \| 'top' \| 'bottom'` | `'right'` | Drawer position | +| `defaultSize` | `number` | - | Default size (width for left/right, height otherwise); when unset, adapts to content | +| `minSize` | `number` | `0` | Minimum size | +| `maxSize` | `number` | `Infinity` | Maximum size | +| `edgeOffset` | `CSSProperties['width']` | `0` | Offset from the parent edge; tune so the drawer hides fully outside the container | +| `showToggle` | `boolean` | `true` | Whether to show the toggle handle | +| `resizable` | `boolean` | `true` | Whether the drawer is resizable | +| `destroyOnClose` | `boolean` | `false` | Destroy content when closed | +| `cacheKey` | `string` | - | localStorage key to persist drawer size | +| `cardProps` | `Omit` | - | Props for the inner `Card` | +| `className` / `style` | `string` / `CSSProperties` | - | Root class / style | +| `classNames` | `{ drawer; toggle; resizeHandle; handleIcon; content; card }` | - | Class names for specific parts | +| `styles` | `{ drawer; toggle; resizeHandle; handleIcon; content; card }` | - | Styles for specific parts | +| `onOpenChange` | `(open: boolean) => void` | - | Called when open state changes | +| `onResize` | `(size: number) => void` | - | Called when the drawer is resized | +| `onClick` | `(e: React.MouseEvent) => void` | - | Click handler for the drawer container | +| `children` | `ReactNode` | - | Drawer content | + +## Notes | 说明 + +- **EN:** Size is applied as `width` for left/right and `height` for top/bottom. Use `edgeOffset` to fully hide the drawer behind the parent's padding/border. +- **CN:** 尺寸对左右为 `width`、上下为 `height`。若抽屉未完全隐藏在父容器外,可用 `edgeOffset` 调整偏移。 +- **EN:** When `cacheKey` is set, the size is stored in `localStorage` and restored on next mount. +- **CN:** 设置 `cacheKey` 后,抽屉尺寸会存入 `localStorage`,下次挂载时恢复。 diff --git a/.storybook/stories/components/FloatDrawer.stories.tsx b/.storybook/stories/components/FloatDrawer/index.stories.tsx similarity index 81% rename from .storybook/stories/components/FloatDrawer.stories.tsx rename to .storybook/stories/components/FloatDrawer/index.stories.tsx index a1ecaf9..aefdf49 100644 --- a/.storybook/stories/components/FloatDrawer.stories.tsx +++ b/.storybook/stories/components/FloatDrawer/index.stories.tsx @@ -1,8 +1,9 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { fn } from 'storybook/test'; -import FloatDrawer from '../../../src/components/FloatDrawer'; -import { storyT, useStoryT } from '../../locales'; -import apidoc from './FloatDrawer.apidoc.md?raw'; +import FloatDrawer from '../../../../src/components/FloatDrawer'; +import { storyT, useStoryT } from '../../../locales'; +import apiDoc from './api-doc.md?raw'; +import introduce from './introduce.md?raw'; const meta: Meta = { title: 'Components/FloatDrawer', @@ -10,7 +11,7 @@ const meta: Meta = { parameters: { docs: { description: { - component: apidoc, + component: introduce + apiDoc, }, }, }, diff --git a/.storybook/stories/components/FloatDrawer/introduce.md b/.storybook/stories/components/FloatDrawer/introduce.md new file mode 100644 index 0000000..e4a9050 --- /dev/null +++ b/.storybook/stories/components/FloatDrawer/introduce.md @@ -0,0 +1,9 @@ +# FloatDrawer + +- **EN:** FloatDrawer is a draggable, resizable drawer that can sit at any edge of a parent container, with a toggle handle and optional size persistence. +- **CN:** FloatDrawer 是一个可拖动、可调整大小的浮动抽屉,可停靠在父容器的任意边缘,带展开/收起手柄,并可选记忆宽度。 + +## When to use | 适用场景 + +- **EN:** You want a side panel that overlays part of a container (not a full-screen modal), can be collapsed, resized by dragging, and optionally remembers its size. +- **CN:** 你需要一个覆盖在容器局部的侧边面板(而非全屏弹窗),可收起、可通过拖拽调整大小,并可选择性记忆尺寸。 diff --git a/.storybook/stories/components/FormItemControl/api-doc.md b/.storybook/stories/components/FormItemControl/api-doc.md new file mode 100644 index 0000000..e219617 --- /dev/null +++ b/.storybook/stories/components/FormItemControl/api-doc.md @@ -0,0 +1,16 @@ +## API + +| Prop | Type | Description | +| ---------- | ------------------------------------------------------------------------ | ----------------------------------------------------------------- | +| `children` | `(options: { value: any; onChange: (value: any) => void }) => ReactNode` | Render prop receiving the current `value` and a setter `onChange` | + +## Usage | 用法 + +- **EN:** Place it inside ``. The render prop receives the form value and a change handler, so your custom control becomes a controlled form field. +- **CN:** 把它放在 `` 内部。渲染函数会收到表单 `value` 与一个变更回调,自定义控件即可成为受控表单字段。 + +```tsx + + {({ value, onChange }) => } + +``` diff --git a/.storybook/stories/components/FormItemControl.stories.tsx b/.storybook/stories/components/FormItemControl/index.stories.tsx similarity index 85% rename from .storybook/stories/components/FormItemControl.stories.tsx rename to .storybook/stories/components/FormItemControl/index.stories.tsx index be36bbd..3ba1a57 100644 --- a/.storybook/stories/components/FormItemControl.stories.tsx +++ b/.storybook/stories/components/FormItemControl/index.stories.tsx @@ -1,8 +1,9 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { Form, Slider, Typography } from 'antd'; -import FormItemControl from '../../../src/components/FormItemControl'; -import { useStoryT } from '../../locales'; -import apidoc from './FormItemControl.apidoc.md?raw'; +import FormItemControl from '../../../../src/components/FormItemControl'; +import { useStoryT } from '../../../locales'; +import apiDoc from './api-doc.md?raw'; +import introduce from './introduce.md?raw'; const meta: Meta = { title: 'Components/FormItemControl', @@ -10,7 +11,7 @@ const meta: Meta = { parameters: { docs: { description: { - component: apidoc, + component: introduce + apiDoc, }, }, }, diff --git a/.storybook/stories/components/FormItemControl.apidoc.md b/.storybook/stories/components/FormItemControl/introduce.md similarity index 52% rename from .storybook/stories/components/FormItemControl.apidoc.md rename to .storybook/stories/components/FormItemControl/introduce.md index a146eb2..a7609bd 100644 --- a/.storybook/stories/components/FormItemControl.apidoc.md +++ b/.storybook/stories/components/FormItemControl/introduce.md @@ -7,22 +7,3 @@ - **EN:** You have a custom widget (slider, color picker, rich editor, …) that you want to plug into an Ant Design `Form` without reimplementing `value`/`onChange` wiring. - **CN:** 你有一个自定义控件(滑块、取色器、富文本编辑器等),希望接入 Ant Design 的 `Form`,而不必自己重新实现 `value`/`onChange` 的对接。 - -## API - -| Prop | Type | Description | -| --- | --- | --- | -| `children` | `(options: { value: any; onChange: (value: any) => void }) => ReactNode` | Render prop receiving the current `value` and a setter `onChange` | - -## Usage | 用法 - -- **EN:** Place it inside ``. The render prop receives the form value and a change handler, so your custom control becomes a controlled form field. -- **CN:** 把它放在 `` 内部。渲染函数会收到表单 `value` 与一个变更回调,自定义控件即可成为受控表单字段。 - -```tsx - - - {({ value, onChange }) => } - - -``` diff --git a/.storybook/stories/components/Loading/api-doc.md b/.storybook/stories/components/Loading/api-doc.md new file mode 100644 index 0000000..e14a013 --- /dev/null +++ b/.storybook/stories/components/Loading/api-doc.md @@ -0,0 +1,14 @@ +## API + +Extends Ant Design `SpinProps` and adds: + +| Prop | Type | Default | Description | +| --------------- | ---------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------- | +| `mode` | `'absolute' \| 'flex'` | `'flex'` | Positioning of the standalone animation. `absolute` centers via absolute positioning; `flex` fills the parent with flexbox | +| `rootClassName` | `string` | - | Class name for the mask parent container (standalone mode) | +| `rootStyle` | `CSSProperties` | - | Style for the mask parent container (standalone mode) | + +## Notes | 说明 + +- **EN:** In standalone mode the component fills its parent, so the parent should be `position: relative` (or otherwise sized) for correct layout. +- **CN:** 独立使用时组件会撑满父容器,因此父容器应为 `position: relative`(或具备确定尺寸)才能正确布局。 diff --git a/.storybook/stories/components/Loading.stories.tsx b/.storybook/stories/components/Loading/index.stories.tsx similarity index 82% rename from .storybook/stories/components/Loading.stories.tsx rename to .storybook/stories/components/Loading/index.stories.tsx index a2cf118..6c41696 100644 --- a/.storybook/stories/components/Loading.stories.tsx +++ b/.storybook/stories/components/Loading/index.stories.tsx @@ -1,8 +1,9 @@ import type { ComponentProps } from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; -import Loading from '../../../src/components/Loading'; -import { storyT, useStoryT } from '../../locales'; -import apidoc from './Loading.apidoc.md?raw'; +import Loading from '../../../../src/components/Loading'; +import { storyT, useStoryT } from '../../../locales'; +import apiDoc from './api-doc.md?raw'; +import introduce from './introduce.md?raw'; const meta: Meta = { title: 'Components/Loading', @@ -10,7 +11,7 @@ const meta: Meta = { parameters: { docs: { description: { - component: apidoc, + component: introduce + apiDoc, }, }, }, diff --git a/.storybook/stories/components/Loading.apidoc.md b/.storybook/stories/components/Loading/introduce.md similarity index 54% rename from .storybook/stories/components/Loading.apidoc.md rename to .storybook/stories/components/Loading/introduce.md index 3144649..3c22e82 100644 --- a/.storybook/stories/components/Loading.apidoc.md +++ b/.storybook/stories/components/Loading/introduce.md @@ -12,18 +12,3 @@ 1. **Spin (overlay)** — pass `children`; the component wraps them with Ant Design `Spin` and toggles the animation via `spinning`. 2. **Standalone** — no children; renders a self-filled, centered loading animation. Hidden automatically when `spinning` is `false`. - -## API - -Extends Ant Design `SpinProps` and adds: - -| Prop | Type | Default | Description | -| --- | --- | --- | --- | -| `mode` | `'absolute' \| 'flex'` | `'flex'` | Positioning of the standalone animation. `absolute` centers via absolute positioning; `flex` fills the parent with flexbox | -| `rootClassName` | `string` | - | Class name for the mask parent container (standalone mode) | -| `rootStyle` | `CSSProperties` | - | Style for the mask parent container (standalone mode) | - -## Notes | 说明 - -- **EN:** In standalone mode the component fills its parent, so the parent should be `position: relative` (or otherwise sized) for correct layout. -- **CN:** 独立使用时组件会撑满父容器,因此父容器应为 `position: relative`(或具备确定尺寸)才能正确布局。 diff --git a/.storybook/stories/components/ModalAction.stories.tsx b/.storybook/stories/components/ModalAction/index.stories.tsx similarity index 96% rename from .storybook/stories/components/ModalAction.stories.tsx rename to .storybook/stories/components/ModalAction/index.stories.tsx index 01951d1..5407342 100644 --- a/.storybook/stories/components/ModalAction.stories.tsx +++ b/.storybook/stories/components/ModalAction/index.stories.tsx @@ -3,10 +3,10 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { fn } from 'storybook/test'; import { type ButtonProps, Form, Input, message, Space, type SwitchProps } from 'antd'; import type { LinkProps } from 'antd/es/typography/Link'; -import type { FormCompPropsConstraint, ModalActionProps } from '../../../src/components/ModalAction'; -import { withModalAction } from '../../../src/components/ModalAction'; -import { storyT, useStoryT } from '../../locales'; -import apidoc from './ModalAction.apidoc.md?raw'; +import type { FormCompPropsConstraint, ModalActionProps } from '../../../../src/components/ModalAction'; +import { withModalAction } from '../../../../src/components/ModalAction'; +import { storyT, useStoryT } from '../../../locales'; +import introduce from './introduce.md?raw'; type ModalActionStoryArgs = ModalActionProps< UserForm, @@ -55,7 +55,7 @@ const meta: Meta = { parameters: { docs: { description: { - component: apidoc, + component: introduce, }, }, }, diff --git a/.storybook/stories/components/ModalAction.apidoc.md b/.storybook/stories/components/ModalAction/introduce.md similarity index 100% rename from .storybook/stories/components/ModalAction.apidoc.md rename to .storybook/stories/components/ModalAction/introduce.md diff --git a/.storybook/stories/components/OverflowTags.apidoc.md b/.storybook/stories/components/OverflowTags.apidoc.md deleted file mode 100644 index 3f7b989..0000000 --- a/.storybook/stories/components/OverflowTags.apidoc.md +++ /dev/null @@ -1,31 +0,0 @@ -# OverflowTags - -- **EN:** Renders a tag collection with overflow collapsing, suitable for dense metadata or filter summaries. When the number of tags exceeds the maximum count, an ellipsis tag is shown; custom tag rendering and per-tag props are supported. -- **CN:** 用于渲染可折叠溢出的标签集合,适合密集元信息或筛选条件摘要场景。当标签数量超过最大显示数时,会显示省略号标签,并支持自定义标签渲染与属性。 - -## When to use | 适用场景 - -- **EN:** You show many tags in a tight space and want the extras collapsed into a "+N" popover instead of wrapping or scrolling. -- **CN:** 在有限空间内展示大量标签,希望多余的标签折叠成 "+N" 弹出层,而不是换行或滚动。 - -## API - -`OverflowTagsProps` extends `rc-overflow`'s `OverflowProps` (minus `renderItem`) and adds: - -| Prop | Type | Default | Description | -| --- | --- | --- | --- | -| `tags` | `T[]` | `[]` | Tag data collection | -| `getTagName` | `(tag: T) => ReactNode` | `tag.label` or `tag.name` | Resolve the tag display name | -| `getTagKey` | `(tag: T) => React.Key` | `tag.value` or `tag.id` | Resolve the tag unique key | -| `renderTag` | `OverflowProps['renderItem']` | - | Custom tag renderer | -| `tagProps` | `TagProps \| ((tag: T, { tags }) => TagProps)` | - | Props for the `Tag` component | -| `ellipsisTagProps` | `TagProps \| ((tag, { omittedItems, allTags }) => TagProps)` | - | Props for the ellipsis ("+N") tag | -| `ellipsisDropdownProps` | `DropdownProps` | - | Props for the overflow dropdown | -| `randomColors` | `boolean` | `false` | Use random preset colors. A `color` field on the tag takes precedence | - -## Notes | 说明 - -- **EN:** The overflow threshold is controlled by `maxCount` (from `rc-overflow`, default `'responsive'`), which you can pass through the base props. -- **CN:** 溢出阈值由 `maxCount`(来自 `rc-overflow`,默认 `'responsive'`)控制,可通过基础属性透传。 -- **EN:** If a tag carries its own `color`, that color wins over `randomColors`. -- **CN:** 若 tag 自身带 `color` 字段,则其优先级高于 `randomColors`。 diff --git a/.storybook/stories/components/OverflowTags/api-doc.md b/.storybook/stories/components/OverflowTags/api-doc.md new file mode 100644 index 0000000..4136009 --- /dev/null +++ b/.storybook/stories/components/OverflowTags/api-doc.md @@ -0,0 +1,21 @@ +## API + +`OverflowTagsProps` extends `rc-overflow`'s `OverflowProps` (minus `renderItem`) and adds: + +| Prop | Type | Default | Description | +| ----------------------- | ------------------------------------------------------------ | ------------------------- | --------------------------------------------------------------------- | +| `tags` | `T[]` | `[]` | Tag data collection | +| `getTagName` | `(tag: T) => ReactNode` | `tag.label` or `tag.name` | Resolve the tag display name | +| `getTagKey` | `(tag: T) => React.Key` | `tag.value` or `tag.id` | Resolve the tag unique key | +| `renderTag` | `OverflowProps['renderItem']` | - | Custom tag renderer | +| `tagProps` | `TagProps \| ((tag: T, { tags }) => TagProps)` | - | Props for the `Tag` component | +| `ellipsisTagProps` | `TagProps \| ((tag, { omittedItems, allTags }) => TagProps)` | - | Props for the ellipsis ("+N") tag | +| `ellipsisDropdownProps` | `DropdownProps` | - | Props for the overflow dropdown | +| `randomColors` | `boolean` | `false` | Use random preset colors. A `color` field on the tag takes precedence | + +## Notes | 说明 + +- **EN:** The overflow threshold is controlled by `maxCount` (from `rc-overflow`, default `'responsive'`), which you can pass through the base props. +- **CN:** 溢出阈值由 `maxCount`(来自 `rc-overflow`,默认 `'responsive'`)控制,可通过基础属性透传。 +- **EN:** If a tag carries its own `color`, that color wins over `randomColors`. +- **CN:** 若 tag 自身带 `color` 字段,则其优先级高于 `randomColors`。 diff --git a/.storybook/stories/components/OverflowTags.stories.tsx b/.storybook/stories/components/OverflowTags/index.stories.tsx similarity index 91% rename from .storybook/stories/components/OverflowTags.stories.tsx rename to .storybook/stories/components/OverflowTags/index.stories.tsx index a91b652..6c4efbb 100644 --- a/.storybook/stories/components/OverflowTags.stories.tsx +++ b/.storybook/stories/components/OverflowTags/index.stories.tsx @@ -1,8 +1,9 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { Typography } from 'antd'; -import OverflowTags from '../../../src/components/OverflowTags'; -import { storyT } from '../../locales'; -import apidoc from './OverflowTags.apidoc.md?raw'; +import OverflowTags from '../../../../src/components/OverflowTags'; +import { storyT } from '../../../locales'; +import apiDoc from './api-doc.md?raw'; +import introduce from './introduce.md?raw'; interface TagItem { id: number; @@ -32,7 +33,7 @@ const meta: Meta> = { parameters: { docs: { description: { - component: apidoc, + component: introduce + apiDoc, }, }, }, diff --git a/.storybook/stories/components/OverflowTags/introduce.md b/.storybook/stories/components/OverflowTags/introduce.md new file mode 100644 index 0000000..6f8f331 --- /dev/null +++ b/.storybook/stories/components/OverflowTags/introduce.md @@ -0,0 +1,9 @@ +# OverflowTags + +- **EN:** Renders a tag collection with overflow collapsing, suitable for dense metadata or filter summaries. When the number of tags exceeds the maximum count, an ellipsis tag is shown; custom tag rendering and per-tag props are supported. +- **CN:** 用于渲染可折叠溢出的标签集合,适合密集元信息或筛选条件摘要场景。当标签数量超过最大显示数时,会显示省略号标签,并支持自定义标签渲染与属性。 + +## When to use | 适用场景 + +- **EN:** You show many tags in a tight space and want the extras collapsed into a "+N" popover instead of wrapping or scrolling. +- **CN:** 在有限空间内展示大量标签,希望多余的标签折叠成 "+N" 弹出层,而不是换行或滚动。 diff --git a/.storybook/stories/components/PulseAnimation.stories.tsx b/.storybook/stories/components/PulseAnimation/index.stories.tsx similarity index 95% rename from .storybook/stories/components/PulseAnimation.stories.tsx rename to .storybook/stories/components/PulseAnimation/index.stories.tsx index 28707d3..525a781 100644 --- a/.storybook/stories/components/PulseAnimation.stories.tsx +++ b/.storybook/stories/components/PulseAnimation/index.stories.tsx @@ -1,8 +1,8 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { Card, Space, Typography } from 'antd'; -import PulseAnimation from '../../../src/components/Animation/Pulse'; -import { useStoryT } from '../../locales'; -import apidoc from './PulseAnimation.apidoc.md?raw'; +import PulseAnimation from '../../../../src/components/Animation/Pulse'; +import { useStoryT } from '../../../locales'; +import introduce from './introduce.md?raw'; const meta: Meta = { title: 'Components/PulseAnimation', @@ -10,7 +10,7 @@ const meta: Meta = { parameters: { docs: { description: { - component: apidoc, + component: introduce, }, }, }, diff --git a/.storybook/stories/components/PulseAnimation.apidoc.md b/.storybook/stories/components/PulseAnimation/introduce.md similarity index 94% rename from .storybook/stories/components/PulseAnimation.apidoc.md rename to .storybook/stories/components/PulseAnimation/introduce.md index 05fe7c3..fe2016f 100644 --- a/.storybook/stories/components/PulseAnimation.apidoc.md +++ b/.storybook/stories/components/PulseAnimation/introduce.md @@ -1,7 +1,8 @@ - **EN:** A pulse bar animation component, suitable for audio activity or lightweight loading -feedback scenarios. + feedback scenarios. > Use `token.AnimationPulse` to globally configure `barMinSize` and `barMaxSize` properties + - **CN:** 一个脉冲柱状动画组件,适合音频活动态或轻量加载反馈场景。 > 使用 `token.AnimationPulse` 来全局配置 `barMinSize` 和 `barMaxSize` 属性 diff --git a/.storybook/stories/components/VirtualTextViewer.stories.tsx b/.storybook/stories/components/VirtualTextViewer/index.stories.tsx similarity index 94% rename from .storybook/stories/components/VirtualTextViewer.stories.tsx rename to .storybook/stories/components/VirtualTextViewer/index.stories.tsx index d3c78af..f75dea3 100644 --- a/.storybook/stories/components/VirtualTextViewer.stories.tsx +++ b/.storybook/stories/components/VirtualTextViewer/index.stories.tsx @@ -1,10 +1,10 @@ import { type CSSProperties, useMemo } from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; import type { ThemeVars } from 'storybook/theming'; -import VirtualTextViewer from '../../../src/components/VirtualTextViewer'; -import { storyT, useStoryT } from '../../locales'; -import { getGlobalValueFromUrl } from '../../utils/global'; -import apidoc from './VirtualTextViewer.apidoc.md?raw'; +import VirtualTextViewer from '../../../../src/components/VirtualTextViewer'; +import { storyT, useStoryT } from '../../../locales'; +import { getGlobalValueFromUrl } from '../../../utils/global'; +import introduce from './introduce.md?raw'; const demoText = buildLargeText(120, 60); const background = getGlobalValueFromUrl('backgrounds.value'); @@ -64,7 +64,7 @@ const meta: Meta = { parameters: { docs: { description: { - component: apidoc, + component: introduce, }, }, }, diff --git a/.storybook/stories/components/VirtualTextViewer.apidoc.md b/.storybook/stories/components/VirtualTextViewer/introduce.md similarity index 100% rename from .storybook/stories/components/VirtualTextViewer.apidoc.md rename to .storybook/stories/components/VirtualTextViewer/introduce.md From c8eefcffae40e177c651d45937bf131f9b4df461 Mon Sep 17 00:00:00 2001 From: Fengbao Li Date: Tue, 4 Aug 2026 11:10:38 +0800 Subject: [PATCH 03/39] refactor(storybook): split bilingual docs into locale-specific markdown files --- .../components/Iconfont/index.stories.tsx | 7 ++++--- .../components/Iconfont/introduce.en-US.md | 1 + .../{introduce.md => introduce.zh-CN.md} | 6 ------ .../{api-doc.md => api-doc.en-US.md} | 9 ++++----- .../components/OverflowTags/api-doc.zh-CN.md | 19 +++++++++++++++++++ .../components/OverflowTags/index.stories.tsx | 10 ++++++---- .../OverflowTags/introduce.en-US.md | 5 +++++ .../components/OverflowTags/introduce.md | 9 --------- .../OverflowTags/introduce.zh-CN.md | 5 +++++ 9 files changed, 44 insertions(+), 27 deletions(-) create mode 100644 .storybook/stories/components/Iconfont/introduce.en-US.md rename .storybook/stories/components/Iconfont/{introduce.md => introduce.zh-CN.md} (51%) rename .storybook/stories/components/OverflowTags/{api-doc.md => api-doc.en-US.md} (81%) create mode 100644 .storybook/stories/components/OverflowTags/api-doc.zh-CN.md create mode 100644 .storybook/stories/components/OverflowTags/introduce.en-US.md delete mode 100644 .storybook/stories/components/OverflowTags/introduce.md create mode 100644 .storybook/stories/components/OverflowTags/introduce.zh-CN.md diff --git a/.storybook/stories/components/Iconfont/index.stories.tsx b/.storybook/stories/components/Iconfont/index.stories.tsx index 7ab0d0a..34cbffe 100644 --- a/.storybook/stories/components/Iconfont/index.stories.tsx +++ b/.storybook/stories/components/Iconfont/index.stories.tsx @@ -2,9 +2,10 @@ import { useMemo } from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; import { Space, Typography } from 'antd'; import { createIconfont } from '../../../../src/components/Iconfont'; -import { useStoryT } from '../../../locales'; +import storyI18n, { useStoryT } from '../../../locales'; import apiDoc from './api-doc.md?raw'; -import introduce from './introduce.md?raw'; +import introduceEn from './introduce.en-US.md?raw'; +import introduceCN from './introduce.zh-CN.md?raw'; // Ant Design official iconfont demo URL, also used by the repo tests. const SCRIPT_URL = '//at.alicdn.com/t/font_8d5l8fzk5b87iudi.js'; @@ -33,7 +34,7 @@ const meta: Meta = { parameters: { docs: { description: { - component: introduce + apiDoc, + component: storyI18n.language === 'zh-CN' ? introduceCN + apiDoc : introduceEn + apiDoc, }, }, }, diff --git a/.storybook/stories/components/Iconfont/introduce.en-US.md b/.storybook/stories/components/Iconfont/introduce.en-US.md new file mode 100644 index 0000000..614a1f9 --- /dev/null +++ b/.storybook/stories/components/Iconfont/introduce.en-US.md @@ -0,0 +1 @@ +`createIconfont` is a factory that turns an iconfont script URL into a ready-to-use icon component. It wraps Ant Design's `createFromIconfontCN` and adds an `iconPrefix` option plus a `size` alias for `style.fontSize`. diff --git a/.storybook/stories/components/Iconfont/introduce.md b/.storybook/stories/components/Iconfont/introduce.zh-CN.md similarity index 51% rename from .storybook/stories/components/Iconfont/introduce.md rename to .storybook/stories/components/Iconfont/introduce.zh-CN.md index 434dbe3..b92425a 100644 --- a/.storybook/stories/components/Iconfont/introduce.md +++ b/.storybook/stories/components/Iconfont/introduce.zh-CN.md @@ -1,7 +1 @@ -- **EN:** - -`createIconfont` is a factory that turns an iconfont script URL into a ready-to-use icon component. It wraps Ant Design's `createFromIconfontCN` and adds an `iconPrefix` option plus a `size` alias for `style.fontSize`. - -- **CN:** - `createIconfont` 是一个工厂函数:传入 iconfont 脚本地址,返回一个可直接使用的图标组件。它封装了 Ant Design 的 `createFromIconfontCN`,额外提供 `iconPrefix` 选项与 `size`(`style.fontSize` 的别名)属性。 diff --git a/.storybook/stories/components/OverflowTags/api-doc.md b/.storybook/stories/components/OverflowTags/api-doc.en-US.md similarity index 81% rename from .storybook/stories/components/OverflowTags/api-doc.md rename to .storybook/stories/components/OverflowTags/api-doc.en-US.md index 4136009..5f5f6f4 100644 --- a/.storybook/stories/components/OverflowTags/api-doc.md +++ b/.storybook/stories/components/OverflowTags/api-doc.en-US.md @@ -13,9 +13,8 @@ | `ellipsisDropdownProps` | `DropdownProps` | - | Props for the overflow dropdown | | `randomColors` | `boolean` | `false` | Use random preset colors. A `color` field on the tag takes precedence | -## Notes | 说明 +## Notes -- **EN:** The overflow threshold is controlled by `maxCount` (from `rc-overflow`, default `'responsive'`), which you can pass through the base props. -- **CN:** 溢出阈值由 `maxCount`(来自 `rc-overflow`,默认 `'responsive'`)控制,可通过基础属性透传。 -- **EN:** If a tag carries its own `color`, that color wins over `randomColors`. -- **CN:** 若 tag 自身带 `color` 字段,则其优先级高于 `randomColors`。 +The overflow threshold is controlled by `maxCount` (from `rc-overflow`, default `'responsive'`), which you can pass through the base props. + +If a tag carries its own `color`, that color wins over `randomColors`. diff --git a/.storybook/stories/components/OverflowTags/api-doc.zh-CN.md b/.storybook/stories/components/OverflowTags/api-doc.zh-CN.md new file mode 100644 index 0000000..554d51a --- /dev/null +++ b/.storybook/stories/components/OverflowTags/api-doc.zh-CN.md @@ -0,0 +1,19 @@ +## API + +`OverflowTagsProps` 继承自 `rc-overflow` 的 `OverflowProps`(不包括 `renderItem`)并增加了: + +| 属性 | 类型 | 默认值 | 说明 | +| ----------------------- | ------------------------------------------------------------ | ------------------------- | ------------------------------------------------- | +| `tags` | `T[]` | `[]` | 标签数据集合 | +| `getTagName` | `(tag: T) => ReactNode` | `tag.label` or `tag.name` | 获取标签显示的内容 | +| `getTagKey` | `(tag: T) => React.Key` | `tag.value` or `tag.id` | 获取标签的唯一 key | +| `renderTag` | `OverflowProps['renderItem']` | - | 自定义标签渲染器 | +| `tagProps` | `TagProps \| ((tag: T, { tags }) => TagProps)` | - | `Tag` 组件的属性 | +| `ellipsisTagProps` | `TagProps \| ((tag, { omittedItems, allTags }) => TagProps)` | - | 省略号("+N")标签的属性 | +| `ellipsisDropdownProps` | `DropdownProps` | - | 溢出下拉菜单的属性 | +| `randomColors` | `boolean` | `false` | 使用随机预设颜色。标签上的 `color` 字段优先级更高 | + +## 说明 + +溢出阈值由 `maxCount`(来自 `rc-overflow`,默认 `'responsive'`)控制,可通过基础属性透传。 +若 tag 自身带 `color` 字段,则其优先级高于 `randomColors`。 diff --git a/.storybook/stories/components/OverflowTags/index.stories.tsx b/.storybook/stories/components/OverflowTags/index.stories.tsx index 6c4efbb..a07579f 100644 --- a/.storybook/stories/components/OverflowTags/index.stories.tsx +++ b/.storybook/stories/components/OverflowTags/index.stories.tsx @@ -1,9 +1,11 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { Typography } from 'antd'; import OverflowTags from '../../../../src/components/OverflowTags'; -import { storyT } from '../../../locales'; -import apiDoc from './api-doc.md?raw'; -import introduce from './introduce.md?raw'; +import storyI18n, { storyT } from '../../../locales'; +import apiDocEN from './api-doc.en-US.md?raw'; +import apiDocCN from './api-doc.zh-CN.md?raw'; +import introduceEn from './introduce.en-US.md?raw'; +import introduceCN from './introduce.zh-CN.md?raw'; interface TagItem { id: number; @@ -33,7 +35,7 @@ const meta: Meta> = { parameters: { docs: { description: { - component: introduce + apiDoc, + component: storyI18n.language === 'zh-CN' ? introduceCN + apiDocCN : introduceEn + apiDocEN, }, }, }, diff --git a/.storybook/stories/components/OverflowTags/introduce.en-US.md b/.storybook/stories/components/OverflowTags/introduce.en-US.md new file mode 100644 index 0000000..aee962e --- /dev/null +++ b/.storybook/stories/components/OverflowTags/introduce.en-US.md @@ -0,0 +1,5 @@ +Renders a tag collection with overflow collapsing, suitable for dense metadata or filter summaries. When the number of tags exceeds the maximum count, an ellipsis tag is shown; custom tag rendering and per-tag props are supported. + +## When to use + +You show many tags in a tight space and want the extras collapsed into a "+N" popover instead of wrapping or scrolling. diff --git a/.storybook/stories/components/OverflowTags/introduce.md b/.storybook/stories/components/OverflowTags/introduce.md deleted file mode 100644 index 6f8f331..0000000 --- a/.storybook/stories/components/OverflowTags/introduce.md +++ /dev/null @@ -1,9 +0,0 @@ -# OverflowTags - -- **EN:** Renders a tag collection with overflow collapsing, suitable for dense metadata or filter summaries. When the number of tags exceeds the maximum count, an ellipsis tag is shown; custom tag rendering and per-tag props are supported. -- **CN:** 用于渲染可折叠溢出的标签集合,适合密集元信息或筛选条件摘要场景。当标签数量超过最大显示数时,会显示省略号标签,并支持自定义标签渲染与属性。 - -## When to use | 适用场景 - -- **EN:** You show many tags in a tight space and want the extras collapsed into a "+N" popover instead of wrapping or scrolling. -- **CN:** 在有限空间内展示大量标签,希望多余的标签折叠成 "+N" 弹出层,而不是换行或滚动。 diff --git a/.storybook/stories/components/OverflowTags/introduce.zh-CN.md b/.storybook/stories/components/OverflowTags/introduce.zh-CN.md new file mode 100644 index 0000000..881adf6 --- /dev/null +++ b/.storybook/stories/components/OverflowTags/introduce.zh-CN.md @@ -0,0 +1,5 @@ +用于渲染可折叠溢出的标签集合,适合密集元信息或筛选条件摘要场景。当标签数量超过最大显示数时,会显示省略号标签,并支持自定义标签渲染与属性。 + +## 适用场景 + +在有限空间内展示大量标签,希望多余的标签折叠成 "+N" 弹出层,而不是换行或滚动。 From 72be43cdf327edcdf3c03f61a25f1d18255b0505 Mon Sep 17 00:00:00 2001 From: Fengbao Li Date: Tue, 4 Aug 2026 12:05:02 +0800 Subject: [PATCH 04/39] refactor(storybook): split component docs into per-locale markdown files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Align with OverflowTags: each component folder now has introduce.en-US.md, introduce.zh-CN.md, api-doc.en-US.md, api-doc.zh-CN.md - Split the previously bilingual (- **EN:** / - **CN:**) blocks and dual-language headings (## When to use | 适用场景, ## Notes | 说明) into separate locale files - Drop the '# Component' title line from intro docs (matches OverflowTags) - Update index.stories.tsx to import all four locale docs and switch the component description via storyI18n.language === 'zh-CN' ? CN : EN - Components covered: BreakLines, ColumnSetting, ConfigProvider, ConfirmAction, ContextMenu, DeleteConfirmAction, EditableText, EllipsisTypography, FloatDrawer, FormItemControl, Iconfont, Loading, ModalAction, PulseAnimation, VirtualTextViewer - Note: API table Description cells in en-US/zh-CN files keep the original English text (no CN translation added); bilingual narrative blocks are fully separated - Verified via build-storybook (all 16 component stories scanned) --- .../{api-doc.md => api-doc.en-US.md} | 5 +-- .../components/BreakLines/api-doc.zh-CN.md | 13 ++++++ .../components/BreakLines/index.stories.tsx | 10 +++-- .../components/BreakLines/introduce.en-US.md | 5 +++ .../components/BreakLines/introduce.md | 9 ---- .../components/BreakLines/introduce.zh-CN.md | 5 +++ .../{api-doc.md => api-doc.en-US.md} | 8 ++-- .../components/ColumnSetting/api-doc.zh-CN.md | 27 +++++++++++ .../ColumnSetting/index.stories.tsx | 10 +++-- .../ColumnSetting/introduce.en-US.md | 5 +++ .../components/ColumnSetting/introduce.md | 9 ---- .../ColumnSetting/introduce.zh-CN.md | 5 +++ .../ConfigProvider/index.stories.tsx | 7 +-- .../ConfigProvider/introduce.en-US.md | 1 + .../components/ConfigProvider/introduce.md | 2 - .../ConfigProvider/introduce.zh-CN.md | 1 + .../ConfirmAction/index.stories.tsx | 7 +-- .../ConfirmAction/introduce.en-US.md | 1 + .../components/ConfirmAction/introduce.md | 2 - .../ConfirmAction/introduce.zh-CN.md | 1 + .../{api-doc.md => api-doc.en-US.md} | 5 +-- .../components/ContextMenu/api-doc.zh-CN.md | 45 +++++++++++++++++++ .../components/ContextMenu/index.stories.tsx | 10 +++-- .../components/ContextMenu/introduce.en-US.md | 5 +++ .../components/ContextMenu/introduce.md | 9 ---- .../components/ContextMenu/introduce.zh-CN.md | 5 +++ .../DeleteConfirmAction/index.stories.tsx | 7 +-- .../DeleteConfirmAction/introduce.en-US.md | 1 + .../DeleteConfirmAction/introduce.md | 2 - .../DeleteConfirmAction/introduce.zh-CN.md | 1 + .../components/EditableText/index.stories.tsx | 7 +-- .../EditableText/introduce.en-US.md | 2 + .../components/EditableText/introduce.md | 3 -- .../EditableText/introduce.zh-CN.md | 1 + .../EllipsisTypography/index.stories.tsx | 7 +-- .../EllipsisTypography/introduce.en-US.md | 2 + .../EllipsisTypography/introduce.md | 3 -- .../EllipsisTypography/introduce.zh-CN.md | 1 + .../{api-doc.md => api-doc.en-US.md} | 8 ++-- .../components/FloatDrawer/api-doc.zh-CN.md | 27 +++++++++++ .../components/FloatDrawer/index.stories.tsx | 10 +++-- .../components/FloatDrawer/introduce.en-US.md | 5 +++ .../components/FloatDrawer/introduce.md | 9 ---- .../components/FloatDrawer/introduce.zh-CN.md | 5 +++ .../{api-doc.md => api-doc.en-US.md} | 5 +-- .../FormItemControl/api-doc.zh-CN.md | 15 +++++++ .../FormItemControl/index.stories.tsx | 10 +++-- .../FormItemControl/introduce.en-US.md | 5 +++ .../components/FormItemControl/introduce.md | 9 ---- .../FormItemControl/introduce.zh-CN.md | 5 +++ .../components/Iconfont/api-doc.en-US.md | 17 +++++++ .../stories/components/Iconfont/api-doc.md | 39 ---------------- .../components/Iconfont/api-doc.zh-CN.md | 17 +++++++ .../components/Iconfont/index.stories.tsx | 7 +-- .../Loading/{api-doc.md => api-doc.en-US.md} | 5 +-- .../components/Loading/api-doc.zh-CN.md | 13 ++++++ .../components/Loading/index.stories.tsx | 10 +++-- .../components/Loading/introduce.en-US.md | 10 +++++ .../stories/components/Loading/introduce.md | 14 ------ .../components/Loading/introduce.zh-CN.md | 10 +++++ .../components/ModalAction/index.stories.tsx | 7 +-- .../components/ModalAction/introduce.en-US.md | 1 + .../components/ModalAction/introduce.md | 2 - .../components/ModalAction/introduce.zh-CN.md | 1 + .../PulseAnimation/index.stories.tsx | 7 +-- .../PulseAnimation/introduce.en-US.md | 4 ++ .../components/PulseAnimation/introduce.md | 8 ---- .../PulseAnimation/introduce.zh-CN.md | 3 ++ .../VirtualTextViewer/index.stories.tsx | 7 +-- .../VirtualTextViewer/introduce.en-US.md | 1 + .../components/VirtualTextViewer/introduce.md | 2 - .../VirtualTextViewer/introduce.zh-CN.md | 1 + 72 files changed, 353 insertions(+), 195 deletions(-) rename .storybook/stories/components/BreakLines/{api-doc.md => api-doc.en-US.md} (80%) create mode 100644 .storybook/stories/components/BreakLines/api-doc.zh-CN.md create mode 100644 .storybook/stories/components/BreakLines/introduce.en-US.md delete mode 100644 .storybook/stories/components/BreakLines/introduce.md create mode 100644 .storybook/stories/components/BreakLines/introduce.zh-CN.md rename .storybook/stories/components/ColumnSetting/{api-doc.md => api-doc.en-US.md} (82%) create mode 100644 .storybook/stories/components/ColumnSetting/api-doc.zh-CN.md create mode 100644 .storybook/stories/components/ColumnSetting/introduce.en-US.md delete mode 100644 .storybook/stories/components/ColumnSetting/introduce.md create mode 100644 .storybook/stories/components/ColumnSetting/introduce.zh-CN.md create mode 100644 .storybook/stories/components/ConfigProvider/introduce.en-US.md delete mode 100644 .storybook/stories/components/ConfigProvider/introduce.md create mode 100644 .storybook/stories/components/ConfigProvider/introduce.zh-CN.md create mode 100644 .storybook/stories/components/ConfirmAction/introduce.en-US.md delete mode 100644 .storybook/stories/components/ConfirmAction/introduce.md create mode 100644 .storybook/stories/components/ConfirmAction/introduce.zh-CN.md rename .storybook/stories/components/ContextMenu/{api-doc.md => api-doc.en-US.md} (93%) create mode 100644 .storybook/stories/components/ContextMenu/api-doc.zh-CN.md create mode 100644 .storybook/stories/components/ContextMenu/introduce.en-US.md delete mode 100644 .storybook/stories/components/ContextMenu/introduce.md create mode 100644 .storybook/stories/components/ContextMenu/introduce.zh-CN.md create mode 100644 .storybook/stories/components/DeleteConfirmAction/introduce.en-US.md delete mode 100644 .storybook/stories/components/DeleteConfirmAction/introduce.md create mode 100644 .storybook/stories/components/DeleteConfirmAction/introduce.zh-CN.md create mode 100644 .storybook/stories/components/EditableText/introduce.en-US.md delete mode 100644 .storybook/stories/components/EditableText/introduce.md create mode 100644 .storybook/stories/components/EditableText/introduce.zh-CN.md create mode 100644 .storybook/stories/components/EllipsisTypography/introduce.en-US.md delete mode 100644 .storybook/stories/components/EllipsisTypography/introduce.md create mode 100644 .storybook/stories/components/EllipsisTypography/introduce.zh-CN.md rename .storybook/stories/components/FloatDrawer/{api-doc.md => api-doc.en-US.md} (88%) create mode 100644 .storybook/stories/components/FloatDrawer/api-doc.zh-CN.md create mode 100644 .storybook/stories/components/FloatDrawer/introduce.en-US.md delete mode 100644 .storybook/stories/components/FloatDrawer/introduce.md create mode 100644 .storybook/stories/components/FloatDrawer/introduce.zh-CN.md rename .storybook/stories/components/FormItemControl/{api-doc.md => api-doc.en-US.md} (64%) create mode 100644 .storybook/stories/components/FormItemControl/api-doc.zh-CN.md create mode 100644 .storybook/stories/components/FormItemControl/introduce.en-US.md delete mode 100644 .storybook/stories/components/FormItemControl/introduce.md create mode 100644 .storybook/stories/components/FormItemControl/introduce.zh-CN.md create mode 100644 .storybook/stories/components/Iconfont/api-doc.en-US.md delete mode 100644 .storybook/stories/components/Iconfont/api-doc.md create mode 100644 .storybook/stories/components/Iconfont/api-doc.zh-CN.md rename .storybook/stories/components/Loading/{api-doc.md => api-doc.en-US.md} (75%) create mode 100644 .storybook/stories/components/Loading/api-doc.zh-CN.md create mode 100644 .storybook/stories/components/Loading/introduce.en-US.md delete mode 100644 .storybook/stories/components/Loading/introduce.md create mode 100644 .storybook/stories/components/Loading/introduce.zh-CN.md create mode 100644 .storybook/stories/components/ModalAction/introduce.en-US.md delete mode 100644 .storybook/stories/components/ModalAction/introduce.md create mode 100644 .storybook/stories/components/ModalAction/introduce.zh-CN.md create mode 100644 .storybook/stories/components/PulseAnimation/introduce.en-US.md delete mode 100644 .storybook/stories/components/PulseAnimation/introduce.md create mode 100644 .storybook/stories/components/PulseAnimation/introduce.zh-CN.md create mode 100644 .storybook/stories/components/VirtualTextViewer/introduce.en-US.md delete mode 100644 .storybook/stories/components/VirtualTextViewer/introduce.md create mode 100644 .storybook/stories/components/VirtualTextViewer/introduce.zh-CN.md diff --git a/.storybook/stories/components/BreakLines/api-doc.md b/.storybook/stories/components/BreakLines/api-doc.en-US.md similarity index 80% rename from .storybook/stories/components/BreakLines/api-doc.md rename to .storybook/stories/components/BreakLines/api-doc.en-US.md index 4b4fba0..4351e6a 100644 --- a/.storybook/stories/components/BreakLines/api-doc.md +++ b/.storybook/stories/components/BreakLines/api-doc.en-US.md @@ -8,7 +8,6 @@ | `tagName` | `false \| 'span' \| 'div' \| 'i' \| 'pre' \| (string & {})` | `false` | HTML tag used to render the content. When `false`, content is rendered as a React fragment | | `className` | `string` | - | CSS class of the DOM node. Ignored when `tagName` is `false` | -## Notes | 说明 +## Notes -- **EN:** When `tagName` is `false` (default), the content is returned as a fragment with `
` between segments, so it can be embedded inline. -- **CN:** `tagName` 为 `false`(默认)时,内容以 React fragment 返回,段间用 `
`,可内联嵌入其它文本中。 +When `tagName` is `false` (default), the content is returned as a fragment with `
` between segments, so it can be embedded inline. diff --git a/.storybook/stories/components/BreakLines/api-doc.zh-CN.md b/.storybook/stories/components/BreakLines/api-doc.zh-CN.md new file mode 100644 index 0000000..5f02b5c --- /dev/null +++ b/.storybook/stories/components/BreakLines/api-doc.zh-CN.md @@ -0,0 +1,13 @@ +## API + +| Prop | Type | Default | Description | +| ----------- | ----------------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------ | +| `value` | `string \| undefined` | - | Text content to render | +| `enabled` | `boolean` | `true` | Whether line-break conversion is enabled | +| `EOL` | `string` | `'\n'` | The end-of-line character used to split lines | +| `tagName` | `false \| 'span' \| 'div' \| 'i' \| 'pre' \| (string & {})` | `false` | HTML tag used to render the content. When `false`, content is rendered as a React fragment | +| `className` | `string` | - | CSS class of the DOM node. Ignored when `tagName` is `false` | + +## 说明 + +`tagName` 为 `false`(默认)时,内容以 React fragment 返回,段间用 `
`,可内联嵌入其它文本中。 diff --git a/.storybook/stories/components/BreakLines/index.stories.tsx b/.storybook/stories/components/BreakLines/index.stories.tsx index fb4f908..7f9d747 100644 --- a/.storybook/stories/components/BreakLines/index.stories.tsx +++ b/.storybook/stories/components/BreakLines/index.stories.tsx @@ -1,8 +1,10 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import BreakLines, { type BreakLinesProps } from '../../../../src/components/BreakLines'; -import { storyT } from '../../../locales'; -import apiDoc from './api-doc.md?raw'; -import introduce from './introduce.md?raw'; +import storyI18n, { storyT } from '../../../locales'; +import apiDocEN from './api-doc.en-US.md?raw'; +import apiDocCN from './api-doc.zh-CN.md?raw'; +import introduceEN from './introduce.en-US.md?raw'; +import introduceCN from './introduce.zh-CN.md?raw'; const meta: Meta = { title: 'Components/BreakLines', @@ -10,7 +12,7 @@ const meta: Meta = { parameters: { docs: { description: { - component: introduce + apiDoc, + component: storyI18n.language === 'zh-CN' ? introduceCN + apiDocCN : introduceEN + apiDocEN, }, }, }, diff --git a/.storybook/stories/components/BreakLines/introduce.en-US.md b/.storybook/stories/components/BreakLines/introduce.en-US.md new file mode 100644 index 0000000..2ab018b --- /dev/null +++ b/.storybook/stories/components/BreakLines/introduce.en-US.md @@ -0,0 +1,5 @@ +Renders plain text with preserved line breaks by splitting with a configurable EOL marker and output tag. + +## When to use + +Display backend-provided text that contains `\n` (or another EOL marker) and you want it shown as multiple visual lines instead of a single wrapped paragraph. diff --git a/.storybook/stories/components/BreakLines/introduce.md b/.storybook/stories/components/BreakLines/introduce.md deleted file mode 100644 index 190ef93..0000000 --- a/.storybook/stories/components/BreakLines/introduce.md +++ /dev/null @@ -1,9 +0,0 @@ -# BreakLines - -- **EN:** Renders plain text with preserved line breaks by splitting with a configurable EOL marker and output tag. -- **CN:** 按指定换行符拆分文本并保留换行展示,可自定义输出标签。 - -## When to use | 适用场景 - -- **EN:** Display backend-provided text that contains `\n` (or another EOL marker) and you want it shown as multiple visual lines instead of a single wrapped paragraph. -- **CN:** 展示后端返回、包含 `\n`(或其它换行符)的文本,希望按原换行分段显示,而不是被当作一整段自动折行。 diff --git a/.storybook/stories/components/BreakLines/introduce.zh-CN.md b/.storybook/stories/components/BreakLines/introduce.zh-CN.md new file mode 100644 index 0000000..a9cc7bd --- /dev/null +++ b/.storybook/stories/components/BreakLines/introduce.zh-CN.md @@ -0,0 +1,5 @@ +按指定换行符拆分文本并保留换行展示,可自定义输出标签。 + +## 适用场景 + +展示后端返回、包含 `\n`(或其它换行符)的文本,希望按原换行分段显示,而不是被当作一整段自动折行。 diff --git a/.storybook/stories/components/ColumnSetting/api-doc.md b/.storybook/stories/components/ColumnSetting/api-doc.en-US.md similarity index 82% rename from .storybook/stories/components/ColumnSetting/api-doc.md rename to .storybook/stories/components/ColumnSetting/api-doc.en-US.md index 9bd09d2..e71834d 100644 --- a/.storybook/stories/components/ColumnSetting/api-doc.md +++ b/.storybook/stories/components/ColumnSetting/api-doc.en-US.md @@ -21,9 +21,7 @@ Extends Ant Design `ColumnType` and adds: | ---------- | --------- | ------------------------------------------- | | `disabled` | `boolean` | Disable toggling visibility for this column | -## Notes | 说明 +## Notes -- **EN:** At least one column always remains visible — the last visible column's checkbox is disabled to prevent hiding everything. -- **CN:** 至少会保留一列可见:最后一列可见项的勾选框会被禁用,避免把所有列都隐藏掉。 -- **EN:** When `storageKey` is set, the selection is read from `localStorage` on mount and written back on every change. -- **CN:** 设置 `storageKey` 后,挂载时会从 `localStorage` 读取选择,并在每次变更时写回。 +At least one column always remains visible — the last visible column's checkbox is disabled to prevent hiding everything. +When `storageKey` is set, the selection is read from `localStorage` on mount and written back on every change. diff --git a/.storybook/stories/components/ColumnSetting/api-doc.zh-CN.md b/.storybook/stories/components/ColumnSetting/api-doc.zh-CN.md new file mode 100644 index 0000000..ea57cbe --- /dev/null +++ b/.storybook/stories/components/ColumnSetting/api-doc.zh-CN.md @@ -0,0 +1,27 @@ +## API + +| Prop | Type | Default | Description | +| ------------------- | ------------------------------------------------------ | ------- | ----------------------------------------------------------------------------------- | +| `columns` | `ColumnSettingItem[]` | - | Column definitions including visibility, ordering, and `disabled` state | +| `onChange` | `(nextColumns: T[]) => void` | - | Called when the selected columns change; returns columns with `hidden` updated | +| `storageKey` | `string` | - | Local storage key for persisting column settings. If unset, persistence is disabled | +| `renderColumnTitle` | `(col: ColumnSettingItem, index: number) => ReactNode` | - | Custom renderer for column titles | +| `triggerProps` | `ButtonProps` | - | Props for the button that opens the dropdown | +| `dropdownProps` | `DropdownProps` | - | Props for the dropdown component | +| `popupProps` | `React.HTMLAttributes` | - | Props for the dropdown popup container | +| `checkAllProps` | `ButtonProps` | - | Props for the "Check All" button | +| `resetProps` | `ButtonProps` | - | Props for the "Reset" button | +| `prefixCls` | `string` | - | Custom CSS class prefix | + +### `ColumnSettingItem` + +Extends Ant Design `ColumnType` and adds: + +| Field | Type | Description | +| ---------- | --------- | ------------------------------------------- | +| `disabled` | `boolean` | Disable toggling visibility for this column | + +## 说明 + +至少会保留一列可见:最后一列可见项的勾选框会被禁用,避免把所有列都隐藏掉。 +设置 `storageKey` 后,挂载时会从 `localStorage` 读取选择,并在每次变更时写回。 diff --git a/.storybook/stories/components/ColumnSetting/index.stories.tsx b/.storybook/stories/components/ColumnSetting/index.stories.tsx index 7b30686..857b7db 100644 --- a/.storybook/stories/components/ColumnSetting/index.stories.tsx +++ b/.storybook/stories/components/ColumnSetting/index.stories.tsx @@ -5,9 +5,11 @@ import { Table } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import ColumnSetting from '../../../../src/components/ColumnSetting'; import type { ColumnSettingItem, ColumnSettingProps } from '../../../../src/components/ColumnSetting'; -import { storyT, useStoryT } from '../../../locales'; -import apiDoc from './api-doc.md?raw'; -import introduce from './introduce.md?raw'; +import storyI18n, { storyT, useStoryT } from '../../../locales'; +import apiDocEN from './api-doc.en-US.md?raw'; +import apiDocCN from './api-doc.zh-CN.md?raw'; +import introduceEN from './introduce.en-US.md?raw'; +import introduceCN from './introduce.zh-CN.md?raw'; const meta: Meta = { title: 'Components/ColumnSetting', @@ -15,7 +17,7 @@ const meta: Meta = { parameters: { docs: { description: { - component: introduce + apiDoc, + component: storyI18n.language === 'zh-CN' ? introduceCN + apiDocCN : introduceEN + apiDocEN, }, }, }, diff --git a/.storybook/stories/components/ColumnSetting/introduce.en-US.md b/.storybook/stories/components/ColumnSetting/introduce.en-US.md new file mode 100644 index 0000000..abb33b2 --- /dev/null +++ b/.storybook/stories/components/ColumnSetting/introduce.en-US.md @@ -0,0 +1,5 @@ +Helps users manage table columns such as visibility and ordering, and can persist preferences with a storage key. + +## When to use + +You have an Ant Design `Table` and want to let users toggle column visibility / reorder columns, optionally remembering their choice across sessions. diff --git a/.storybook/stories/components/ColumnSetting/introduce.md b/.storybook/stories/components/ColumnSetting/introduce.md deleted file mode 100644 index 16c2a49..0000000 --- a/.storybook/stories/components/ColumnSetting/introduce.md +++ /dev/null @@ -1,9 +0,0 @@ -# ColumnSetting - -- **EN:** Helps users manage table columns such as visibility and ordering, and can persist preferences with a storage key. -- **CN:** 用于管理表格列的显示与顺序,并可通过 `storageKey` 持久化用户偏好。 - -## When to use | 适用场景 - -- **EN:** You have an Ant Design `Table` and want to let users toggle column visibility / reorder columns, optionally remembering their choice across sessions. -- **CN:** 你使用 Ant Design 的 `Table`,希望让用户开关列显示 / 调整列顺序,并可选地在会话间记住其选择。 diff --git a/.storybook/stories/components/ColumnSetting/introduce.zh-CN.md b/.storybook/stories/components/ColumnSetting/introduce.zh-CN.md new file mode 100644 index 0000000..f8382d2 --- /dev/null +++ b/.storybook/stories/components/ColumnSetting/introduce.zh-CN.md @@ -0,0 +1,5 @@ +用于管理表格列的显示与顺序,并可通过 `storageKey` 持久化用户偏好。 + +## 适用场景 + +你使用 Ant Design 的 `Table`,希望让用户开关列显示 / 调整列顺序,并可选地在会话间记住其选择。 diff --git a/.storybook/stories/components/ConfigProvider/index.stories.tsx b/.storybook/stories/components/ConfigProvider/index.stories.tsx index a3d39ad..a43c563 100644 --- a/.storybook/stories/components/ConfigProvider/index.stories.tsx +++ b/.storybook/stories/components/ConfigProvider/index.stories.tsx @@ -2,8 +2,9 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { App as AntdApp, Space, Typography } from 'antd'; import ConfigProvider, { type ConfigProviderProps } from '../../../../src/components/ConfigProvider'; import ConfirmAction from '../../../../src/components/ConfirmAction'; -import { storyT, useStoryT } from '../../../locales'; -import introduce from './introduce.md?raw'; +import storyI18n, { storyT, useStoryT } from '../../../locales'; +import introduceEN from './introduce.en-US.md?raw'; +import introduceCN from './introduce.zh-CN.md?raw'; type ConfigProviderStoryArgs = ConfigProviderProps; @@ -13,7 +14,7 @@ const meta: Meta = { parameters: { docs: { description: { - component: introduce, + component: storyI18n.language === 'zh-CN' ? introduceCN : introduceEN, }, }, }, diff --git a/.storybook/stories/components/ConfigProvider/introduce.en-US.md b/.storybook/stories/components/ConfigProvider/introduce.en-US.md new file mode 100644 index 0000000..545ddde --- /dev/null +++ b/.storybook/stories/components/ConfigProvider/introduce.en-US.md @@ -0,0 +1 @@ +Provides shared configuration such as locale-aware copy and default action text for components in ReactEasy. diff --git a/.storybook/stories/components/ConfigProvider/introduce.md b/.storybook/stories/components/ConfigProvider/introduce.md deleted file mode 100644 index dc3ccb4..0000000 --- a/.storybook/stories/components/ConfigProvider/introduce.md +++ /dev/null @@ -1,2 +0,0 @@ -- **EN:** Provides shared configuration such as locale-aware copy and default action text for components in ReactEasy. -- **CN:** 为ReactEasy提供共享配置,例如本地化文案和确认类组件的默认文本。 diff --git a/.storybook/stories/components/ConfigProvider/introduce.zh-CN.md b/.storybook/stories/components/ConfigProvider/introduce.zh-CN.md new file mode 100644 index 0000000..5d66f36 --- /dev/null +++ b/.storybook/stories/components/ConfigProvider/introduce.zh-CN.md @@ -0,0 +1 @@ +为ReactEasy提供共享配置,例如本地化文案和确认类组件的默认文本。 diff --git a/.storybook/stories/components/ConfirmAction/index.stories.tsx b/.storybook/stories/components/ConfirmAction/index.stories.tsx index 9657d58..3c186ec 100644 --- a/.storybook/stories/components/ConfirmAction/index.stories.tsx +++ b/.storybook/stories/components/ConfirmAction/index.stories.tsx @@ -5,8 +5,9 @@ import { App as AntdApp } from 'antd'; import type { LinkProps } from 'antd/es/typography/Link'; import type { ConfirmActionProps } from '../../../../src/components/ConfirmAction'; import ConfirmAction from '../../../../src/components/ConfirmAction'; -import { storyT } from '../../../locales'; -import introduce from './introduce.md?raw'; +import storyI18n, { storyT } from '../../../locales'; +import introduceEN from './introduce.en-US.md?raw'; +import introduceCN from './introduce.zh-CN.md?raw'; type TriggerType = 'Button' | 'Switch' | 'Link'; @@ -19,7 +20,7 @@ const meta: Meta = { parameters: { docs: { description: { - component: introduce, + component: storyI18n.language === 'zh-CN' ? introduceCN : introduceEN, }, }, }, diff --git a/.storybook/stories/components/ConfirmAction/introduce.en-US.md b/.storybook/stories/components/ConfirmAction/introduce.en-US.md new file mode 100644 index 0000000..63cd9dc --- /dev/null +++ b/.storybook/stories/components/ConfirmAction/introduce.en-US.md @@ -0,0 +1 @@ +Wraps a trigger component and opens a confirm-style modal before executing the action. Can switch between Button, Switch, and Link trigger types. diff --git a/.storybook/stories/components/ConfirmAction/introduce.md b/.storybook/stories/components/ConfirmAction/introduce.md deleted file mode 100644 index 8d2e536..0000000 --- a/.storybook/stories/components/ConfirmAction/introduce.md +++ /dev/null @@ -1,2 +0,0 @@ -- **EN:** Wraps a trigger component and opens a confirm-style modal before executing the action. Can switch between Button, Switch, and Link trigger types. -- **CN:** 对触发器组件进行封装,在真正执行操作前弹出确认对话框,可切换 Button、Switch、Link 三种触发方式。 diff --git a/.storybook/stories/components/ConfirmAction/introduce.zh-CN.md b/.storybook/stories/components/ConfirmAction/introduce.zh-CN.md new file mode 100644 index 0000000..296e1ee --- /dev/null +++ b/.storybook/stories/components/ConfirmAction/introduce.zh-CN.md @@ -0,0 +1 @@ +对触发器组件进行封装,在真正执行操作前弹出确认对话框,可切换 Button、Switch、Link 三种触发方式。 diff --git a/.storybook/stories/components/ContextMenu/api-doc.md b/.storybook/stories/components/ContextMenu/api-doc.en-US.md similarity index 93% rename from .storybook/stories/components/ContextMenu/api-doc.md rename to .storybook/stories/components/ContextMenu/api-doc.en-US.md index 5adac95..0217fb4 100644 --- a/.storybook/stories/components/ContextMenu/api-doc.md +++ b/.storybook/stories/components/ContextMenu/api-doc.en-US.md @@ -40,7 +40,6 @@ | `show` | `(event: React.MouseEvent) => void` | Open the menu at the event position | | `hideAll` | `() => void` | Close all context menus | -## Notes | 说明 +## Notes -- **EN:** The `show`/`hideAll` methods are exposed via `ref`, useful for programmatic control. -- **CN:** `show` / `hideAll` 通过 `ref` 暴露,便于以编程方式控制菜单。 +The `show`/`hideAll` methods are exposed via `ref`, useful for programmatic control. diff --git a/.storybook/stories/components/ContextMenu/api-doc.zh-CN.md b/.storybook/stories/components/ContextMenu/api-doc.zh-CN.md new file mode 100644 index 0000000..07a3d23 --- /dev/null +++ b/.storybook/stories/components/ContextMenu/api-doc.zh-CN.md @@ -0,0 +1,45 @@ +## API + +| Prop | Type | Default | Description | +| -------------- | ------------------------------------------------------------------- | ----------------- | ----------------------------- | +| `items` | `(ContextMenuItem \| ContextMenuSeparator \| ContextMenuSubmenu)[]` | - | Menu items to display | +| `trigger` | `('click' \| 'doubleClick' \| 'hover' \| 'contextMenu')[]` | `['contextMenu']` | How the menu is triggered | +| `triggerProps` | `{ className?: string; style?: CSSProperties }` | - | Props for the trigger element | +| `prefixCls` | `string` | - | Custom CSS class prefix | + +> Other `MenuProps` (from `react-contexify`) are forwarded to the underlying ``. + +### `ContextMenuItem` + +| Field | Type | Description | +| ------------- | -------------------------------------- | ----------------------------------------- | +| `key` | `string` | Unique key | +| `label` | `ReactNode` | Item label | +| `icon` | `ReactNode` | Leading icon | +| `shortcutKey` | `Partial \| keyMatcher` | Keyboard shortcut matcher | +| `children` | `ReactNode` | Custom content (overrides `label`/`icon`) | + +### `ContextMenuSubmenu` + +| Field | Type | Description | +| ------- | ------------------------------------------------------------------- | ------------- | +| `key` | `string` | Unique key | +| `type` | `'submenu'` | Discriminator | +| `items` | `(ContextMenuItem \| ContextMenuSeparator \| ContextMenuSubmenu)[]` | Nested items | + +### `ContextMenuSeparator` + +| Field | Type | Description | +| ------ | ------------- | ------------- | +| `type` | `'separator'` | Discriminator | + +### Ref — `ContextMenuRef` + +| Method | Signature | Description | +| --------- | ----------------------------------- | ----------------------------------- | +| `show` | `(event: React.MouseEvent) => void` | Open the menu at the event position | +| `hideAll` | `() => void` | Close all context menus | + +## 说明 + +`show` / `hideAll` 通过 `ref` 暴露,便于以编程方式控制菜单。 diff --git a/.storybook/stories/components/ContextMenu/index.stories.tsx b/.storybook/stories/components/ContextMenu/index.stories.tsx index 3b6da93..af9f644 100644 --- a/.storybook/stories/components/ContextMenu/index.stories.tsx +++ b/.storybook/stories/components/ContextMenu/index.stories.tsx @@ -2,9 +2,11 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { fn } from 'storybook/test'; import { Button } from 'antd'; import ContextMenu from '../../../../src/components/ContextMenu'; -import { storyT, useStoryT } from '../../../locales'; -import apiDoc from './api-doc.md?raw'; -import introduce from './introduce.md?raw'; +import storyI18n, { storyT, useStoryT } from '../../../locales'; +import apiDocEN from './api-doc.en-US.md?raw'; +import apiDocCN from './api-doc.zh-CN.md?raw'; +import introduceEN from './introduce.en-US.md?raw'; +import introduceCN from './introduce.zh-CN.md?raw'; const meta: Meta = { title: 'Components/ContextMenu', @@ -12,7 +14,7 @@ const meta: Meta = { parameters: { docs: { description: { - component: introduce + apiDoc, + component: storyI18n.language === 'zh-CN' ? introduceCN + apiDocCN : introduceEN + apiDocEN, }, }, }, diff --git a/.storybook/stories/components/ContextMenu/introduce.en-US.md b/.storybook/stories/components/ContextMenu/introduce.en-US.md new file mode 100644 index 0000000..4afee67 --- /dev/null +++ b/.storybook/stories/components/ContextMenu/introduce.en-US.md @@ -0,0 +1,5 @@ +Renders a configurable context menu with support for shortcuts, separators, and nested submenu items. + +## When to use + +You need a right-click (or other trigger) menu on an element, with keyboard shortcuts, separators and multi-level submenus. diff --git a/.storybook/stories/components/ContextMenu/introduce.md b/.storybook/stories/components/ContextMenu/introduce.md deleted file mode 100644 index 73dfcaf..0000000 --- a/.storybook/stories/components/ContextMenu/introduce.md +++ /dev/null @@ -1,9 +0,0 @@ -# ContextMenu - -- **EN:** Renders a configurable context menu with support for shortcuts, separators, and nested submenu items. -- **CN:** 提供可配置的右键菜单能力,支持快捷键、分隔线和嵌套子菜单。 - -## When to use | 适用场景 - -- **EN:** You need a right-click (or other trigger) menu on an element, with keyboard shortcuts, separators and multi-level submenus. -- **CN:** 你需要在元素上提供右键(或其它触发方式)菜单,并支持快捷键、分隔线与多级子菜单。 diff --git a/.storybook/stories/components/ContextMenu/introduce.zh-CN.md b/.storybook/stories/components/ContextMenu/introduce.zh-CN.md new file mode 100644 index 0000000..6298bbf --- /dev/null +++ b/.storybook/stories/components/ContextMenu/introduce.zh-CN.md @@ -0,0 +1,5 @@ +提供可配置的右键菜单能力,支持快捷键、分隔线和嵌套子菜单。 + +## 适用场景 + +你需要在元素上提供右键(或其它触发方式)菜单,并支持快捷键、分隔线与多级子菜单。 diff --git a/.storybook/stories/components/DeleteConfirmAction/index.stories.tsx b/.storybook/stories/components/DeleteConfirmAction/index.stories.tsx index 0cac380..eb7c75e 100644 --- a/.storybook/stories/components/DeleteConfirmAction/index.stories.tsx +++ b/.storybook/stories/components/DeleteConfirmAction/index.stories.tsx @@ -5,8 +5,9 @@ import { App as AntdApp } from 'antd'; import type { LinkProps } from 'antd/es/typography/Link'; import type { ConfirmActionProps } from '../../../../src/components/ConfirmAction'; import DeleteConfirmAction from '../../../../src/components/DeleteConfirmAction'; -import { storyT } from '../../../locales'; -import introduce from './introduce.md?raw'; +import storyI18n, { storyT } from '../../../locales'; +import introduceEN from './introduce.en-US.md?raw'; +import introduceCN from './introduce.zh-CN.md?raw'; type TriggerType = 'Button' | 'Switch' | 'Link'; @@ -19,7 +20,7 @@ const meta: Meta = { parameters: { docs: { description: { - component: introduce, + component: storyI18n.language === 'zh-CN' ? introduceCN : introduceEN, }, }, }, diff --git a/.storybook/stories/components/DeleteConfirmAction/introduce.en-US.md b/.storybook/stories/components/DeleteConfirmAction/introduce.en-US.md new file mode 100644 index 0000000..d802744 --- /dev/null +++ b/.storybook/stories/components/DeleteConfirmAction/introduce.en-US.md @@ -0,0 +1 @@ +Wraps a trigger component and opens a delete confirmation modal before executing the action. Can switch between Button, Switch, and Link trigger types. diff --git a/.storybook/stories/components/DeleteConfirmAction/introduce.md b/.storybook/stories/components/DeleteConfirmAction/introduce.md deleted file mode 100644 index ff98ea8..0000000 --- a/.storybook/stories/components/DeleteConfirmAction/introduce.md +++ /dev/null @@ -1,2 +0,0 @@ -- **EN:** Wraps a trigger component and opens a delete confirmation modal before executing the action. Can switch between Button, Switch, and Link trigger types. -- **CN:** 对触发器组件进行封装,在真正执行操作前弹出删除确认框,可切换 Button、Switch、Link 三种触发方式。 diff --git a/.storybook/stories/components/DeleteConfirmAction/introduce.zh-CN.md b/.storybook/stories/components/DeleteConfirmAction/introduce.zh-CN.md new file mode 100644 index 0000000..97e7647 --- /dev/null +++ b/.storybook/stories/components/DeleteConfirmAction/introduce.zh-CN.md @@ -0,0 +1 @@ +对触发器组件进行封装,在真正执行操作前弹出删除确认框,可切换 Button、Switch、Link 三种触发方式。 diff --git a/.storybook/stories/components/EditableText/index.stories.tsx b/.storybook/stories/components/EditableText/index.stories.tsx index 77e7665..4bdf8da 100644 --- a/.storybook/stories/components/EditableText/index.stories.tsx +++ b/.storybook/stories/components/EditableText/index.stories.tsx @@ -1,8 +1,9 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { fn } from 'storybook/test'; import EditableText, { type EditableTextProps } from '../../../../src/components/EditableText'; -import { storyT } from '../../../locales'; -import introduce from './introduce.md?raw'; +import storyI18n, { storyT } from '../../../locales'; +import introduceEN from './introduce.en-US.md?raw'; +import introduceCN from './introduce.zh-CN.md?raw'; const meta: Meta = { title: 'Components/EditableText', @@ -10,7 +11,7 @@ const meta: Meta = { parameters: { docs: { description: { - component: introduce, + component: storyI18n.language === 'zh-CN' ? introduceCN : introduceEN, }, }, }, diff --git a/.storybook/stories/components/EditableText/introduce.en-US.md b/.storybook/stories/components/EditableText/introduce.en-US.md new file mode 100644 index 0000000..f3239c7 --- /dev/null +++ b/.storybook/stories/components/EditableText/introduce.en-US.md @@ -0,0 +1,2 @@ +Editable text component, providing a read-only display and an edit mode, supporting +inline text editing, with edit state, validation-related switches, and edit lifecycle callbacks. diff --git a/.storybook/stories/components/EditableText/introduce.md b/.storybook/stories/components/EditableText/introduce.md deleted file mode 100644 index 3bf9f91..0000000 --- a/.storybook/stories/components/EditableText/introduce.md +++ /dev/null @@ -1,3 +0,0 @@ -- **EN:** Editable text component, providing a read-only display and an edit mode, supporting - inline text editing, with edit state, validation-related switches, and edit lifecycle callbacks. -- **CN:** 可编辑文本组件,提供只读显示和编辑模式,支持行内文本编辑,提供编辑态、校验相关开关和编辑生命周期回调。 diff --git a/.storybook/stories/components/EditableText/introduce.zh-CN.md b/.storybook/stories/components/EditableText/introduce.zh-CN.md new file mode 100644 index 0000000..eb7e442 --- /dev/null +++ b/.storybook/stories/components/EditableText/introduce.zh-CN.md @@ -0,0 +1 @@ +可编辑文本组件,提供只读显示和编辑模式,支持行内文本编辑,提供编辑态、校验相关开关和编辑生命周期回调。 diff --git a/.storybook/stories/components/EllipsisTypography/index.stories.tsx b/.storybook/stories/components/EllipsisTypography/index.stories.tsx index e78c9ee..7cefcde 100644 --- a/.storybook/stories/components/EllipsisTypography/index.stories.tsx +++ b/.storybook/stories/components/EllipsisTypography/index.stories.tsx @@ -3,8 +3,9 @@ import EllipsisLinkComponent from '../../../../src/components/EllipsisTypography import EllipsisParagraphComponent from '../../../../src/components/EllipsisTypography/EllipsisParagraph'; import EllipsisTextComponent from '../../../../src/components/EllipsisTypography/EllipsisText'; import EllipsisTitleComponent from '../../../../src/components/EllipsisTypography/EllipsisTitle'; -import { storyT } from '../../../locales'; -import introduce from './introduce.md?raw'; +import storyI18n, { storyT } from '../../../locales'; +import introduceEN from './introduce.en-US.md?raw'; +import introduceCN from './introduce.zh-CN.md?raw'; const longText = storyT('storybook.stories.EllipsisTypography.longText'); @@ -13,7 +14,7 @@ const meta: Meta = { parameters: { docs: { description: { - component: introduce, + component: storyI18n.language === 'zh-CN' ? introduceCN : introduceEN, }, }, }, diff --git a/.storybook/stories/components/EllipsisTypography/introduce.en-US.md b/.storybook/stories/components/EllipsisTypography/introduce.en-US.md new file mode 100644 index 0000000..b913689 --- /dev/null +++ b/.storybook/stories/components/EllipsisTypography/introduce.en-US.md @@ -0,0 +1,2 @@ +A set of typography wrapper components with ellipsis capabilities, covering text, paragraph, title, and link variants. +When the text overflows, the ellipsis effect is displayed; if the text does not overflow, the tooltip is not shown. diff --git a/.storybook/stories/components/EllipsisTypography/introduce.md b/.storybook/stories/components/EllipsisTypography/introduce.md deleted file mode 100644 index afe1dfb..0000000 --- a/.storybook/stories/components/EllipsisTypography/introduce.md +++ /dev/null @@ -1,3 +0,0 @@ -- **EN:** A set of typography wrapper components with ellipsis capabilities, covering text, paragraph, title, and link variants. - When the text overflows, the ellipsis effect is displayed; if the text does not overflow, the tooltip is not shown. -- **CN:** 一组带省略能力的排版封装组件,覆盖文本、段落、标题和链接几种形态。在文本溢出时显示省略效果,如果文本没有溢出,则不显示tooltip。 diff --git a/.storybook/stories/components/EllipsisTypography/introduce.zh-CN.md b/.storybook/stories/components/EllipsisTypography/introduce.zh-CN.md new file mode 100644 index 0000000..8adbd95 --- /dev/null +++ b/.storybook/stories/components/EllipsisTypography/introduce.zh-CN.md @@ -0,0 +1 @@ +一组带省略能力的排版封装组件,覆盖文本、段落、标题和链接几种形态。在文本溢出时显示省略效果,如果文本没有溢出,则不显示tooltip。 diff --git a/.storybook/stories/components/FloatDrawer/api-doc.md b/.storybook/stories/components/FloatDrawer/api-doc.en-US.md similarity index 88% rename from .storybook/stories/components/FloatDrawer/api-doc.md rename to .storybook/stories/components/FloatDrawer/api-doc.en-US.md index 3cc445b..5ce2fbb 100644 --- a/.storybook/stories/components/FloatDrawer/api-doc.md +++ b/.storybook/stories/components/FloatDrawer/api-doc.en-US.md @@ -21,9 +21,7 @@ | `onClick` | `(e: React.MouseEvent) => void` | - | Click handler for the drawer container | | `children` | `ReactNode` | - | Drawer content | -## Notes | 说明 +## Notes -- **EN:** Size is applied as `width` for left/right and `height` for top/bottom. Use `edgeOffset` to fully hide the drawer behind the parent's padding/border. -- **CN:** 尺寸对左右为 `width`、上下为 `height`。若抽屉未完全隐藏在父容器外,可用 `edgeOffset` 调整偏移。 -- **EN:** When `cacheKey` is set, the size is stored in `localStorage` and restored on next mount. -- **CN:** 设置 `cacheKey` 后,抽屉尺寸会存入 `localStorage`,下次挂载时恢复。 +Size is applied as `width` for left/right and `height` for top/bottom. Use `edgeOffset` to fully hide the drawer behind the parent's padding/border. +When `cacheKey` is set, the size is stored in `localStorage` and restored on next mount. diff --git a/.storybook/stories/components/FloatDrawer/api-doc.zh-CN.md b/.storybook/stories/components/FloatDrawer/api-doc.zh-CN.md new file mode 100644 index 0000000..5864693 --- /dev/null +++ b/.storybook/stories/components/FloatDrawer/api-doc.zh-CN.md @@ -0,0 +1,27 @@ +## API + +| Prop | Type | Default | Description | +| --------------------- | ------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------ | +| `open` | `boolean` | - | Whether the drawer is open (controlled) | +| `position` | `'left' \| 'right' \| 'top' \| 'bottom'` | `'right'` | Drawer position | +| `defaultSize` | `number` | - | Default size (width for left/right, height otherwise); when unset, adapts to content | +| `minSize` | `number` | `0` | Minimum size | +| `maxSize` | `number` | `Infinity` | Maximum size | +| `edgeOffset` | `CSSProperties['width']` | `0` | Offset from the parent edge; tune so the drawer hides fully outside the container | +| `showToggle` | `boolean` | `true` | Whether to show the toggle handle | +| `resizable` | `boolean` | `true` | Whether the drawer is resizable | +| `destroyOnClose` | `boolean` | `false` | Destroy content when closed | +| `cacheKey` | `string` | - | localStorage key to persist drawer size | +| `cardProps` | `Omit` | - | Props for the inner `Card` | +| `className` / `style` | `string` / `CSSProperties` | - | Root class / style | +| `classNames` | `{ drawer; toggle; resizeHandle; handleIcon; content; card }` | - | Class names for specific parts | +| `styles` | `{ drawer; toggle; resizeHandle; handleIcon; content; card }` | - | Styles for specific parts | +| `onOpenChange` | `(open: boolean) => void` | - | Called when open state changes | +| `onResize` | `(size: number) => void` | - | Called when the drawer is resized | +| `onClick` | `(e: React.MouseEvent) => void` | - | Click handler for the drawer container | +| `children` | `ReactNode` | - | Drawer content | + +## 说明 + +尺寸对左右为 `width`、上下为 `height`。若抽屉未完全隐藏在父容器外,可用 `edgeOffset` 调整偏移。 +设置 `cacheKey` 后,抽屉尺寸会存入 `localStorage`,下次挂载时恢复。 diff --git a/.storybook/stories/components/FloatDrawer/index.stories.tsx b/.storybook/stories/components/FloatDrawer/index.stories.tsx index aefdf49..6cab83e 100644 --- a/.storybook/stories/components/FloatDrawer/index.stories.tsx +++ b/.storybook/stories/components/FloatDrawer/index.stories.tsx @@ -1,9 +1,11 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { fn } from 'storybook/test'; import FloatDrawer from '../../../../src/components/FloatDrawer'; -import { storyT, useStoryT } from '../../../locales'; -import apiDoc from './api-doc.md?raw'; -import introduce from './introduce.md?raw'; +import storyI18n, { storyT, useStoryT } from '../../../locales'; +import apiDocEN from './api-doc.en-US.md?raw'; +import apiDocCN from './api-doc.zh-CN.md?raw'; +import introduceEN from './introduce.en-US.md?raw'; +import introduceCN from './introduce.zh-CN.md?raw'; const meta: Meta = { title: 'Components/FloatDrawer', @@ -11,7 +13,7 @@ const meta: Meta = { parameters: { docs: { description: { - component: introduce + apiDoc, + component: storyI18n.language === 'zh-CN' ? introduceCN + apiDocCN : introduceEN + apiDocEN, }, }, }, diff --git a/.storybook/stories/components/FloatDrawer/introduce.en-US.md b/.storybook/stories/components/FloatDrawer/introduce.en-US.md new file mode 100644 index 0000000..a303fed --- /dev/null +++ b/.storybook/stories/components/FloatDrawer/introduce.en-US.md @@ -0,0 +1,5 @@ +FloatDrawer is a draggable, resizable drawer that can sit at any edge of a parent container, with a toggle handle and optional size persistence. + +## When to use + +You want a side panel that overlays part of a container (not a full-screen modal), can be collapsed, resized by dragging, and optionally remembers its size. diff --git a/.storybook/stories/components/FloatDrawer/introduce.md b/.storybook/stories/components/FloatDrawer/introduce.md deleted file mode 100644 index e4a9050..0000000 --- a/.storybook/stories/components/FloatDrawer/introduce.md +++ /dev/null @@ -1,9 +0,0 @@ -# FloatDrawer - -- **EN:** FloatDrawer is a draggable, resizable drawer that can sit at any edge of a parent container, with a toggle handle and optional size persistence. -- **CN:** FloatDrawer 是一个可拖动、可调整大小的浮动抽屉,可停靠在父容器的任意边缘,带展开/收起手柄,并可选记忆宽度。 - -## When to use | 适用场景 - -- **EN:** You want a side panel that overlays part of a container (not a full-screen modal), can be collapsed, resized by dragging, and optionally remembers its size. -- **CN:** 你需要一个覆盖在容器局部的侧边面板(而非全屏弹窗),可收起、可通过拖拽调整大小,并可选择性记忆尺寸。 diff --git a/.storybook/stories/components/FloatDrawer/introduce.zh-CN.md b/.storybook/stories/components/FloatDrawer/introduce.zh-CN.md new file mode 100644 index 0000000..b6b4af0 --- /dev/null +++ b/.storybook/stories/components/FloatDrawer/introduce.zh-CN.md @@ -0,0 +1,5 @@ +FloatDrawer 是一个可拖动、可调整大小的浮动抽屉,可停靠在父容器的任意边缘,带展开/收起手柄,并可选记忆宽度。 + +## 适用场景 + +你需要一个覆盖在容器局部的侧边面板(而非全屏弹窗),可收起、可通过拖拽调整大小,并可选择性记忆尺寸。 diff --git a/.storybook/stories/components/FormItemControl/api-doc.md b/.storybook/stories/components/FormItemControl/api-doc.en-US.md similarity index 64% rename from .storybook/stories/components/FormItemControl/api-doc.md rename to .storybook/stories/components/FormItemControl/api-doc.en-US.md index e219617..54bcf7a 100644 --- a/.storybook/stories/components/FormItemControl/api-doc.md +++ b/.storybook/stories/components/FormItemControl/api-doc.en-US.md @@ -4,10 +4,9 @@ | ---------- | ------------------------------------------------------------------------ | ----------------------------------------------------------------- | | `children` | `(options: { value: any; onChange: (value: any) => void }) => ReactNode` | Render prop receiving the current `value` and a setter `onChange` | -## Usage | 用法 +## Usage -- **EN:** Place it inside ``. The render prop receives the form value and a change handler, so your custom control becomes a controlled form field. -- **CN:** 把它放在 `` 内部。渲染函数会收到表单 `value` 与一个变更回调,自定义控件即可成为受控表单字段。 +Place it inside ``. The render prop receives the form value and a change handler, so your custom control becomes a controlled form field. ```tsx diff --git a/.storybook/stories/components/FormItemControl/api-doc.zh-CN.md b/.storybook/stories/components/FormItemControl/api-doc.zh-CN.md new file mode 100644 index 0000000..b4f9e7c --- /dev/null +++ b/.storybook/stories/components/FormItemControl/api-doc.zh-CN.md @@ -0,0 +1,15 @@ +## API + +| Prop | Type | Description | +| ---------- | ------------------------------------------------------------------------ | ----------------------------------------------------------------- | +| `children` | `(options: { value: any; onChange: (value: any) => void }) => ReactNode` | Render prop receiving the current `value` and a setter `onChange` | + +## 用法 + +把它放在 `` 内部。渲染函数会收到表单 `value` 与一个变更回调,自定义控件即可成为受控表单字段。 + +```tsx + + {({ value, onChange }) => } + +``` diff --git a/.storybook/stories/components/FormItemControl/index.stories.tsx b/.storybook/stories/components/FormItemControl/index.stories.tsx index 3ba1a57..54ea70d 100644 --- a/.storybook/stories/components/FormItemControl/index.stories.tsx +++ b/.storybook/stories/components/FormItemControl/index.stories.tsx @@ -1,9 +1,11 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { Form, Slider, Typography } from 'antd'; import FormItemControl from '../../../../src/components/FormItemControl'; -import { useStoryT } from '../../../locales'; -import apiDoc from './api-doc.md?raw'; -import introduce from './introduce.md?raw'; +import storyI18n, { useStoryT } from '../../../locales'; +import apiDocEN from './api-doc.en-US.md?raw'; +import apiDocCN from './api-doc.zh-CN.md?raw'; +import introduceEN from './introduce.en-US.md?raw'; +import introduceCN from './introduce.zh-CN.md?raw'; const meta: Meta = { title: 'Components/FormItemControl', @@ -11,7 +13,7 @@ const meta: Meta = { parameters: { docs: { description: { - component: introduce + apiDoc, + component: storyI18n.language === 'zh-CN' ? introduceCN + apiDocCN : introduceEN + apiDocEN, }, }, }, diff --git a/.storybook/stories/components/FormItemControl/introduce.en-US.md b/.storybook/stories/components/FormItemControl/introduce.en-US.md new file mode 100644 index 0000000..6fc1f6e --- /dev/null +++ b/.storybook/stories/components/FormItemControl/introduce.en-US.md @@ -0,0 +1,5 @@ +FormItemControl wraps custom content into a valid `Form.Item` control. It must be used as a direct child of `Form.Item`, and provides its children with `value` and `onChange` to interact with the form state. + +## When to use + +You have a custom widget (slider, color picker, rich editor, …) that you want to plug into an Ant Design `Form` without reimplementing `value`/`onChange` wiring. diff --git a/.storybook/stories/components/FormItemControl/introduce.md b/.storybook/stories/components/FormItemControl/introduce.md deleted file mode 100644 index a7609bd..0000000 --- a/.storybook/stories/components/FormItemControl/introduce.md +++ /dev/null @@ -1,9 +0,0 @@ -# FormItemControl - -- **EN:** FormItemControl wraps custom content into a valid `Form.Item` control. It must be used as a direct child of `Form.Item`, and provides its children with `value` and `onChange` to interact with the form state. -- **CN:** FormItemControl 将自定义内容包装成有效的 `Form.Item` 控件,必须作为 `Form.Item` 的直接子节点使用,并向子组件提供 `value` 与 `onChange` 以与表单状态交互。 - -## When to use | 适用场景 - -- **EN:** You have a custom widget (slider, color picker, rich editor, …) that you want to plug into an Ant Design `Form` without reimplementing `value`/`onChange` wiring. -- **CN:** 你有一个自定义控件(滑块、取色器、富文本编辑器等),希望接入 Ant Design 的 `Form`,而不必自己重新实现 `value`/`onChange` 的对接。 diff --git a/.storybook/stories/components/FormItemControl/introduce.zh-CN.md b/.storybook/stories/components/FormItemControl/introduce.zh-CN.md new file mode 100644 index 0000000..e443296 --- /dev/null +++ b/.storybook/stories/components/FormItemControl/introduce.zh-CN.md @@ -0,0 +1,5 @@ +FormItemControl 将自定义内容包装成有效的 `Form.Item` 控件,必须作为 `Form.Item` 的直接子节点使用,并向子组件提供 `value` 与 `onChange` 以与表单状态交互。 + +## 适用场景 + +你有一个自定义控件(滑块、取色器、富文本编辑器等),希望接入 Ant Design 的 `Form`,而不必自己重新实现 `value`/`onChange` 的对接。 diff --git a/.storybook/stories/components/Iconfont/api-doc.en-US.md b/.storybook/stories/components/Iconfont/api-doc.en-US.md new file mode 100644 index 0000000..973a88d --- /dev/null +++ b/.storybook/stories/components/Iconfont/api-doc.en-US.md @@ -0,0 +1,17 @@ +## API + +**Factory API** + +| Parameter | Type | Default | Description | +| -------------------- | -------- | ------- | --------------------------------------------------------------------------------- | +| `scriptUrl` | `string` | - | iconfont script URL generated from iconfont.cn | +| `options.iconPrefix` | `string` | `''` | Prefix automatically prepended to `type` unless it already starts with the prefix | + +**Component props** + +| Prop | Type | Default | Description | +| -------- | --------------------------- | ------- | ---------------------------------------------------- | +| `type` | `T` | - | Icon name. Find it in iconfont and click `Copy Code` | +| `size` | `CSSProperties['fontSize']` | - | Alias of `style.fontSize` | +| `spin` | `boolean` | `false` | Whether the icon spins continuously | +| `rotate` | `number` | `0` | Fixed clockwise rotation angle | diff --git a/.storybook/stories/components/Iconfont/api-doc.md b/.storybook/stories/components/Iconfont/api-doc.md deleted file mode 100644 index 150ecef..0000000 --- a/.storybook/stories/components/Iconfont/api-doc.md +++ /dev/null @@ -1,39 +0,0 @@ -- **EN:** - -## API - -**Factory API** - -| Parameter | Type | Default | Description | -| -------------------- | -------- | ------- | --------------------------------------------------------------------------------- | -| `scriptUrl` | `string` | - | iconfont script URL generated from iconfont.cn | -| `options.iconPrefix` | `string` | `''` | Prefix automatically prepended to `type` unless it already starts with the prefix | - -**Component props** - -| Prop | Type | Default | Description | -| -------- | --------------------------- | ------- | ---------------------------------------------------- | -| `type` | `T` | - | Icon name. Find it in iconfont and click `Copy Code` | -| `size` | `CSSProperties['fontSize']` | - | Alias of `style.fontSize` | -| `spin` | `boolean` | `false` | Whether the icon spins continuously | -| `rotate` | `number` | `0` | Fixed clockwise rotation angle | - -- **CN:** - -## API - -**工厂 API** - -| 参数 | 类型 | 默认值 | 说明 | -| -------------------- | -------- | ------ | ---------------------------------------------------------- | -| `scriptUrl` | `string` | - | 在 iconfont.cn 上生成的脚本地址 | -| `options.iconPrefix` | `string` | `''` | 自动拼接到 `type` 的前缀,若 `type` 已包含该前缀则不再拼接 | - -**组件 props** - -| 属性 | 类型 | 默认值 | 说明 | -| -------- | --------------------------- | ------- | ---------------------------------------------------- | -| `type` | `T` | - | 图标名称。在 iconfont 中找到某个图标,点击`复制代码` | -| `size` | `CSSProperties['fontSize']` | - | `style.fontSize` 的别名 | -| `spin` | `boolean` | `false` | 图标是否持续旋转 | -| `rotate` | `number` | `0` | 图标顺时针旋转一个固定角度 | diff --git a/.storybook/stories/components/Iconfont/api-doc.zh-CN.md b/.storybook/stories/components/Iconfont/api-doc.zh-CN.md new file mode 100644 index 0000000..34a2c1a --- /dev/null +++ b/.storybook/stories/components/Iconfont/api-doc.zh-CN.md @@ -0,0 +1,17 @@ +## API + +**工厂 API** + +| 参数 | 类型 | 默认值 | 说明 | +| -------------------- | -------- | ------ | ---------------------------------------------------------- | +| `scriptUrl` | `string` | - | 在 iconfont.cn 上生成的脚本地址 | +| `options.iconPrefix` | `string` | `''` | 自动拼接到 `type` 的前缀,若 `type` 已包含该前缀则不再拼接 | + +**组件 props** + +| 属性 | 类型 | 默认值 | 说明 | +| -------- | --------------------------- | ------- | ---------------------------------------------------- | +| `type` | `T` | - | 图标名称。在 iconfont 中找到某个图标,点击`复制代码` | +| `size` | `CSSProperties['fontSize']` | - | `style.fontSize` 的别名 | +| `spin` | `boolean` | `false` | 图标是否持续旋转 | +| `rotate` | `number` | `0` | 图标顺时针旋转一个固定角度 | diff --git a/.storybook/stories/components/Iconfont/index.stories.tsx b/.storybook/stories/components/Iconfont/index.stories.tsx index 34cbffe..179bafc 100644 --- a/.storybook/stories/components/Iconfont/index.stories.tsx +++ b/.storybook/stories/components/Iconfont/index.stories.tsx @@ -3,8 +3,9 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { Space, Typography } from 'antd'; import { createIconfont } from '../../../../src/components/Iconfont'; import storyI18n, { useStoryT } from '../../../locales'; -import apiDoc from './api-doc.md?raw'; -import introduceEn from './introduce.en-US.md?raw'; +import apiDocEN from './api-doc.en-US.md?raw'; +import apiDocCN from './api-doc.zh-CN.md?raw'; +import introduceEN from './introduce.en-US.md?raw'; import introduceCN from './introduce.zh-CN.md?raw'; // Ant Design official iconfont demo URL, also used by the repo tests. @@ -34,7 +35,7 @@ const meta: Meta = { parameters: { docs: { description: { - component: storyI18n.language === 'zh-CN' ? introduceCN + apiDoc : introduceEn + apiDoc, + component: storyI18n.language === 'zh-CN' ? introduceCN + apiDocCN : introduceEN + apiDocEN, }, }, }, diff --git a/.storybook/stories/components/Loading/api-doc.md b/.storybook/stories/components/Loading/api-doc.en-US.md similarity index 75% rename from .storybook/stories/components/Loading/api-doc.md rename to .storybook/stories/components/Loading/api-doc.en-US.md index e14a013..6310553 100644 --- a/.storybook/stories/components/Loading/api-doc.md +++ b/.storybook/stories/components/Loading/api-doc.en-US.md @@ -8,7 +8,6 @@ Extends Ant Design `SpinProps` and adds: | `rootClassName` | `string` | - | Class name for the mask parent container (standalone mode) | | `rootStyle` | `CSSProperties` | - | Style for the mask parent container (standalone mode) | -## Notes | 说明 +## Notes -- **EN:** In standalone mode the component fills its parent, so the parent should be `position: relative` (or otherwise sized) for correct layout. -- **CN:** 独立使用时组件会撑满父容器,因此父容器应为 `position: relative`(或具备确定尺寸)才能正确布局。 +In standalone mode the component fills its parent, so the parent should be `position: relative` (or otherwise sized) for correct layout. diff --git a/.storybook/stories/components/Loading/api-doc.zh-CN.md b/.storybook/stories/components/Loading/api-doc.zh-CN.md new file mode 100644 index 0000000..64787f6 --- /dev/null +++ b/.storybook/stories/components/Loading/api-doc.zh-CN.md @@ -0,0 +1,13 @@ +## API + +Extends Ant Design `SpinProps` and adds: + +| Prop | Type | Default | Description | +| --------------- | ---------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------- | +| `mode` | `'absolute' \| 'flex'` | `'flex'` | Positioning of the standalone animation. `absolute` centers via absolute positioning; `flex` fills the parent with flexbox | +| `rootClassName` | `string` | - | Class name for the mask parent container (standalone mode) | +| `rootStyle` | `CSSProperties` | - | Style for the mask parent container (standalone mode) | + +## 说明 + +独立使用时组件会撑满父容器,因此父容器应为 `position: relative`(或具备确定尺寸)才能正确布局。 diff --git a/.storybook/stories/components/Loading/index.stories.tsx b/.storybook/stories/components/Loading/index.stories.tsx index 6c41696..a7c8984 100644 --- a/.storybook/stories/components/Loading/index.stories.tsx +++ b/.storybook/stories/components/Loading/index.stories.tsx @@ -1,9 +1,11 @@ import type { ComponentProps } from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; import Loading from '../../../../src/components/Loading'; -import { storyT, useStoryT } from '../../../locales'; -import apiDoc from './api-doc.md?raw'; -import introduce from './introduce.md?raw'; +import storyI18n, { storyT, useStoryT } from '../../../locales'; +import apiDocEN from './api-doc.en-US.md?raw'; +import apiDocCN from './api-doc.zh-CN.md?raw'; +import introduceEN from './introduce.en-US.md?raw'; +import introduceCN from './introduce.zh-CN.md?raw'; const meta: Meta = { title: 'Components/Loading', @@ -11,7 +13,7 @@ const meta: Meta = { parameters: { docs: { description: { - component: introduce + apiDoc, + component: storyI18n.language === 'zh-CN' ? introduceCN + apiDocCN : introduceEN + apiDocEN, }, }, }, diff --git a/.storybook/stories/components/Loading/introduce.en-US.md b/.storybook/stories/components/Loading/introduce.en-US.md new file mode 100644 index 0000000..13d84e4 --- /dev/null +++ b/.storybook/stories/components/Loading/introduce.en-US.md @@ -0,0 +1,10 @@ +Displays a loading indicator that can work as an overlay (wrapping children) or as a standalone visual state that fills and centers within its parent. + +## When to use + +Show a spinner over existing content, or render a standalone centered loading placeholder inside a relatively-positioned container. + +## Two usage modes + +1. **Spin (overlay)** — pass `children`; the component wraps them with Ant Design `Spin` and toggles the animation via `spinning`. +2. **Standalone** — no children; renders a self-filled, centered loading animation. Hidden automatically when `spinning` is `false`. diff --git a/.storybook/stories/components/Loading/introduce.md b/.storybook/stories/components/Loading/introduce.md deleted file mode 100644 index 3c22e82..0000000 --- a/.storybook/stories/components/Loading/introduce.md +++ /dev/null @@ -1,14 +0,0 @@ -# Loading - -- **EN:** Displays a loading indicator that can work as an overlay (wrapping children) or as a standalone visual state that fills and centers within its parent. -- **CN:** 用于展示加载状态,可作为覆盖层(包裹 children),也可单独作为视觉占位:自动撑满父容器并居中显示。 - -## When to use | 适用场景 - -- **EN:** Show a spinner over existing content, or render a standalone centered loading placeholder inside a relatively-positioned container. -- **CN:** 在已有内容上叠加 spinner,或在相对定位的容器内渲染一个独立、居中、撑满的加载占位。 - -## Two usage modes | 两种用法 - -1. **Spin (overlay)** — pass `children`; the component wraps them with Ant Design `Spin` and toggles the animation via `spinning`. -2. **Standalone** — no children; renders a self-filled, centered loading animation. Hidden automatically when `spinning` is `false`. diff --git a/.storybook/stories/components/Loading/introduce.zh-CN.md b/.storybook/stories/components/Loading/introduce.zh-CN.md new file mode 100644 index 0000000..d2f7aab --- /dev/null +++ b/.storybook/stories/components/Loading/introduce.zh-CN.md @@ -0,0 +1,10 @@ +用于展示加载状态,可作为覆盖层(包裹 children),也可单独作为视觉占位:自动撑满父容器并居中显示。 + +## 适用场景 + +在已有内容上叠加 spinner,或在相对定位的容器内渲染一个独立、居中、撑满的加载占位。 + +## 两种用法 + +1. **Spin (overlay)** — pass `children`; the component wraps them with Ant Design `Spin` and toggles the animation via `spinning`. +2. **Standalone** — no children; renders a self-filled, centered loading animation. Hidden automatically when `spinning` is `false`. diff --git a/.storybook/stories/components/ModalAction/index.stories.tsx b/.storybook/stories/components/ModalAction/index.stories.tsx index 5407342..8e3b4de 100644 --- a/.storybook/stories/components/ModalAction/index.stories.tsx +++ b/.storybook/stories/components/ModalAction/index.stories.tsx @@ -5,8 +5,9 @@ import { type ButtonProps, Form, Input, message, Space, type SwitchProps } from import type { LinkProps } from 'antd/es/typography/Link'; import type { FormCompPropsConstraint, ModalActionProps } from '../../../../src/components/ModalAction'; import { withModalAction } from '../../../../src/components/ModalAction'; -import { storyT, useStoryT } from '../../../locales'; -import introduce from './introduce.md?raw'; +import storyI18n, { storyT, useStoryT } from '../../../locales'; +import introduceEN from './introduce.en-US.md?raw'; +import introduceCN from './introduce.zh-CN.md?raw'; type ModalActionStoryArgs = ModalActionProps< UserForm, @@ -55,7 +56,7 @@ const meta: Meta = { parameters: { docs: { description: { - component: introduce, + component: storyI18n.language === 'zh-CN' ? introduceCN : introduceEN, }, }, }, diff --git a/.storybook/stories/components/ModalAction/introduce.en-US.md b/.storybook/stories/components/ModalAction/introduce.en-US.md new file mode 100644 index 0000000..a82410e --- /dev/null +++ b/.storybook/stories/components/ModalAction/introduce.en-US.md @@ -0,0 +1 @@ +A modal-based action component that wraps a trigger and opens a form dialog before executing the action. The editing form is provided by the caller via `formComp`; the parent automatically creates the form instance and wires save/close lifecycle. Can switch between Button, Switch, and Link trigger types. diff --git a/.storybook/stories/components/ModalAction/introduce.md b/.storybook/stories/components/ModalAction/introduce.md deleted file mode 100644 index 00984b0..0000000 --- a/.storybook/stories/components/ModalAction/introduce.md +++ /dev/null @@ -1,2 +0,0 @@ -- **EN:** A modal-based action component that wraps a trigger and opens a form dialog before executing the action. The editing form is provided by the caller via `formComp`; the parent automatically creates the form instance and wires save/close lifecycle. Can switch between Button, Switch, and Link trigger types. -- **CN:** 基于弹框的动作组件,封装触发器并在真正执行操作前打开表单对话框。编辑表单由调用方通过 `formComp` 提供,父组件自动创建表单实例并接管保存/关闭生命周期。可切换 Button、Switch、Link 三种触发方式。 diff --git a/.storybook/stories/components/ModalAction/introduce.zh-CN.md b/.storybook/stories/components/ModalAction/introduce.zh-CN.md new file mode 100644 index 0000000..bc296d9 --- /dev/null +++ b/.storybook/stories/components/ModalAction/introduce.zh-CN.md @@ -0,0 +1 @@ +基于弹框的动作组件,封装触发器并在真正执行操作前打开表单对话框。编辑表单由调用方通过 `formComp` 提供,父组件自动创建表单实例并接管保存/关闭生命周期。可切换 Button、Switch、Link 三种触发方式。 diff --git a/.storybook/stories/components/PulseAnimation/index.stories.tsx b/.storybook/stories/components/PulseAnimation/index.stories.tsx index 525a781..bf498b3 100644 --- a/.storybook/stories/components/PulseAnimation/index.stories.tsx +++ b/.storybook/stories/components/PulseAnimation/index.stories.tsx @@ -1,8 +1,9 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { Card, Space, Typography } from 'antd'; import PulseAnimation from '../../../../src/components/Animation/Pulse'; -import { useStoryT } from '../../../locales'; -import introduce from './introduce.md?raw'; +import storyI18n, { useStoryT } from '../../../locales'; +import introduceEN from './introduce.en-US.md?raw'; +import introduceCN from './introduce.zh-CN.md?raw'; const meta: Meta = { title: 'Components/PulseAnimation', @@ -10,7 +11,7 @@ const meta: Meta = { parameters: { docs: { description: { - component: introduce, + component: storyI18n.language === 'zh-CN' ? introduceCN : introduceEN, }, }, }, diff --git a/.storybook/stories/components/PulseAnimation/introduce.en-US.md b/.storybook/stories/components/PulseAnimation/introduce.en-US.md new file mode 100644 index 0000000..565eb45 --- /dev/null +++ b/.storybook/stories/components/PulseAnimation/introduce.en-US.md @@ -0,0 +1,4 @@ +A pulse bar animation component, suitable for audio activity or lightweight loading +feedback scenarios. + +> Use `token.AnimationPulse` to globally configure `barMinSize` and `barMaxSize` properties diff --git a/.storybook/stories/components/PulseAnimation/introduce.md b/.storybook/stories/components/PulseAnimation/introduce.md deleted file mode 100644 index fe2016f..0000000 --- a/.storybook/stories/components/PulseAnimation/introduce.md +++ /dev/null @@ -1,8 +0,0 @@ -- **EN:** A pulse bar animation component, suitable for audio activity or lightweight loading - feedback scenarios. - -> Use `token.AnimationPulse` to globally configure `barMinSize` and `barMaxSize` properties - -- **CN:** 一个脉冲柱状动画组件,适合音频活动态或轻量加载反馈场景。 - -> 使用 `token.AnimationPulse` 来全局配置 `barMinSize` 和 `barMaxSize` 属性 diff --git a/.storybook/stories/components/PulseAnimation/introduce.zh-CN.md b/.storybook/stories/components/PulseAnimation/introduce.zh-CN.md new file mode 100644 index 0000000..9fdc684 --- /dev/null +++ b/.storybook/stories/components/PulseAnimation/introduce.zh-CN.md @@ -0,0 +1,3 @@ +一个脉冲柱状动画组件,适合音频活动态或轻量加载反馈场景。 + +> 使用 `token.AnimationPulse` 来全局配置 `barMinSize` 和 `barMaxSize` 属性 diff --git a/.storybook/stories/components/VirtualTextViewer/index.stories.tsx b/.storybook/stories/components/VirtualTextViewer/index.stories.tsx index f75dea3..2fb0dbc 100644 --- a/.storybook/stories/components/VirtualTextViewer/index.stories.tsx +++ b/.storybook/stories/components/VirtualTextViewer/index.stories.tsx @@ -2,9 +2,10 @@ import { type CSSProperties, useMemo } from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; import type { ThemeVars } from 'storybook/theming'; import VirtualTextViewer from '../../../../src/components/VirtualTextViewer'; -import { storyT, useStoryT } from '../../../locales'; +import storyI18n, { storyT, useStoryT } from '../../../locales'; +import introduceEN from './introduce.en-US.md?raw'; +import introduceCN from './introduce.zh-CN.md?raw'; import { getGlobalValueFromUrl } from '../../../utils/global'; -import introduce from './introduce.md?raw'; const demoText = buildLargeText(120, 60); const background = getGlobalValueFromUrl('backgrounds.value'); @@ -64,7 +65,7 @@ const meta: Meta = { parameters: { docs: { description: { - component: introduce, + component: storyI18n.language === 'zh-CN' ? introduceCN : introduceEN, }, }, }, diff --git a/.storybook/stories/components/VirtualTextViewer/introduce.en-US.md b/.storybook/stories/components/VirtualTextViewer/introduce.en-US.md new file mode 100644 index 0000000..9fec4a2 --- /dev/null +++ b/.storybook/stories/components/VirtualTextViewer/introduce.en-US.md @@ -0,0 +1 @@ +A simplified large-text virtualization component built on top of Pretext. It predicts wrapped line geometry without DOM text measurement and only materializes the visible line window. diff --git a/.storybook/stories/components/VirtualTextViewer/introduce.md b/.storybook/stories/components/VirtualTextViewer/introduce.md deleted file mode 100644 index 08289b5..0000000 --- a/.storybook/stories/components/VirtualTextViewer/introduce.md +++ /dev/null @@ -1,2 +0,0 @@ -- **EN:** A simplified large-text virtualization component built on top of Pretext. It predicts wrapped line geometry without DOM text measurement and only materializes the visible line window. -- **CN:** 一个基于 Pretext 的简化版大文本虚拟查看组件。它不依赖 DOM 文本测量来预测换行几何,并且只物化可见行窗口。 diff --git a/.storybook/stories/components/VirtualTextViewer/introduce.zh-CN.md b/.storybook/stories/components/VirtualTextViewer/introduce.zh-CN.md new file mode 100644 index 0000000..a7b399d --- /dev/null +++ b/.storybook/stories/components/VirtualTextViewer/introduce.zh-CN.md @@ -0,0 +1 @@ +一个基于 Pretext 的简化版大文本虚拟查看组件。它不依赖 DOM 文本测量来预测换行几何,并且只物化可见行窗口。 From 63553995c47bab2733b6f7edfff93efced39448a Mon Sep 17 00:00:00 2001 From: Fengbao Li Date: Tue, 4 Aug 2026 17:46:22 +0800 Subject: [PATCH 05/39] docs: improve storybook preview --- .storybook/preview.tsx | 118 ++++++++-------------------------------- .storybook/utils/doc.ts | 70 ++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 95 deletions(-) create mode 100644 .storybook/utils/doc.ts diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 7c25c36..d2aac89 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -1,29 +1,21 @@ import type { ComponentType, PropsWithChildren } from 'react'; import { useMemo, useState } from 'react'; import type { Control, DocsContainerProps } from '@storybook/addon-docs/blocks'; -import { - Controls, - DocsContainer, - Markdown, - Primary, - Stories, - Subtitle, - Title, - useOf, -} from '@storybook/addon-docs/blocks'; +import { Controls, DocsContainer, Markdown, Primary, Subtitle, Title, useOf } from '@storybook/addon-docs/blocks'; import type { Preview, ReactRenderer } from '@storybook/react-vite'; -import { App as AntdApp, ConfigProvider as AntdConfigProvider, theme as antThemes } from 'antd'; -import enUS from 'antd/es/locale/en_US'; -import zhCN from 'antd/es/locale/zh_CN'; import { FORCE_RE_RENDER } from 'storybook/internal/core-events'; import type { StoryContext, StoryContextForEnhancers } from 'storybook/internal/csf'; import type { ResolvedModuleExportFromType } from 'storybook/internal/types'; import { addons, useStoryContext } from 'storybook/preview-api'; import { themes } from 'storybook/theming'; +import { App as AntdApp, ConfigProvider as AntdConfigProvider, theme as antThemes } from 'antd'; +import enUS from 'antd/es/locale/en_US'; +import zhCN from 'antd/es/locale/zh_CN'; import ConfigProvider from '../src/components/ConfigProvider'; import { useRefValue } from '../src/hooks'; import type { Langs } from '../src/locales'; import storyI18n, { storyT } from './locales'; +import { pickLangDoc } from './utils/doc'; import { getGlobalValueFromUrl } from './utils/global'; import { inferControlFromDocgenType, standardizeJsDocDefaultValue } from './utils/jsdoc'; @@ -35,7 +27,7 @@ const isPreferDark = window.matchMedia('(prefers-color-scheme: dark)').matches; const preview: Preview = { initialGlobals: { - lang: 'en-US', + lang: '', backgrounds: { value: themeFromUrl ?? (isPreferDark ? 'dark' : 'light'), grid: false, @@ -85,23 +77,27 @@ const preview: Preview = { extractComponentDescription: ( component: ComponentType & { __docgenInfo?: { description?: string }; - } + }, ) => { const raw = component?.__docgenInfo?.description ?? ''; let result = stripExampleBlock(raw); - result = removeOtherLang(result); + result = pickLangDoc(result); return result; }, - page: () => ( - <> - - <Subtitle /> - <CustomComponentDescription /> - <Primary /> - <Controls /> - <Stories /> - </> - ), + page: () => { + const langFromUrl = getGlobalValueFromUrl('lang'); + return ( + <> + <Title /> + <Subtitle /> + <CustomComponentDescription /> + <h2>{langFromUrl === 'zh-CN' ? '演示' : 'Demo'}</h2> + <Primary /> + <Controls /> + {/* <Stories /> */} + </> + ); + }, }, }, tags: ['autodocs'], @@ -153,74 +149,6 @@ const preview: Preview = { argTypesEnhancers: [jsdocArgTypesEnhancer], }; -function removeOtherLang(input = '') { - const langFromUrl = getGlobalValueFromUrl('lang'); - const currentLang = langFromUrl === 'zh-CN' ? 'zh-CN' : 'en-US'; - return keepCurrentLangContent(input, currentLang); -} - -function keepCurrentLangContent(input = '', lang: Langs = 'en-US') { - const targetLang = lang === 'zh-CN' ? 'CN' : 'EN'; - - // Compatible with JSDoc original text (with *) and plain text extracted by docgen - const lines = input.split(/\r?\n/).map((line) => line.replace(/^\s*\*\s?/, '')); - - const result: string[] = []; - let blockLang: 'EN' | 'CN' | null = null; - let blockLines: string[] = []; - - // Language block: - **EN:** xxx or - **CN:** xxx - const langHeaderReg = /^-\s*\*\*(EN|CN):\*\*\s*(.*)$/; - // JSDoc 标签:@param @returns ... - const jsdocTagReg = /^@\w+/; - - const flushBlock = () => { - if (blockLang === targetLang) { - result.push(...blockLines); - } - blockLang = null; - blockLines = []; - }; - - for (const line of lines) { - const headerMatch = line.match(langHeaderReg); - - if (headerMatch) { - if (blockLang) { - flushBlock(); - } - const [, langFlag, firstContent = ''] = headerMatch; - blockLang = langFlag as 'EN' | 'CN'; - blockLines = firstContent ? [firstContent] : []; - continue; - } - - if (blockLang) { - // Encounter @param/@returns indicating the end of the language block, tag content should be retained - if (jsdocTagReg.test(line)) { - flushBlock(); - result.push(line); - } else { - blockLines.push(line); - } - continue; - } - - // Non-internationalized content remains unchanged. - result.push(line); - } - - if (blockLang) { - flushBlock(); - } - - // 压缩多余空行 - return result - .join('\n') - .replace(/\n{3,}/g, '\n\n') - .trim(); -} - function stripExampleBlock(input = '') { return ( input @@ -285,7 +213,7 @@ function CustomComponentDescription() { function processDescription(content: string | undefined) { const raw = content ?? ''; let result = stripExampleBlock(raw); - result = removeOtherLang(result); + result = pickLangDoc(result); return result; } diff --git a/.storybook/utils/doc.ts b/.storybook/utils/doc.ts new file mode 100644 index 0000000..ef6088c --- /dev/null +++ b/.storybook/utils/doc.ts @@ -0,0 +1,70 @@ +import type { Langs } from '../../src/locales'; +import { getGlobalValueFromUrl } from './global'; + +export const pickLangDoc = (input = '') => { + const langFromUrl = getGlobalValueFromUrl('lang'); + const currentLang = langFromUrl === 'zh-CN' ? 'zh-CN' : 'en-US'; + return keepCurrentLangContent(input, currentLang); +}; + +function keepCurrentLangContent(input = '', lang: Langs = 'en-US') { + const targetLang = lang === 'zh-CN' ? 'CN' : 'EN'; + + // Compatible with JSDoc original text (with *) and plain text extracted by docgen + const lines = input.split(/\r?\n/).map((line) => line.replace(/^\s*\*\s?/, '')); + + const result: string[] = []; + let blockLang: 'EN' | 'CN' | null = null; + let blockLines: string[] = []; + + // Language block: - **EN:** xxx or - **CN:** xxx + const langHeaderReg = /^-\s*\*\*(EN|CN):\*\*\s*(.*)$/; + // JSDoc 标签:@param @returns ... + const jsdocTagReg = /^@\w+/; + + const flushBlock = () => { + if (blockLang === targetLang) { + result.push(...blockLines); + } + blockLang = null; + blockLines = []; + }; + + for (const line of lines) { + const headerMatch = line.match(langHeaderReg); + + if (headerMatch) { + if (blockLang) { + flushBlock(); + } + const [, langFlag, firstContent = ''] = headerMatch; + blockLang = langFlag as 'EN' | 'CN'; + blockLines = firstContent ? [firstContent] : []; + continue; + } + + if (blockLang) { + // Encounter @param/@returns indicating the end of the language block, tag content should be retained + if (jsdocTagReg.test(line)) { + flushBlock(); + result.push(line); + } else { + blockLines.push(line); + } + continue; + } + + // Non-internationalized content remains unchanged. + result.push(line); + } + + if (blockLang) { + flushBlock(); + } + + // 压缩多余空行 + return result + .join('\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} From dd3615539db950da3ab30d386d426fa9d6b5cd36 Mon Sep 17 00:00:00 2001 From: Fengbao Li <shijistar@gmail.com> Date: Tue, 4 Aug 2026 20:40:10 +0800 Subject: [PATCH 06/39] refactor(storybook): extract argTypes/story descriptions into i18n resources - Extract inline bilingual (- **EN:** / - **CN:**) description strings from argTypes[prop].description and story.parameters.docs.description.story across 7 components into storybook.stories.<Comp>.{argTypes.<prop>|stories.<Story>}.description keys in en-US.ts / zh-CN.ts - Replace inline strings with storyT('...') references (consistent with BreakLines pattern) - EllipsisTypography's repeated 'text' description is merged into one shared key since the underlying text is identical across its four sub-stories - Components covered: ConfirmAction, DeleteConfirmAction, EditableText, EllipsisTypography, Iconfont, ModalAction, PulseAnimation - Verified via build-storybook (all 16 component stories scanned) and prettier --- .storybook/locales/langs/en-US.ts | 77 +++++++++++++++++++ .storybook/locales/langs/zh-CN.ts | 63 +++++++++++++++ .../ConfirmAction/index.stories.tsx | 24 ++---- .../DeleteConfirmAction/index.stories.tsx | 15 ++-- .../components/EditableText/index.stories.tsx | 18 ++--- .../EllipsisTypography/index.stories.tsx | 41 ++++------ .../components/Iconfont/index.stories.tsx | 27 +++---- .../components/ModalAction/index.stories.tsx | 6 +- .../PulseAnimation/index.stories.tsx | 30 +++----- 9 files changed, 195 insertions(+), 106 deletions(-) diff --git a/.storybook/locales/langs/en-US.ts b/.storybook/locales/langs/en-US.ts index e708e44..0de02bb 100644 --- a/.storybook/locales/langs/en-US.ts +++ b/.storybook/locales/langs/en-US.ts @@ -203,5 +203,82 @@ const enUS = { 'storybook.stories.useStreamDownloader.logs.cancel': 'Cancel requested', 'storybook.stories.useStreamDownloader.logs.reset': 'Snapshot reset to idle', 'storybook.stories.useStreamDownloader.logs.error': 'Download failed', + 'storybook.stories.ConfirmAction.argTypes.autoFocusButton.description': + 'The button to auto focus when the confirm action is opened.', + 'storybook.stories.ConfirmAction.argTypes.contentColor.description': 'The color of the content text.', + 'storybook.stories.ConfirmAction.argTypes.iconColor.description': 'The color of the icon.', + 'storybook.stories.ConfirmAction.argTypes.titleColor.description': 'The color of the title text.', + 'storybook.stories.ConfirmAction.argTypes.triggerProps.description': + 'Props of the trigger component, will change according to the trigger type.', + 'storybook.stories.ConfirmAction.argTypes.triggerType.description': + 'Demo-only option to switch trigger component type, \`triggerProps\` will change according to the type', + 'storybook.stories.ConfirmAction.argTypes.type.description': 'Type of the confirm action.', + 'storybook.stories.ConfirmAction.stories.Playground.description': + 'Switch between Button, Switch, and Link triggers to compare how the same confirm flow is exposed.', + 'storybook.stories.DeleteConfirmAction.argTypes.autoFocusButton.description': + 'The button to auto focus when the confirm action is opened.', + 'storybook.stories.DeleteConfirmAction.argTypes.contentColor.description': 'The color of the content text.', + 'storybook.stories.DeleteConfirmAction.argTypes.iconColor.description': 'The color of the icon.', + 'storybook.stories.DeleteConfirmAction.argTypes.titleColor.description': 'The color of the title text.', + 'storybook.stories.DeleteConfirmAction.argTypes.triggerType.description': + 'Demo-only option to switch trigger component type.', + 'storybook.stories.EditableText.argTypes.editable.description': 'Controls whether the edit affordance is available.', + 'storybook.stories.EditableText.argTypes.editing.description': + 'Forces the component into editing mode when controlled externally.', + 'storybook.stories.EditableText.argTypes.required.description': + 'Marks the editor as required when confirming changes.', + 'storybook.stories.EditableText.argTypes.textComp.description': + 'Typography component type used to render the read-only text state.', + 'storybook.stories.EditableText.argTypes.value.description': + 'Current text value displayed and edited by the component.', + 'storybook.stories.EditableText.stories.Playground.description': + 'Try toggling editable and editing state to inspect the inline editing experience.', + 'storybook.stories.EllipsisTypography.stories.EllipsisLink.argTypes.ellipsis.description': + 'Ellipsis configuration. Set \`true\` for automatic ellipsis and tooltip behavior.', + 'storybook.stories.EllipsisTypography.stories.EllipsisLink.argTypes.href.description': 'Link target URL.', + 'storybook.stories.EllipsisTypography.stories.EllipsisLink.description': + 'Link ellipsis example for long URLs or labels.', + 'storybook.stories.EllipsisTypography.stories.EllipsisParagraph.argTypes.ellipsis.description': + 'Ellipsis configuration with optional rows and tooltip settings.', + 'storybook.stories.EllipsisTypography.stories.EllipsisParagraph.description': + 'Multi-line paragraph ellipsis with tooltip support.', + 'storybook.stories.EllipsisTypography.stories.EllipsisText.description': 'Single-line text ellipsis example.', + 'storybook.stories.EllipsisTypography.stories.EllipsisTitle.argTypes.ellipsis.description': + 'Ellipsis configuration. Supports automatic tooltip title behavior.', + 'storybook.stories.EllipsisTypography.stories.EllipsisTitle.argTypes.level.description': 'Title level.', + 'storybook.stories.EllipsisTypography.stories.EllipsisTitle.description': + 'Heading ellipsis example with configurable title level.', + 'storybook.stories.Iconfont.argTypes.color.description': 'Icon color.', + 'storybook.stories.Iconfont.argTypes.iconPrefix.description': + 'Demo-only prefix. The factory prepends it to \`type\` unless the type already starts with it.', + 'storybook.stories.Iconfont.argTypes.rotate.description': 'Fixed clockwise rotation angle.', + 'storybook.stories.Iconfont.argTypes.scriptUrl.description': 'URL of the iconfont script.', + 'storybook.stories.Iconfont.argTypes.size.description': 'Icon size in pixels.', + 'storybook.stories.Iconfont.argTypes.spin.description': 'Whether the icon spins continuously.', + 'storybook.stories.Iconfont.argTypes.type.description': 'Icon name provided by the loaded iconfont script.', + 'storybook.stories.Iconfont.stories.IconGallery.description': + 'All icons shipped by the official demo iconfont script.', + 'storybook.stories.Iconfont.stories.Playground.description': + 'Pick an icon and tune size, color, spin, rotation, or the demo prefix. The resolved \`type\` is shown below the icon.', + 'storybook.stories.ModalAction.argTypes.triggerType.description': + 'Demo-only option to switch trigger component type.', + 'storybook.stories.ModalAction.stories.Playground.description': + 'Demonstrates create and edit flows backed by the same form component and different trigger variants.', + 'storybook.stories.PulseAnimation.argTypes.barColor.description': 'Base color of the animation bars.', + 'storybook.stories.PulseAnimation.argTypes.barGap.description': 'Horizontal gap between bars.', + 'storybook.stories.PulseAnimation.argTypes.barStyle.description': 'Extra styles applied to each bar.', + 'storybook.stories.PulseAnimation.argTypes.bars.description': 'Number of animated bars.', + 'storybook.stories.PulseAnimation.argTypes.delayRate.description': + 'Delay offset rate used to stagger each bar animation.', + 'storybook.stories.PulseAnimation.argTypes.duration.description': 'Animation duration in seconds.', + 'storybook.stories.PulseAnimation.argTypes.prefixCls.description': 'Custom CSS class prefix for the component.', + 'storybook.stories.PulseAnimation.stories.AudioActivity.description': + 'A typical audio-activity indicator: many thin bars with a tight gap and a short stagger delay, rendered inside a player card.', + 'storybook.stories.PulseAnimation.stories.LightLoading.description': + 'Use pulse bars as a lightweight loading indicator next to text content.', + 'storybook.stories.PulseAnimation.stories.Playground.description': + 'Adjust bar count, gap, and timing controls to explore different motion rhythms.', + 'storybook.stories.EllipsisTypography.argTypes.text.description': + 'Text content. If omitted, \`children\` can be used as fallback.', } as const; export default enUS; diff --git a/.storybook/locales/langs/zh-CN.ts b/.storybook/locales/langs/zh-CN.ts index 78c8703..8265aa9 100644 --- a/.storybook/locales/langs/zh-CN.ts +++ b/.storybook/locales/langs/zh-CN.ts @@ -200,5 +200,68 @@ const zhCN = { 'storybook.stories.useStreamDownloader.logs.cancel': '已请求取消', 'storybook.stories.useStreamDownloader.logs.reset': '快照已重置为 idle', 'storybook.stories.useStreamDownloader.logs.error': '下载失败', + 'storybook.stories.ConfirmAction.argTypes.autoFocusButton.description': '确认操作打开时自动获取焦点的按钮。', + 'storybook.stories.ConfirmAction.argTypes.contentColor.description': '内容文本的颜色。', + 'storybook.stories.ConfirmAction.argTypes.iconColor.description': '图标的颜色。', + 'storybook.stories.ConfirmAction.argTypes.titleColor.description': '标题文本的颜色。', + 'storybook.stories.ConfirmAction.argTypes.triggerProps.description': + '触发器组件的Props属性,随触发器类型变化进行调整类型定义。', + 'storybook.stories.ConfirmAction.argTypes.triggerType.description': + '示例专用:切换触发器组件类型,\`triggerProps\`会根据类型而变化类型定义。', + 'storybook.stories.ConfirmAction.argTypes.type.description': '确认操作的类型。', + 'storybook.stories.ConfirmAction.stories.Playground.description': + '可切换 Button、Switch、Link 三种触发方式,对比相同确认流程的接入形式。', + 'storybook.stories.DeleteConfirmAction.argTypes.autoFocusButton.description': '确认操作打开时自动获取焦点的按钮。', + 'storybook.stories.DeleteConfirmAction.argTypes.contentColor.description': '内容文本的颜色。', + 'storybook.stories.DeleteConfirmAction.argTypes.iconColor.description': '图标的颜色。', + 'storybook.stories.DeleteConfirmAction.argTypes.titleColor.description': '标题文本的颜色。', + 'storybook.stories.DeleteConfirmAction.argTypes.triggerType.description': '示例专用:切换触发器组件类型。', + 'storybook.stories.EditableText.argTypes.editable.description': '控制是否允许进入编辑状态。', + 'storybook.stories.EditableText.argTypes.editing.description': '外部受控地将组件切换到编辑状态。', + 'storybook.stories.EditableText.argTypes.required.description': '在确认修改时将输入视为必填。', + 'storybook.stories.EditableText.argTypes.textComp.description': '只读文本态所使用的排版组件类型。', + 'storybook.stories.EditableText.argTypes.value.description': '组件当前展示和编辑的文本值。', + 'storybook.stories.EditableText.stories.Playground.description': + '可切换 editable 和 editing 状态,观察行内编辑体验。', + 'storybook.stories.EllipsisTypography.stories.EllipsisLink.argTypes.ellipsis.description': + '省略配置。设为 \`true\` 可启用自动省略与 tooltip 行为。', + 'storybook.stories.EllipsisTypography.stories.EllipsisLink.argTypes.href.description': '链接跳转地址。', + 'storybook.stories.EllipsisTypography.stories.EllipsisLink.description': '适用于长链接文本或长链接标签的省略示例。', + 'storybook.stories.EllipsisTypography.stories.EllipsisParagraph.argTypes.ellipsis.description': + '省略配置,支持行数和 tooltip 设置。', + 'storybook.stories.EllipsisTypography.stories.EllipsisParagraph.description': '支持 tooltip 的多行段落省略示例。', + 'storybook.stories.EllipsisTypography.stories.EllipsisText.description': '单行文本省略示例。', + 'storybook.stories.EllipsisTypography.stories.EllipsisTitle.argTypes.ellipsis.description': + '省略配置,支持自动 tooltip title 行为。', + 'storybook.stories.EllipsisTypography.stories.EllipsisTitle.argTypes.level.description': '标题层级。', + 'storybook.stories.EllipsisTypography.stories.EllipsisTitle.description': '支持标题层级配置的标题省略示例。', + 'storybook.stories.Iconfont.argTypes.color.description': '图标颜色。', + 'storybook.stories.Iconfont.argTypes.iconPrefix.description': + '示例专用前缀。工厂会将其拼接到 \`type\` 前,除非 \`type\` 已包含该前缀。', + 'storybook.stories.Iconfont.argTypes.rotate.description': '图标顺时针旋转的固定角度。', + 'storybook.stories.Iconfont.argTypes.scriptUrl.description': 'iconfont 脚本的 URL。', + 'storybook.stories.Iconfont.argTypes.size.description': '图标尺寸(像素)。', + 'storybook.stories.Iconfont.argTypes.spin.description': '图标是否持续旋转。', + 'storybook.stories.Iconfont.argTypes.type.description': '已加载的 iconfont 脚本提供的图标名称。', + 'storybook.stories.Iconfont.stories.IconGallery.description': '官方示例 iconfont 脚本内置的全部图标。', + 'storybook.stories.Iconfont.stories.Playground.description': + '选择图标并调整尺寸、颜色、旋转、spin 或示例前缀。图标下方会展示最终解析出的 \`type\`。', + 'storybook.stories.ModalAction.argTypes.triggerType.description': '示例专用:切换触发器组件类型。', + 'storybook.stories.ModalAction.stories.Playground.description': + '演示同一个表单组件在创建、编辑流程以及不同触发器形态下的复用方式。', + 'storybook.stories.PulseAnimation.argTypes.barColor.description': '动画柱条的基础颜色。', + 'storybook.stories.PulseAnimation.argTypes.barGap.description': '柱条之间的水平间距。', + 'storybook.stories.PulseAnimation.argTypes.barStyle.description': '应用于每根柱条的额外样式。', + 'storybook.stories.PulseAnimation.argTypes.bars.description': '动画柱条数量。', + 'storybook.stories.PulseAnimation.argTypes.delayRate.description': '每根柱条的错峰延迟系数。', + 'storybook.stories.PulseAnimation.argTypes.duration.description': '动画总时长,单位为秒。', + 'storybook.stories.PulseAnimation.argTypes.prefixCls.description': '组件的自定义 CSS 类前缀。', + 'storybook.stories.PulseAnimation.stories.AudioActivity.description': + '典型的音频活动指示器:细密柱条 + 小间距 + 短错峰延迟,放在播放器卡片中演示。', + 'storybook.stories.PulseAnimation.stories.LightLoading.description': + '将脉冲柱条作为轻量加载指示器,与文本内容并排展示。', + 'storybook.stories.PulseAnimation.stories.Playground.description': + '可调整柱子数量、间距和节奏参数,观察不同的动画律动效果。', + 'storybook.stories.EllipsisTypography.argTypes.text.description': '文本内容;未传时可由 \`children\` 兜底。', } as const; export default zhCN; diff --git a/.storybook/stories/components/ConfirmAction/index.stories.tsx b/.storybook/stories/components/ConfirmAction/index.stories.tsx index 3c186ec..079a8d5 100644 --- a/.storybook/stories/components/ConfirmAction/index.stories.tsx +++ b/.storybook/stories/components/ConfirmAction/index.stories.tsx @@ -61,44 +61,37 @@ const meta: Meta<ConfirmActionStoryArgs> = { triggerType: { control: 'radio', options: ['Button', 'Switch', 'Link'], - description: `- **EN:** Demo-only option to switch trigger component type, \`triggerProps\` will change according to the type -- **CN:** 示例专用:切换触发器组件类型,\`triggerProps\`会根据类型而变化类型定义。`, + description: storyT('storybook.stories.ConfirmAction.argTypes.triggerType.description'), table: { defaultValue: { summary: '"Button"' } }, }, type: { control: 'select', options: ['info', 'success', 'error', 'warn', 'warning', 'confirm'], - description: `- **EN:** Type of the confirm action. -- **CN:** 确认操作的类型。`, + description: storyT('storybook.stories.ConfirmAction.argTypes.type.description'), table: { defaultValue: { summary: '"confirm"' } }, }, triggerProps: { - description: `- **EN:** Props of the trigger component, will change according to the trigger type. -- **CN:** 触发器组件的Props属性,随触发器类型变化进行调整类型定义。`, + description: storyT('storybook.stories.ConfirmAction.argTypes.triggerProps.description'), }, autoFocusButton: { control: 'select', options: ['ok', 'cancel'], - description: `- **EN:** The button to auto focus when the confirm action is opened. -- **CN:** 确认操作打开时自动获取焦点的按钮。`, + description: storyT('storybook.stories.ConfirmAction.argTypes.autoFocusButton.description'), table: { defaultValue: { summary: 'undefined' } }, }, iconColor: { options: ['info', 'success', 'error', 'warn', 'warning', 'secondary'], - description: `- **EN:** The color of the icon. -- **CN:** 图标的颜色。`, + description: storyT('storybook.stories.ConfirmAction.argTypes.iconColor.description'), table: { defaultValue: { summary: 'undefined' } }, }, titleColor: { options: ['info', 'success', 'error', 'warn', 'warning', 'secondary'], - description: `- **EN:** The color of the title text. -- **CN:** 标题文本的颜色。`, + description: storyT('storybook.stories.ConfirmAction.argTypes.titleColor.description'), table: { defaultValue: { summary: 'undefined' } }, }, contentColor: { options: ['info', 'success', 'error', 'warn', 'warning', 'secondary'], - description: `- **EN:** The color of the content text. -- **CN:** 内容文本的颜色。`, + description: storyT('storybook.stories.ConfirmAction.argTypes.contentColor.description'), table: { defaultValue: { summary: 'undefined' } }, }, }, @@ -116,8 +109,7 @@ export const Playground: Story = { parameters: { docs: { description: { - story: `- **EN:** Switch between Button, Switch, and Link triggers to compare how the same confirm flow is exposed. -- **CN:** 可切换 Button、Switch、Link 三种触发方式,对比相同确认流程的接入形式。`, + story: storyT('storybook.stories.ConfirmAction.stories.Playground.description'), }, }, }, diff --git a/.storybook/stories/components/DeleteConfirmAction/index.stories.tsx b/.storybook/stories/components/DeleteConfirmAction/index.stories.tsx index eb7c75e..868b735 100644 --- a/.storybook/stories/components/DeleteConfirmAction/index.stories.tsx +++ b/.storybook/stories/components/DeleteConfirmAction/index.stories.tsx @@ -60,33 +60,28 @@ const meta: Meta<DeleteConfirmActionStoryArgs> = { triggerType: { control: 'radio', options: ['Button', 'Switch', 'Link'], - description: `- **EN:** Demo-only option to switch trigger component type. -- **CN:** 示例专用:切换触发器组件类型。`, + description: storyT('storybook.stories.DeleteConfirmAction.argTypes.triggerType.description'), table: { defaultValue: { summary: '"Button"' } }, }, autoFocusButton: { control: 'select', options: ['ok', 'cancel'], - description: `- **EN:** The button to auto focus when the confirm action is opened. -- **CN:** 确认操作打开时自动获取焦点的按钮。`, + description: storyT('storybook.stories.DeleteConfirmAction.argTypes.autoFocusButton.description'), table: { defaultValue: { summary: 'undefined' } }, }, iconColor: { options: ['info', 'success', 'error', 'warn', 'warning', 'secondary'], - description: `- **EN:** The color of the icon. -- **CN:** 图标的颜色。`, + description: storyT('storybook.stories.DeleteConfirmAction.argTypes.iconColor.description'), table: { defaultValue: { summary: 'undefined' } }, }, titleColor: { options: ['info', 'success', 'error', 'warn', 'warning', 'secondary'], - description: `- **EN:** The color of the title text. -- **CN:** 标题文本的颜色。`, + description: storyT('storybook.stories.DeleteConfirmAction.argTypes.titleColor.description'), table: { defaultValue: { summary: 'undefined' } }, }, contentColor: { options: ['info', 'success', 'error', 'warn', 'warning', 'secondary'], - description: `- **EN:** The color of the content text. -- **CN:** 内容文本的颜色。`, + description: storyT('storybook.stories.DeleteConfirmAction.argTypes.contentColor.description'), table: { defaultValue: { summary: 'undefined' } }, }, }, diff --git a/.storybook/stories/components/EditableText/index.stories.tsx b/.storybook/stories/components/EditableText/index.stories.tsx index 4bdf8da..524752d 100644 --- a/.storybook/stories/components/EditableText/index.stories.tsx +++ b/.storybook/stories/components/EditableText/index.stories.tsx @@ -29,32 +29,27 @@ const meta: Meta<EditableTextProps> = { argTypes: { value: { control: 'text', - description: `- **EN:** Current text value displayed and edited by the component. -- **CN:** 组件当前展示和编辑的文本值。`, + description: storyT('storybook.stories.EditableText.argTypes.value.description'), table: { defaultValue: { summary: '-' } }, }, editable: { control: 'boolean', - description: `- **EN:** Controls whether the edit affordance is available. -- **CN:** 控制是否允许进入编辑状态。`, + description: storyT('storybook.stories.EditableText.argTypes.editable.description'), table: { defaultValue: { summary: 'true' } }, }, editing: { control: 'boolean', - description: `- **EN:** Forces the component into editing mode when controlled externally. -- **CN:** 外部受控地将组件切换到编辑状态。`, + description: storyT('storybook.stories.EditableText.argTypes.editing.description'), table: { defaultValue: { summary: 'false' } }, }, required: { control: 'boolean', - description: `- **EN:** Marks the editor as required when confirming changes. -- **CN:** 在确认修改时将输入视为必填。`, + description: storyT('storybook.stories.EditableText.argTypes.required.description'), table: { defaultValue: { summary: 'false' } }, }, textComp: { control: 'text', - description: `- **EN:** Typography component type used to render the read-only text state. -- **CN:** 只读文本态所使用的排版组件类型。`, + description: storyT('storybook.stories.EditableText.argTypes.textComp.description'), table: { defaultValue: { summary: "'Text'" } }, }, }, @@ -67,8 +62,7 @@ export const Playground: Story = { parameters: { docs: { description: { - story: `- **EN:** Try toggling editable and editing state to inspect the inline editing experience. -- **CN:** 可切换 editable 和 editing 状态,观察行内编辑体验。`, + story: storyT('storybook.stories.EditableText.stories.Playground.description'), }, }, }, diff --git a/.storybook/stories/components/EllipsisTypography/index.stories.tsx b/.storybook/stories/components/EllipsisTypography/index.stories.tsx index 7cefcde..58283df 100644 --- a/.storybook/stories/components/EllipsisTypography/index.stories.tsx +++ b/.storybook/stories/components/EllipsisTypography/index.stories.tsx @@ -38,8 +38,7 @@ export const EllipsisText: EllipsisTextStory = { parameters: { docs: { description: { - story: `- **EN:** Single-line text ellipsis example. -- **CN:** 单行文本省略示例。`, + story: storyT('storybook.stories.EllipsisTypography.stories.EllipsisText.description'), }, }, }, @@ -50,8 +49,7 @@ export const EllipsisText: EllipsisTextStory = { argTypes: { text: { control: 'text', - description: `- **EN:** Text content. If omitted, \`children\` can be used as fallback. -- **CN:** 文本内容;未传时可由 \`children\` 兜底。`, + description: storyT('storybook.stories.EllipsisTypography.argTypes.text.description'), table: { defaultValue: { summary: '-' } }, }, }, @@ -62,8 +60,7 @@ export const EllipsisParagraph: EllipsisParagraphStory = { parameters: { docs: { description: { - story: `- **EN:** Multi-line paragraph ellipsis with tooltip support. -- **CN:** 支持 tooltip 的多行段落省略示例。`, + story: storyT('storybook.stories.EllipsisTypography.stories.EllipsisParagraph.description'), }, }, }, @@ -74,14 +71,14 @@ export const EllipsisParagraph: EllipsisParagraphStory = { argTypes: { text: { control: 'text', - description: `- **EN:** Text content. If omitted, \`children\` can be used as fallback. -- **CN:** 文本内容;未传时可由 \`children\` 兜底。`, + description: storyT('storybook.stories.EllipsisTypography.argTypes.text.description'), table: { defaultValue: { summary: '-' } }, }, ellipsis: { control: 'object', - description: `- **EN:** Ellipsis configuration with optional rows and tooltip settings. -- **CN:** 省略配置,支持行数和 tooltip 设置。`, + description: storyT( + 'storybook.stories.EllipsisTypography.stories.EllipsisParagraph.argTypes.ellipsis.description', + ), table: { defaultValue: { summary: '-' } }, }, }, @@ -92,8 +89,7 @@ export const EllipsisTitle: EllipsisTitleStory = { parameters: { docs: { description: { - story: `- **EN:** Heading ellipsis example with configurable title level. -- **CN:** 支持标题层级配置的标题省略示例。`, + story: storyT('storybook.stories.EllipsisTypography.stories.EllipsisTitle.description'), }, }, }, @@ -106,21 +102,18 @@ export const EllipsisTitle: EllipsisTitleStory = { argTypes: { text: { control: 'text', - description: `- **EN:** Text content. If omitted, \`children\` can be used as fallback. -- **CN:** 文本内容;未传时可由 \`children\` 兜底。`, + description: storyT('storybook.stories.EllipsisTypography.argTypes.text.description'), table: { defaultValue: { summary: '-' } }, }, level: { control: 'select', options: [1, 2, 3, 4, 5], - description: `- **EN:** Title level. - - **CN:** 标题层级。`, + description: storyT('storybook.stories.EllipsisTypography.stories.EllipsisTitle.argTypes.level.description'), table: { defaultValue: { summary: '1' } }, }, ellipsis: { control: 'object', - description: `- **EN:** Ellipsis configuration. Supports automatic tooltip title behavior. - - **CN:** 省略配置,支持自动 tooltip title 行为。`, + description: storyT('storybook.stories.EllipsisTypography.stories.EllipsisTitle.argTypes.ellipsis.description'), table: { defaultValue: { summary: '-' } }, }, }, @@ -131,8 +124,7 @@ export const EllipsisLink: EllipsisLinkStory = { parameters: { docs: { description: { - story: `- **EN:** Link ellipsis example for long URLs or labels. -- **CN:** 适用于长链接文本或长链接标签的省略示例。`, + story: storyT('storybook.stories.EllipsisTypography.stories.EllipsisLink.description'), }, }, }, @@ -144,20 +136,17 @@ export const EllipsisLink: EllipsisLinkStory = { argTypes: { text: { control: 'text', - description: `- **EN:** Text content. If omitted, \`children\` can be used as fallback. -- **CN:** 文本内容;未传时可由 \`children\` 兜底。`, + description: storyT('storybook.stories.EllipsisTypography.argTypes.text.description'), table: { defaultValue: { summary: '-' } }, }, ellipsis: { control: 'object', - description: `- **EN:** Ellipsis configuration. Set \`true\` for automatic ellipsis and tooltip behavior. -- **CN:** 省略配置。设为 \`true\` 可启用自动省略与 tooltip 行为。`, + description: storyT('storybook.stories.EllipsisTypography.stories.EllipsisLink.argTypes.ellipsis.description'), table: { defaultValue: { summary: '-' } }, }, href: { control: 'text', - description: `- **EN:** Link target URL. -- **CN:** 链接跳转地址。`, + description: storyT('storybook.stories.EllipsisTypography.stories.EllipsisLink.argTypes.href.description'), table: { defaultValue: { summary: '-' } }, }, }, diff --git a/.storybook/stories/components/Iconfont/index.stories.tsx b/.storybook/stories/components/Iconfont/index.stories.tsx index 179bafc..d6ac8bb 100644 --- a/.storybook/stories/components/Iconfont/index.stories.tsx +++ b/.storybook/stories/components/Iconfont/index.stories.tsx @@ -51,42 +51,35 @@ const meta: Meta<IconfontStoryArgs> = { argTypes: { scriptUrl: { control: 'text', - description: `- **EN:** URL of the iconfont script. -- **CN:** iconfont 脚本的 URL。`, + description: storyT('storybook.stories.Iconfont.argTypes.scriptUrl.description'), }, type: { control: 'select', options: [...ICON_TYPES], - description: `- **EN:** Icon name provided by the loaded iconfont script. -- **CN:** 已加载的 iconfont 脚本提供的图标名称。`, + description: storyT('storybook.stories.Iconfont.argTypes.type.description'), }, size: { control: { type: 'number', min: 12, max: 64, step: 4 }, - description: `- **EN:** Icon size in pixels. -- **CN:** 图标尺寸(像素)。`, + description: storyT('storybook.stories.Iconfont.argTypes.size.description'), table: { defaultValue: { summary: '32' } }, }, color: { control: 'color', - description: `- **EN:** Icon color. -- **CN:** 图标颜色。`, + description: storyT('storybook.stories.Iconfont.argTypes.color.description'), }, spin: { control: 'boolean', - description: `- **EN:** Whether the icon spins continuously. -- **CN:** 图标是否持续旋转。`, + description: storyT('storybook.stories.Iconfont.argTypes.spin.description'), table: { defaultValue: { summary: 'false' } }, }, rotate: { control: { type: 'number', min: 0, max: 360, step: 15 }, - description: `- **EN:** Fixed clockwise rotation angle. -- **CN:** 图标顺时针旋转的固定角度。`, + description: storyT('storybook.stories.Iconfont.argTypes.rotate.description'), table: { defaultValue: { summary: '0' } }, }, iconPrefix: { control: 'text', - description: `- **EN:** Demo-only prefix. The factory prepends it to \`type\` unless the type already starts with it. -- **CN:** 示例专用前缀。工厂会将其拼接到 \`type\` 前,除非 \`type\` 已包含该前缀。`, + description: storyT('storybook.stories.Iconfont.argTypes.iconPrefix.description'), table: { defaultValue: { summary: "''" } }, }, }, @@ -99,8 +92,7 @@ export const Playground: Story = { parameters: { docs: { description: { - story: `- **EN:** Pick an icon and tune size, color, spin, rotation, or the demo prefix. The resolved \`type\` is shown below the icon. -- **CN:** 选择图标并调整尺寸、颜色、旋转、spin 或示例前缀。图标下方会展示最终解析出的 \`type\`。`, + story: storyT('storybook.stories.Iconfont.stories.Playground.description'), }, }, }, @@ -137,8 +129,7 @@ export const IconGallery: Story = { parameters: { docs: { description: { - story: `- **EN:** All icons shipped by the official demo iconfont script. -- **CN:** 官方示例 iconfont 脚本内置的全部图标。`, + story: storyT('storybook.stories.Iconfont.stories.IconGallery.description'), }, }, }, diff --git a/.storybook/stories/components/ModalAction/index.stories.tsx b/.storybook/stories/components/ModalAction/index.stories.tsx index 8e3b4de..5e5c58b 100644 --- a/.storybook/stories/components/ModalAction/index.stories.tsx +++ b/.storybook/stories/components/ModalAction/index.stories.tsx @@ -88,8 +88,7 @@ const meta: Meta<ModalActionStoryArgs> = { triggerType: { control: 'radio', options: ['Button', 'Switch', 'Link'], - description: `- **EN:** Demo-only option to switch trigger component type. -- **CN:** 示例专用:切换触发器组件类型。`, + description: storyT('storybook.stories.ModalAction.argTypes.triggerType.description'), table: { defaultValue: { summary: '"Button"' } }, }, }, @@ -102,8 +101,7 @@ export const Playground: Story = { parameters: { docs: { description: { - story: `- **EN:** Demonstrates create and edit flows backed by the same form component and different trigger variants. -- **CN:** 演示同一个表单组件在创建、编辑流程以及不同触发器形态下的复用方式。`, + story: storyT('storybook.stories.ModalAction.stories.Playground.description'), }, }, }, diff --git a/.storybook/stories/components/PulseAnimation/index.stories.tsx b/.storybook/stories/components/PulseAnimation/index.stories.tsx index bf498b3..5ea6e43 100644 --- a/.storybook/stories/components/PulseAnimation/index.stories.tsx +++ b/.storybook/stories/components/PulseAnimation/index.stories.tsx @@ -26,42 +26,35 @@ const meta: Meta<typeof PulseAnimation> = { argTypes: { bars: { control: 'number', - description: `- **EN:** Number of animated bars. -- **CN:** 动画柱条数量。`, + description: storyT('storybook.stories.PulseAnimation.argTypes.bars.description'), table: { defaultValue: { summary: '8' } }, }, barGap: { control: 'number', - description: `- **EN:** Horizontal gap between bars. -- **CN:** 柱条之间的水平间距。`, + description: storyT('storybook.stories.PulseAnimation.argTypes.barGap.description'), table: { defaultValue: { summary: '4' } }, }, duration: { control: 'number', - description: `- **EN:** Animation duration in seconds. -- **CN:** 动画总时长,单位为秒。`, + description: storyT('storybook.stories.PulseAnimation.argTypes.duration.description'), table: { defaultValue: { summary: '1.6' } }, }, delayRate: { control: 'number', - description: `- **EN:** Delay offset rate used to stagger each bar animation. -- **CN:** 每根柱条的错峰延迟系数。`, + description: storyT('storybook.stories.PulseAnimation.argTypes.delayRate.description'), table: { defaultValue: { summary: '0.09' } }, }, barColor: { control: 'color', - description: `- **EN:** Base color of the animation bars. -- **CN:** 动画柱条的基础颜色。`, + description: storyT('storybook.stories.PulseAnimation.argTypes.barColor.description'), }, barStyle: { control: 'object', - description: `- **EN:** Extra styles applied to each bar. -- **CN:** 应用于每根柱条的额外样式。`, + description: storyT('storybook.stories.PulseAnimation.argTypes.barStyle.description'), }, prefixCls: { control: 'text', - description: `- **EN:** Custom CSS class prefix for the component. -- **CN:** 组件的自定义 CSS 类前缀。`, + description: storyT('storybook.stories.PulseAnimation.argTypes.prefixCls.description'), }, }, }; @@ -73,8 +66,7 @@ export const Playground: Story = { parameters: { docs: { description: { - story: `- **EN:** Adjust bar count, gap, and timing controls to explore different motion rhythms. -- **CN:** 可调整柱子数量、间距和节奏参数,观察不同的动画律动效果。`, + story: storyT('storybook.stories.PulseAnimation.stories.Playground.description'), }, }, }, @@ -84,8 +76,7 @@ export const AudioActivity: Story = { parameters: { docs: { description: { - story: `- **EN:** A typical audio-activity indicator: many thin bars with a tight gap and a short stagger delay, rendered inside a player card. -- **CN:** 典型的音频活动指示器:细密柱条 + 小间距 + 短错峰延迟,放在播放器卡片中演示。`, + story: storyT('storybook.stories.PulseAnimation.stories.AudioActivity.description'), }, }, }, @@ -109,8 +100,7 @@ export const LightLoading: Story = { parameters: { docs: { description: { - story: `- **EN:** Use pulse bars as a lightweight loading indicator next to text content. -- **CN:** 将脉冲柱条作为轻量加载指示器,与文本内容并排展示。`, + story: storyT('storybook.stories.PulseAnimation.stories.LightLoading.description'), }, }, }, From 2314aaf92a30e5a2268f4db9da2492e0b9cb3407 Mon Sep 17 00:00:00 2001 From: Fengbao Li <shijistar@gmail.com> Date: Tue, 4 Aug 2026 22:18:03 +0800 Subject: [PATCH 07/39] refactor(storybook): update component descriptions and improve localization support --- .storybook/locales/langs/en-US.ts | 36 ++++++++++++++++--- .storybook/locales/langs/zh-CN.ts | 29 ++++++++++++--- .storybook/preview-head.html | 6 ++++ .../FormItemControl/index.stories.tsx | 5 +-- .../components/Loading/index.stories.tsx | 5 +-- .../components/OverflowTags/index.stories.tsx | 3 +- .../stories/hooks/useAudioPlayer.stories.tsx | 21 ++++------- .../stories/hooks/useDebounce.stories.tsx | 21 +++++------ .../hooks/useStreamDownloader.stories.tsx | 15 ++++---- .../utils/StreamDownloader/index.stories.tsx | 14 +++----- 10 files changed, 90 insertions(+), 65 deletions(-) create mode 100644 .storybook/preview-head.html diff --git a/.storybook/locales/langs/en-US.ts b/.storybook/locales/langs/en-US.ts index 0de02bb..f492f67 100644 --- a/.storybook/locales/langs/en-US.ts +++ b/.storybook/locales/langs/en-US.ts @@ -211,7 +211,7 @@ const enUS = { 'storybook.stories.ConfirmAction.argTypes.triggerProps.description': 'Props of the trigger component, will change according to the trigger type.', 'storybook.stories.ConfirmAction.argTypes.triggerType.description': - 'Demo-only option to switch trigger component type, \`triggerProps\` will change according to the type', + 'Demo-only option to switch trigger component type, `triggerProps` will change according to the type', 'storybook.stories.ConfirmAction.argTypes.type.description': 'Type of the confirm action.', 'storybook.stories.ConfirmAction.stories.Playground.description': 'Switch between Button, Switch, and Link triggers to compare how the same confirm flow is exposed.', @@ -234,7 +234,7 @@ const enUS = { 'storybook.stories.EditableText.stories.Playground.description': 'Try toggling editable and editing state to inspect the inline editing experience.', 'storybook.stories.EllipsisTypography.stories.EllipsisLink.argTypes.ellipsis.description': - 'Ellipsis configuration. Set \`true\` for automatic ellipsis and tooltip behavior.', + 'Ellipsis configuration. Set `true` for automatic ellipsis and tooltip behavior.', 'storybook.stories.EllipsisTypography.stories.EllipsisLink.argTypes.href.description': 'Link target URL.', 'storybook.stories.EllipsisTypography.stories.EllipsisLink.description': 'Link ellipsis example for long URLs or labels.', @@ -250,7 +250,7 @@ const enUS = { 'Heading ellipsis example with configurable title level.', 'storybook.stories.Iconfont.argTypes.color.description': 'Icon color.', 'storybook.stories.Iconfont.argTypes.iconPrefix.description': - 'Demo-only prefix. The factory prepends it to \`type\` unless the type already starts with it.', + 'Demo-only prefix. The factory prepends it to `type` unless the type already starts with it.', 'storybook.stories.Iconfont.argTypes.rotate.description': 'Fixed clockwise rotation angle.', 'storybook.stories.Iconfont.argTypes.scriptUrl.description': 'URL of the iconfont script.', 'storybook.stories.Iconfont.argTypes.size.description': 'Icon size in pixels.', @@ -259,7 +259,7 @@ const enUS = { 'storybook.stories.Iconfont.stories.IconGallery.description': 'All icons shipped by the official demo iconfont script.', 'storybook.stories.Iconfont.stories.Playground.description': - 'Pick an icon and tune size, color, spin, rotation, or the demo prefix. The resolved \`type\` is shown below the icon.', + 'Pick an icon and tune size, color, spin, rotation, or the demo prefix. The resolved `type` is shown below the icon.', 'storybook.stories.ModalAction.argTypes.triggerType.description': 'Demo-only option to switch trigger component type.', 'storybook.stories.ModalAction.stories.Playground.description': @@ -279,6 +279,32 @@ const enUS = { 'storybook.stories.PulseAnimation.stories.Playground.description': 'Adjust bar count, gap, and timing controls to explore different motion rhythms.', 'storybook.stories.EllipsisTypography.argTypes.text.description': - 'Text content. If omitted, \`children\` can be used as fallback.', + 'Text content. If omitted, `children` can be used as fallback.', + 'storybook.stories.OverflowTags.argTypes.maxCount.description': 'Maximum number of tags to display before overflow.', + 'storybook.stories.useAudioPlayer.argTypes.source.description': + 'Initial audio source URL used by the demo. Prefer local static assets served by Storybook for reliable playback.', + 'storybook.stories.useAudioPlayer.argTypes.initialVolume.description': + 'Initial player volume used when the hook instance is created.', + 'storybook.stories.useAudioPlayer.argTypes.seekStep.description': + 'Demo-only option that controls how many seconds the forward/backward buttons seek.', + 'storybook.stories.useDebounce.argTypes.wait.description': 'The debounce delay in milliseconds.', + 'storybook.stories.useDebounce.argTypes.leading.description': 'Whether the first call executes immediately.', + 'storybook.stories.useDebounce.argTypes.maxWait.description': + 'Maximum time to wait before forcing execution. Use `0` to disable.', + 'storybook.stories.useStreamDownloader.argTypes.url.description': 'Real public file URL used by the hook demo.', + 'storybook.stories.useStreamDownloader.argTypes.fileName.description': 'Optional explicit file name override.', + 'storybook.stories.useStreamDownloader.argTypes.progressThrottleMs.description': + 'Forwarded into the underlying class constructor.', + 'storybook.stories.useStreamDownloader.argTypes.autoDispose.description': + 'Whether the hook disposes the downloader on unmount.', + 'storybook.stories.useStreamDownloader.argTypes.saveStrategy.description': + 'Save strategy passed into `start(request)`.', + 'storybook.stories.StreamDownloader.argTypes.url.description': + 'Real public file URL used by both fetch and axios demos.', + 'storybook.stories.StreamDownloader.argTypes.fileName.description': + 'Optional explicit file name override. Leave empty to derive it from response headers / URL.', + 'storybook.stories.StreamDownloader.argTypes.progressThrottleMs.description': + 'Constructor-level progress throttling window in milliseconds.', + 'storybook.stories.StreamDownloader.argTypes.saveStrategy.description': 'Save strategy passed into `start(request)`.', } as const; export default enUS; diff --git a/.storybook/locales/langs/zh-CN.ts b/.storybook/locales/langs/zh-CN.ts index 8265aa9..b5c68ee 100644 --- a/.storybook/locales/langs/zh-CN.ts +++ b/.storybook/locales/langs/zh-CN.ts @@ -207,7 +207,7 @@ const zhCN = { 'storybook.stories.ConfirmAction.argTypes.triggerProps.description': '触发器组件的Props属性,随触发器类型变化进行调整类型定义。', 'storybook.stories.ConfirmAction.argTypes.triggerType.description': - '示例专用:切换触发器组件类型,\`triggerProps\`会根据类型而变化类型定义。', + '示例专用:切换触发器组件类型,`triggerProps`会根据类型而变化类型定义。', 'storybook.stories.ConfirmAction.argTypes.type.description': '确认操作的类型。', 'storybook.stories.ConfirmAction.stories.Playground.description': '可切换 Button、Switch、Link 三种触发方式,对比相同确认流程的接入形式。', @@ -224,7 +224,7 @@ const zhCN = { 'storybook.stories.EditableText.stories.Playground.description': '可切换 editable 和 editing 状态,观察行内编辑体验。', 'storybook.stories.EllipsisTypography.stories.EllipsisLink.argTypes.ellipsis.description': - '省略配置。设为 \`true\` 可启用自动省略与 tooltip 行为。', + '省略配置。设为 `true` 可启用自动省略与 tooltip 行为。', 'storybook.stories.EllipsisTypography.stories.EllipsisLink.argTypes.href.description': '链接跳转地址。', 'storybook.stories.EllipsisTypography.stories.EllipsisLink.description': '适用于长链接文本或长链接标签的省略示例。', 'storybook.stories.EllipsisTypography.stories.EllipsisParagraph.argTypes.ellipsis.description': @@ -237,7 +237,7 @@ const zhCN = { 'storybook.stories.EllipsisTypography.stories.EllipsisTitle.description': '支持标题层级配置的标题省略示例。', 'storybook.stories.Iconfont.argTypes.color.description': '图标颜色。', 'storybook.stories.Iconfont.argTypes.iconPrefix.description': - '示例专用前缀。工厂会将其拼接到 \`type\` 前,除非 \`type\` 已包含该前缀。', + '示例专用前缀。工厂会将其拼接到 `type` 前,除非 `type` 已包含该前缀。', 'storybook.stories.Iconfont.argTypes.rotate.description': '图标顺时针旋转的固定角度。', 'storybook.stories.Iconfont.argTypes.scriptUrl.description': 'iconfont 脚本的 URL。', 'storybook.stories.Iconfont.argTypes.size.description': '图标尺寸(像素)。', @@ -245,7 +245,7 @@ const zhCN = { 'storybook.stories.Iconfont.argTypes.type.description': '已加载的 iconfont 脚本提供的图标名称。', 'storybook.stories.Iconfont.stories.IconGallery.description': '官方示例 iconfont 脚本内置的全部图标。', 'storybook.stories.Iconfont.stories.Playground.description': - '选择图标并调整尺寸、颜色、旋转、spin 或示例前缀。图标下方会展示最终解析出的 \`type\`。', + '选择图标并调整尺寸、颜色、旋转、spin 或示例前缀。图标下方会展示最终解析出的 `type`。', 'storybook.stories.ModalAction.argTypes.triggerType.description': '示例专用:切换触发器组件类型。', 'storybook.stories.ModalAction.stories.Playground.description': '演示同一个表单组件在创建、编辑流程以及不同触发器形态下的复用方式。', @@ -262,6 +262,25 @@ const zhCN = { '将脉冲柱条作为轻量加载指示器,与文本内容并排展示。', 'storybook.stories.PulseAnimation.stories.Playground.description': '可调整柱子数量、间距和节奏参数,观察不同的动画律动效果。', - 'storybook.stories.EllipsisTypography.argTypes.text.description': '文本内容;未传时可由 \`children\` 兜底。', + 'storybook.stories.EllipsisTypography.argTypes.text.description': '文本内容;未传时可由 `children` 兜底。', + 'storybook.stories.OverflowTags.argTypes.maxCount.description': '超出前最多显示的标签数量。', + 'storybook.stories.useAudioPlayer.argTypes.source.description': + '示例初始使用的音频地址。为保证播放更稳定,建议优先使用由 Storybook 提供的本地静态资源。', + 'storybook.stories.useAudioPlayer.argTypes.initialVolume.description': 'Hook 实例创建时使用的初始音量。', + 'storybook.stories.useAudioPlayer.argTypes.seekStep.description': + '示例专用参数,用于控制前进/后退按钮每次跳转的秒数。', + 'storybook.stories.useDebounce.argTypes.wait.description': '防抖延迟(毫秒)。', + 'storybook.stories.useDebounce.argTypes.leading.description': '是否在首次调用时立即执行。', + 'storybook.stories.useDebounce.argTypes.maxWait.description': '强制执行的最大等待时间,设为 `0` 表示关闭。', + 'storybook.stories.useStreamDownloader.argTypes.url.description': 'hook demo 使用的真实公开文件 URL。', + 'storybook.stories.useStreamDownloader.argTypes.fileName.description': '可选的显式文件名覆盖。', + 'storybook.stories.useStreamDownloader.argTypes.progressThrottleMs.description': '透传给底层 class 构造参数。', + 'storybook.stories.useStreamDownloader.argTypes.autoDispose.description': 'hook 卸载时是否自动释放 downloader。', + 'storybook.stories.useStreamDownloader.argTypes.saveStrategy.description': '传给 `start(request)` 的保存策略。', + 'storybook.stories.StreamDownloader.argTypes.url.description': 'fetch 与 axios demo 共用的真实公开文件 URL。', + 'storybook.stories.StreamDownloader.argTypes.fileName.description': '可选显式文件名覆盖;留空时从响应头 / URL 推导。', + 'storybook.stories.StreamDownloader.argTypes.progressThrottleMs.description': + '构造参数级别的进度节流窗口,单位毫秒。', + 'storybook.stories.StreamDownloader.argTypes.saveStrategy.description': '传给 `start(request)` 的保存策略。', } as const; export default zhCN; diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html new file mode 100644 index 0000000..3eabab6 --- /dev/null +++ b/.storybook/preview-head.html @@ -0,0 +1,6 @@ +<style> + #storybook-docs > .sbdocs-wrapper { + padding-top: 3rem; + padding-bottom: 3rem; + } +</style> diff --git a/.storybook/stories/components/FormItemControl/index.stories.tsx b/.storybook/stories/components/FormItemControl/index.stories.tsx index 54ea70d..f5e09e9 100644 --- a/.storybook/stories/components/FormItemControl/index.stories.tsx +++ b/.storybook/stories/components/FormItemControl/index.stories.tsx @@ -26,10 +26,7 @@ type Story = StoryObj<typeof FormItemControl>; export const Playground: Story = { parameters: { docs: { - description: { - story: `- **EN:** Uses a Slider as a controlled field and mirrors the current form value below it. -- **CN:** 使用 Slider 作为受控字段,并在下方同步展示当前表单值。`, - }, + description: {}, }, }, render: function Render() { diff --git a/.storybook/stories/components/Loading/index.stories.tsx b/.storybook/stories/components/Loading/index.stories.tsx index a7c8984..186e182 100644 --- a/.storybook/stories/components/Loading/index.stories.tsx +++ b/.storybook/stories/components/Loading/index.stories.tsx @@ -32,10 +32,7 @@ type Story = StoryObj<typeof Loading>; export const Standalone: Story = { parameters: { docs: { - description: { - story: `- **EN:** Shows the loading overlay on top of a simple content container. -- **CN:** 演示加载态叠加在普通内容容器之上的效果。`, - }, + description: {}, }, }, args: { diff --git a/.storybook/stories/components/OverflowTags/index.stories.tsx b/.storybook/stories/components/OverflowTags/index.stories.tsx index a07579f..e8a8992 100644 --- a/.storybook/stories/components/OverflowTags/index.stories.tsx +++ b/.storybook/stories/components/OverflowTags/index.stories.tsx @@ -48,8 +48,7 @@ const meta: Meta<typeof OverflowTags<TagItem>> = { maxCount: { control: 'select', options: [2, 3, 4, 5, 'responsive', 'invalidate'], - description: `- **EN:** Maximum number of tags to display before overflow. -- **CN:** 超出前最多显示的标签数量。`, + description: storyT('storybook.stories.OverflowTags.argTypes.maxCount.description'), table: { defaultValue: { summary: storyT('storybook.stories.OverflowTags.maxCountSummary') } }, }, }, diff --git a/.storybook/stories/hooks/useAudioPlayer.stories.tsx b/.storybook/stories/hooks/useAudioPlayer.stories.tsx index d0bcf5c..2869fc8 100644 --- a/.storybook/stories/hooks/useAudioPlayer.stories.tsx +++ b/.storybook/stories/hooks/useAudioPlayer.stories.tsx @@ -5,7 +5,7 @@ import { useRefFunction } from '../../../src/hooks'; import useAudioPlayer from '../../../src/hooks/useAudioPlayer'; // @ts-expect-error: because Vite handles mp3 imports natively import musicUrl from '../../assets/sample.mp3'; -import { useStoryT } from '../../locales'; +import { storyT, useStoryT } from '../../locales'; interface UseAudioPlayerStoryArgs { source: string; @@ -23,10 +23,7 @@ const meta: Meta<UseAudioPlayerStoryArgs> = { title: 'Hooks/useAudioPlayer', parameters: { docs: { - description: { - component: `- **EN:** Demonstrates how to use the \`useAudioPlayer\` hook to create a stable \`AudioPlayer\` instance, control playback, switch sources, and observe player state in a React view. \n\n Note that AudioPlayer is an audio playback API object and does not provide a user interface, only playback control and state management functionality. -- **CN:** 演示如何使用 \`useAudioPlayer\` 钩子创建稳定的 \`AudioPlayer\` 实例,完成播放控制、音源切换,并在 React 视图中观察播放器状态。\n\n 注意 AudioPlayer 是一个音频播放API对象,不提供用户界面,仅提供播放控制和状态管理功能。`, - }, + description: {}, }, }, args: { @@ -37,18 +34,15 @@ const meta: Meta<UseAudioPlayerStoryArgs> = { argTypes: { source: { control: 'text', - description: `- **EN:** Initial audio source URL used by the demo. Prefer local static assets served by Storybook for reliable playback. -- **CN:** 示例初始使用的音频地址。为保证播放更稳定,建议优先使用由 Storybook 提供的本地静态资源。`, + description: storyT('storybook.stories.useAudioPlayer.argTypes.source.description'), }, initialVolume: { control: { type: 'range', min: 0, max: 1, step: 0.1 }, - description: `- **EN:** Initial player volume used when the hook instance is created. -- **CN:** Hook 实例创建时使用的初始音量。`, + description: storyT('storybook.stories.useAudioPlayer.argTypes.initialVolume.description'), }, seekStep: { control: { type: 'number', min: 1, max: 60, step: 1 }, - description: `- **EN:** Demo-only option that controls how many seconds the forward/backward buttons seek. -- **CN:** 示例专用参数,用于控制前进/后退按钮每次跳转的秒数。`, + description: storyT('storybook.stories.useAudioPlayer.argTypes.seekStep.description'), }, }, }; @@ -59,10 +53,7 @@ type Story = StoryObj<UseAudioPlayerStoryArgs>; export const Playground: Story = { parameters: { docs: { - description: { - story: `- **EN:** Includes source switching, transport controls, progress/volume synchronization, and an event log so you can inspect how the player behaves over time. -- **CN:** 提供音源切换、播放控制、进度与音量同步,以及事件日志,便于观察播放器在不同交互下的行为。`, - }, + description: {}, }, }, render: function Render(args: UseAudioPlayerStoryArgs) { diff --git a/.storybook/stories/hooks/useDebounce.stories.tsx b/.storybook/stories/hooks/useDebounce.stories.tsx index 60acaa0..22c6b70 100644 --- a/.storybook/stories/hooks/useDebounce.stories.tsx +++ b/.storybook/stories/hooks/useDebounce.stories.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; import { Alert, Button, Card, Divider, Input, List, Space, Typography } from 'antd'; import useDebounce from '../../../src/hooks/useDebounce'; -import { useStoryT } from '../../locales'; +import { storyT, useStoryT } from '../../locales'; interface UseDebounceStoryArgs { wait: number; @@ -35,18 +35,15 @@ const meta: Meta<UseDebounceStoryArgs> = { argTypes: { wait: { control: { type: 'range', min: 0, max: 3000, step: 100 }, - description: `- **EN:** The debounce delay in milliseconds. -- **CN:** 防抖延迟(毫秒)。`, + description: storyT('storybook.stories.useDebounce.argTypes.wait.description'), }, leading: { control: 'boolean', - description: `- **EN:** Whether the first call executes immediately. -- **CN:** 是否在首次调用时立即执行。`, + description: storyT('storybook.stories.useDebounce.argTypes.leading.description'), }, maxWait: { control: { type: 'range', min: 0, max: 5000, step: 100 }, - description: `- **EN:** Maximum time to wait before forcing execution. Use \`0\` to disable. -- **CN:** 强制执行的最大等待时间,设为 \`0\` 表示关闭。`, + description: storyT('storybook.stories.useDebounce.argTypes.maxWait.description'), }, }, }; @@ -87,7 +84,7 @@ function UseDebounceStoryDemo({ wait, leading, maxWait }: UseDebounceStoryArgs) wait, leading, maxWait, - } + }, ); function appendEventLog(type: string, value: string) { @@ -140,14 +137,14 @@ function UseDebounceStoryDemo({ wait, leading, maxWait }: UseDebounceStoryArgs) : t('storybook.stories.useDebounce.enabledStatus'); return ( - <Card bordered style={{ maxWidth: 920 }} title={t('storybook.stories.useDebounce.cardTitle')}> - <Space direction="vertical" size="large" style={{ width: '100%' }}> + <Card variant="outlined" style={{ maxWidth: 920 }} title={t('storybook.stories.useDebounce.cardTitle')}> + <Space orientation="vertical" size="large" style={{ width: '100%' }}> <Typography.Paragraph style={{ marginBottom: 0 }}> {t('storybook.stories.useDebounce.description')} </Typography.Paragraph> <Space align="start" wrap style={{ width: '100%', justifyContent: 'space-between' }}> - <Space direction="vertical" size="small" style={{ minWidth: 280, flex: 1 }}> + <Space orientation="vertical" size="small" style={{ minWidth: 280, flex: 1 }}> <Typography.Text strong>{t('storybook.stories.useDebounce.currentValue')}</Typography.Text> <Input value={query} @@ -172,7 +169,7 @@ function UseDebounceStoryDemo({ wait, leading, maxWait }: UseDebounceStoryArgs) </Space> </Space> - <Space direction="vertical" size="small" style={{ minWidth: 260 }}> + <Space orientation="vertical" size="small" style={{ minWidth: 260 }}> <Typography.Text strong>{t('storybook.stories.useDebounce.waitLabel')}</Typography.Text> <Typography.Text>{`${wait} ms`}</Typography.Text> <Typography.Text strong>{t('storybook.stories.useDebounce.leadingLabel')}</Typography.Text> diff --git a/.storybook/stories/hooks/useStreamDownloader.stories.tsx b/.storybook/stories/hooks/useStreamDownloader.stories.tsx index a52c5b5..261a22f 100644 --- a/.storybook/stories/hooks/useStreamDownloader.stories.tsx +++ b/.storybook/stories/hooks/useStreamDownloader.stories.tsx @@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { create as createAxios } from 'axios'; import { List, Space, Tag, Typography } from 'antd'; import { type StreamDownloadSaveStrategy, useStreamDownloader } from '../../../src'; -import { useStoryT } from '../../locales'; +import { storyT, useStoryT } from '../../locales'; import StreamDownloaderDemoCard, { formatErrorLog, useStreamDownloaderDemoLogs } from '../shared/streamDownloaderDemo'; const REAL_DOWNLOAD_URL = 'https://huggingface.co/gpt2/resolve/main/pytorch_model.bin'; @@ -97,27 +97,24 @@ const meta: Meta<UseStreamDownloaderStoryArgs> = { argTypes: { url: { control: 'text', - description: - '- **EN:** Real public file URL used by the hook demo.\n- **CN:** hook demo 使用的真实公开文件 URL。', + description: storyT('storybook.stories.useStreamDownloader.argTypes.url.description'), }, fileName: { control: 'text', - description: '- **EN:** Optional explicit file name override.\n- **CN:** 可选的显式文件名覆盖。', + description: storyT('storybook.stories.useStreamDownloader.argTypes.fileName.description'), }, progressThrottleMs: { control: { type: 'number', min: 0, max: 2000, step: 20 }, - description: '- **EN:** Forwarded into the underlying class constructor.\n- **CN:** 透传给底层 class 构造参数。', + description: storyT('storybook.stories.useStreamDownloader.argTypes.progressThrottleMs.description'), }, autoDispose: { control: 'boolean', - description: - '- **EN:** Whether the hook disposes the downloader on unmount.\n- **CN:** hook 卸载时是否自动释放 downloader。', + description: storyT('storybook.stories.useStreamDownloader.argTypes.autoDispose.description'), }, saveStrategy: { control: 'radio', options: ['auto', 'file-system-access', 'stream-saver'], - description: - '- **EN:** Save strategy passed into `start(request)`.\n- **CN:** 传给 `start(request)` 的保存策略。', + description: storyT('storybook.stories.useStreamDownloader.argTypes.saveStrategy.description'), }, }, }; diff --git a/.storybook/stories/utils/StreamDownloader/index.stories.tsx b/.storybook/stories/utils/StreamDownloader/index.stories.tsx index 18c2a99..0f0300c 100644 --- a/.storybook/stories/utils/StreamDownloader/index.stories.tsx +++ b/.storybook/stories/utils/StreamDownloader/index.stories.tsx @@ -6,7 +6,7 @@ import StreamDownloader, { type StreamDownloadRequest, type StreamDownloadSaveStrategy, } from '../../../../src/utils/StreamDownloader'; -import { useStoryT } from '../../../locales'; +import { storyT, useStoryT } from '../../../locales'; import StreamDownloaderDemoCard, { formatErrorLog, getCodeBlockStyle, @@ -117,24 +117,20 @@ const meta: Meta<StreamDownloaderStoryArgs> = { argTypes: { url: { control: 'text', - description: - '- **EN:** Real public file URL used by both fetch and axios demos.\n- **CN:** fetch 与 axios demo 共用的真实公开文件 URL。', + description: storyT('storybook.stories.StreamDownloader.argTypes.url.description'), }, fileName: { control: 'text', - description: - '- **EN:** Optional explicit file name override. Leave empty to derive it from response headers / URL.\n- **CN:** 可选显式文件名覆盖;留空时从响应头 / URL 推导。', + description: storyT('storybook.stories.StreamDownloader.argTypes.fileName.description'), }, progressThrottleMs: { control: { type: 'number', min: 0, max: 2000, step: 20 }, - description: - '- **EN:** Constructor-level progress throttling window in milliseconds.\n- **CN:** 构造参数级别的进度节流窗口,单位毫秒。', + description: storyT('storybook.stories.StreamDownloader.argTypes.progressThrottleMs.description'), }, saveStrategy: { control: 'radio', options: ['auto', 'file-system-access', 'stream-saver'], - description: - '- **EN:** Save strategy passed into `start(request)`.\n- **CN:** 传给 `start(request)` 的保存策略。', + description: storyT('storybook.stories.StreamDownloader.argTypes.saveStrategy.description'), }, }, }; From bdb23a13358057b8fc518153e24b473214f20ef6 Mon Sep 17 00:00:00 2001 From: Fengbao Li <shijistar@gmail.com> Date: Tue, 4 Aug 2026 22:25:15 +0800 Subject: [PATCH 08/39] refactor(storybook): update imports to include storyT for localization support --- .storybook/stories/components/Iconfont/index.stories.tsx | 2 +- .storybook/stories/components/PulseAnimation/index.stories.tsx | 2 +- .../stories/components/VirtualTextViewer/index.stories.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.storybook/stories/components/Iconfont/index.stories.tsx b/.storybook/stories/components/Iconfont/index.stories.tsx index d6ac8bb..e5a037e 100644 --- a/.storybook/stories/components/Iconfont/index.stories.tsx +++ b/.storybook/stories/components/Iconfont/index.stories.tsx @@ -2,7 +2,7 @@ import { useMemo } from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; import { Space, Typography } from 'antd'; import { createIconfont } from '../../../../src/components/Iconfont'; -import storyI18n, { useStoryT } from '../../../locales'; +import storyI18n, { storyT, useStoryT } from '../../../locales'; import apiDocEN from './api-doc.en-US.md?raw'; import apiDocCN from './api-doc.zh-CN.md?raw'; import introduceEN from './introduce.en-US.md?raw'; diff --git a/.storybook/stories/components/PulseAnimation/index.stories.tsx b/.storybook/stories/components/PulseAnimation/index.stories.tsx index 5ea6e43..7983121 100644 --- a/.storybook/stories/components/PulseAnimation/index.stories.tsx +++ b/.storybook/stories/components/PulseAnimation/index.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { Card, Space, Typography } from 'antd'; import PulseAnimation from '../../../../src/components/Animation/Pulse'; -import storyI18n, { useStoryT } from '../../../locales'; +import storyI18n, { storyT, useStoryT } from '../../../locales'; import introduceEN from './introduce.en-US.md?raw'; import introduceCN from './introduce.zh-CN.md?raw'; diff --git a/.storybook/stories/components/VirtualTextViewer/index.stories.tsx b/.storybook/stories/components/VirtualTextViewer/index.stories.tsx index 2fb0dbc..26f54d4 100644 --- a/.storybook/stories/components/VirtualTextViewer/index.stories.tsx +++ b/.storybook/stories/components/VirtualTextViewer/index.stories.tsx @@ -3,9 +3,9 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import type { ThemeVars } from 'storybook/theming'; import VirtualTextViewer from '../../../../src/components/VirtualTextViewer'; import storyI18n, { storyT, useStoryT } from '../../../locales'; +import { getGlobalValueFromUrl } from '../../../utils/global'; import introduceEN from './introduce.en-US.md?raw'; import introduceCN from './introduce.zh-CN.md?raw'; -import { getGlobalValueFromUrl } from '../../../utils/global'; const demoText = buildLargeText(120, 60); const background = getGlobalValueFromUrl('backgrounds.value'); From b82b5e76d45c3ed857c31153ba06b4382fa0011a Mon Sep 17 00:00:00 2001 From: Fengbao Li <shijistar@gmail.com> Date: Tue, 4 Aug 2026 22:47:33 +0800 Subject: [PATCH 09/39] refactor(storybook): enhance description localization in jsdocArgTypesEnhancer --- .storybook/preview.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index d2aac89..45fc799 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -176,7 +176,7 @@ function jsdocArgTypesEnhancer(context: StoryContextForEnhancers) { control: { type: argType?.control ?? inferred.control } as Control, options: argType?.options ?? inferred.options, // The handwritten description will not be overwritten. - description: argType?.description ?? docProp?.description ?? '', + description: pickLangDoc(argType?.description ?? docProp?.description ?? ''), table: { ...(argType?.table || {}), defaultValue: { From 5a0b66c907fb17445992fce6fb17bb042c715571 Mon Sep 17 00:00:00 2001 From: Fengbao Li <shijistar@gmail.com> Date: Tue, 4 Aug 2026 22:58:01 +0800 Subject: [PATCH 10/39] refactor(storybook): move Description column to second position in api-doc tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reorder every api-doc table column so the Description/说明 column sits immediately after the prop/field/method/参数/属性/方法 name column (2nd column) - Affected tables include both primary component prop tables and nested type tables (ColumnSettingItem, ContextMenuItem/Submenu/Separator, ContextMenuRef) - Order becomes: Name | Description | Type | (Default) (or Name | Description | Type) - Applied to all 16 api-doc.{en-US,zh-CN}.md files; prettier-formatted --- .../components/BreakLines/api-doc.en-US.md | 14 +++--- .../components/BreakLines/api-doc.zh-CN.md | 14 +++--- .../components/ColumnSetting/api-doc.en-US.md | 30 ++++++------ .../components/ColumnSetting/api-doc.zh-CN.md | 30 ++++++------ .../components/ContextMenu/api-doc.en-US.md | 46 +++++++++---------- .../components/ContextMenu/api-doc.zh-CN.md | 46 +++++++++---------- .../components/FloatDrawer/api-doc.en-US.md | 40 ++++++++-------- .../components/FloatDrawer/api-doc.zh-CN.md | 40 ++++++++-------- .../FormItemControl/api-doc.en-US.md | 6 +-- .../FormItemControl/api-doc.zh-CN.md | 6 +-- .../components/Iconfont/api-doc.en-US.md | 20 ++++---- .../components/Iconfont/api-doc.zh-CN.md | 20 ++++---- .../components/Loading/api-doc.en-US.md | 10 ++-- .../components/Loading/api-doc.zh-CN.md | 10 ++-- .../components/OverflowTags/api-doc.en-US.md | 20 ++++---- .../components/OverflowTags/api-doc.zh-CN.md | 20 ++++---- 16 files changed, 186 insertions(+), 186 deletions(-) diff --git a/.storybook/stories/components/BreakLines/api-doc.en-US.md b/.storybook/stories/components/BreakLines/api-doc.en-US.md index 4351e6a..c3dcdad 100644 --- a/.storybook/stories/components/BreakLines/api-doc.en-US.md +++ b/.storybook/stories/components/BreakLines/api-doc.en-US.md @@ -1,12 +1,12 @@ ## API -| Prop | Type | Default | Description | -| ----------- | ----------------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------ | -| `value` | `string \| undefined` | - | Text content to render | -| `enabled` | `boolean` | `true` | Whether line-break conversion is enabled | -| `EOL` | `string` | `'\n'` | The end-of-line character used to split lines | -| `tagName` | `false \| 'span' \| 'div' \| 'i' \| 'pre' \| (string & {})` | `false` | HTML tag used to render the content. When `false`, content is rendered as a React fragment | -| `className` | `string` | - | CSS class of the DOM node. Ignored when `tagName` is `false` | +| Prop | Description | Type | Default | +| ----------- | ------------------------------------------------------------------------------------------ | ----------------------------------------------------------- | ------- | +| `value` | Text content to render | `string \| undefined` | - | +| `enabled` | Whether line-break conversion is enabled | `boolean` | `true` | +| `EOL` | The end-of-line character used to split lines | `string` | `'\n'` | +| `tagName` | HTML tag used to render the content. When `false`, content is rendered as a React fragment | `false \| 'span' \| 'div' \| 'i' \| 'pre' \| (string & {})` | `false` | +| `className` | CSS class of the DOM node. Ignored when `tagName` is `false` | `string` | - | ## Notes diff --git a/.storybook/stories/components/BreakLines/api-doc.zh-CN.md b/.storybook/stories/components/BreakLines/api-doc.zh-CN.md index 5f02b5c..aec567b 100644 --- a/.storybook/stories/components/BreakLines/api-doc.zh-CN.md +++ b/.storybook/stories/components/BreakLines/api-doc.zh-CN.md @@ -1,12 +1,12 @@ ## API -| Prop | Type | Default | Description | -| ----------- | ----------------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------ | -| `value` | `string \| undefined` | - | Text content to render | -| `enabled` | `boolean` | `true` | Whether line-break conversion is enabled | -| `EOL` | `string` | `'\n'` | The end-of-line character used to split lines | -| `tagName` | `false \| 'span' \| 'div' \| 'i' \| 'pre' \| (string & {})` | `false` | HTML tag used to render the content. When `false`, content is rendered as a React fragment | -| `className` | `string` | - | CSS class of the DOM node. Ignored when `tagName` is `false` | +| Prop | Description | Type | Default | +| ----------- | ------------------------------------------------------------------------------------------ | ----------------------------------------------------------- | ------- | +| `value` | Text content to render | `string \| undefined` | - | +| `enabled` | Whether line-break conversion is enabled | `boolean` | `true` | +| `EOL` | The end-of-line character used to split lines | `string` | `'\n'` | +| `tagName` | HTML tag used to render the content. When `false`, content is rendered as a React fragment | `false \| 'span' \| 'div' \| 'i' \| 'pre' \| (string & {})` | `false` | +| `className` | CSS class of the DOM node. Ignored when `tagName` is `false` | `string` | - | ## 说明 diff --git a/.storybook/stories/components/ColumnSetting/api-doc.en-US.md b/.storybook/stories/components/ColumnSetting/api-doc.en-US.md index e71834d..462060b 100644 --- a/.storybook/stories/components/ColumnSetting/api-doc.en-US.md +++ b/.storybook/stories/components/ColumnSetting/api-doc.en-US.md @@ -1,25 +1,25 @@ ## API -| Prop | Type | Default | Description | -| ------------------- | ------------------------------------------------------ | ------- | ----------------------------------------------------------------------------------- | -| `columns` | `ColumnSettingItem<T>[]` | - | Column definitions including visibility, ordering, and `disabled` state | -| `onChange` | `(nextColumns: T[]) => void` | - | Called when the selected columns change; returns columns with `hidden` updated | -| `storageKey` | `string` | - | Local storage key for persisting column settings. If unset, persistence is disabled | -| `renderColumnTitle` | `(col: ColumnSettingItem, index: number) => ReactNode` | - | Custom renderer for column titles | -| `triggerProps` | `ButtonProps` | - | Props for the button that opens the dropdown | -| `dropdownProps` | `DropdownProps` | - | Props for the dropdown component | -| `popupProps` | `React.HTMLAttributes<HTMLDivElement>` | - | Props for the dropdown popup container | -| `checkAllProps` | `ButtonProps` | - | Props for the "Check All" button | -| `resetProps` | `ButtonProps` | - | Props for the "Reset" button | -| `prefixCls` | `string` | - | Custom CSS class prefix | +| Prop | Description | Type | Default | +| ------------------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------ | ------- | +| `columns` | Column definitions including visibility, ordering, and `disabled` state | `ColumnSettingItem<T>[]` | - | +| `onChange` | Called when the selected columns change; returns columns with `hidden` updated | `(nextColumns: T[]) => void` | - | +| `storageKey` | Local storage key for persisting column settings. If unset, persistence is disabled | `string` | - | +| `renderColumnTitle` | Custom renderer for column titles | `(col: ColumnSettingItem, index: number) => ReactNode` | - | +| `triggerProps` | Props for the button that opens the dropdown | `ButtonProps` | - | +| `dropdownProps` | Props for the dropdown component | `DropdownProps` | - | +| `popupProps` | Props for the dropdown popup container | `React.HTMLAttributes<HTMLDivElement>` | - | +| `checkAllProps` | Props for the "Check All" button | `ButtonProps` | - | +| `resetProps` | Props for the "Reset" button | `ButtonProps` | - | +| `prefixCls` | Custom CSS class prefix | `string` | - | ### `ColumnSettingItem` Extends Ant Design `ColumnType<T>` and adds: -| Field | Type | Description | -| ---------- | --------- | ------------------------------------------- | -| `disabled` | `boolean` | Disable toggling visibility for this column | +| Field | Description | Type | +| ---------- | ------------------------------------------- | --------- | +| `disabled` | Disable toggling visibility for this column | `boolean` | ## Notes diff --git a/.storybook/stories/components/ColumnSetting/api-doc.zh-CN.md b/.storybook/stories/components/ColumnSetting/api-doc.zh-CN.md index ea57cbe..a1c71c4 100644 --- a/.storybook/stories/components/ColumnSetting/api-doc.zh-CN.md +++ b/.storybook/stories/components/ColumnSetting/api-doc.zh-CN.md @@ -1,25 +1,25 @@ ## API -| Prop | Type | Default | Description | -| ------------------- | ------------------------------------------------------ | ------- | ----------------------------------------------------------------------------------- | -| `columns` | `ColumnSettingItem<T>[]` | - | Column definitions including visibility, ordering, and `disabled` state | -| `onChange` | `(nextColumns: T[]) => void` | - | Called when the selected columns change; returns columns with `hidden` updated | -| `storageKey` | `string` | - | Local storage key for persisting column settings. If unset, persistence is disabled | -| `renderColumnTitle` | `(col: ColumnSettingItem, index: number) => ReactNode` | - | Custom renderer for column titles | -| `triggerProps` | `ButtonProps` | - | Props for the button that opens the dropdown | -| `dropdownProps` | `DropdownProps` | - | Props for the dropdown component | -| `popupProps` | `React.HTMLAttributes<HTMLDivElement>` | - | Props for the dropdown popup container | -| `checkAllProps` | `ButtonProps` | - | Props for the "Check All" button | -| `resetProps` | `ButtonProps` | - | Props for the "Reset" button | -| `prefixCls` | `string` | - | Custom CSS class prefix | +| Prop | Description | Type | Default | +| ------------------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------ | ------- | +| `columns` | Column definitions including visibility, ordering, and `disabled` state | `ColumnSettingItem<T>[]` | - | +| `onChange` | Called when the selected columns change; returns columns with `hidden` updated | `(nextColumns: T[]) => void` | - | +| `storageKey` | Local storage key for persisting column settings. If unset, persistence is disabled | `string` | - | +| `renderColumnTitle` | Custom renderer for column titles | `(col: ColumnSettingItem, index: number) => ReactNode` | - | +| `triggerProps` | Props for the button that opens the dropdown | `ButtonProps` | - | +| `dropdownProps` | Props for the dropdown component | `DropdownProps` | - | +| `popupProps` | Props for the dropdown popup container | `React.HTMLAttributes<HTMLDivElement>` | - | +| `checkAllProps` | Props for the "Check All" button | `ButtonProps` | - | +| `resetProps` | Props for the "Reset" button | `ButtonProps` | - | +| `prefixCls` | Custom CSS class prefix | `string` | - | ### `ColumnSettingItem` Extends Ant Design `ColumnType<T>` and adds: -| Field | Type | Description | -| ---------- | --------- | ------------------------------------------- | -| `disabled` | `boolean` | Disable toggling visibility for this column | +| Field | Description | Type | +| ---------- | ------------------------------------------- | --------- | +| `disabled` | Disable toggling visibility for this column | `boolean` | ## 说明 diff --git a/.storybook/stories/components/ContextMenu/api-doc.en-US.md b/.storybook/stories/components/ContextMenu/api-doc.en-US.md index 0217fb4..74872a4 100644 --- a/.storybook/stories/components/ContextMenu/api-doc.en-US.md +++ b/.storybook/stories/components/ContextMenu/api-doc.en-US.md @@ -1,44 +1,44 @@ ## API -| Prop | Type | Default | Description | -| -------------- | ------------------------------------------------------------------- | ----------------- | ----------------------------- | -| `items` | `(ContextMenuItem \| ContextMenuSeparator \| ContextMenuSubmenu)[]` | - | Menu items to display | -| `trigger` | `('click' \| 'doubleClick' \| 'hover' \| 'contextMenu')[]` | `['contextMenu']` | How the menu is triggered | -| `triggerProps` | `{ className?: string; style?: CSSProperties }` | - | Props for the trigger element | -| `prefixCls` | `string` | - | Custom CSS class prefix | +| Prop | Description | Type | Default | +| -------------- | ----------------------------- | ------------------------------------------------------------------- | ----------------- | +| `items` | Menu items to display | `(ContextMenuItem \| ContextMenuSeparator \| ContextMenuSubmenu)[]` | - | +| `trigger` | How the menu is triggered | `('click' \| 'doubleClick' \| 'hover' \| 'contextMenu')[]` | `['contextMenu']` | +| `triggerProps` | Props for the trigger element | `{ className?: string; style?: CSSProperties }` | - | +| `prefixCls` | Custom CSS class prefix | `string` | - | > Other `MenuProps` (from `react-contexify`) are forwarded to the underlying `<Menu>`. ### `ContextMenuItem` -| Field | Type | Description | -| ------------- | -------------------------------------- | ----------------------------------------- | -| `key` | `string` | Unique key | -| `label` | `ReactNode` | Item label | -| `icon` | `ReactNode` | Leading icon | -| `shortcutKey` | `Partial<KeyboardEvent> \| keyMatcher` | Keyboard shortcut matcher | -| `children` | `ReactNode` | Custom content (overrides `label`/`icon`) | +| Field | Description | Type | +| ------------- | ----------------------------------------- | -------------------------------------- | +| `key` | Unique key | `string` | +| `label` | Item label | `ReactNode` | +| `icon` | Leading icon | `ReactNode` | +| `shortcutKey` | Keyboard shortcut matcher | `Partial<KeyboardEvent> \| keyMatcher` | +| `children` | Custom content (overrides `label`/`icon`) | `ReactNode` | ### `ContextMenuSubmenu` -| Field | Type | Description | -| ------- | ------------------------------------------------------------------- | ------------- | -| `key` | `string` | Unique key | -| `type` | `'submenu'` | Discriminator | -| `items` | `(ContextMenuItem \| ContextMenuSeparator \| ContextMenuSubmenu)[]` | Nested items | +| Field | Description | Type | +| ------- | ------------- | ------------------------------------------------------------------- | +| `key` | Unique key | `string` | +| `type` | Discriminator | `'submenu'` | +| `items` | Nested items | `(ContextMenuItem \| ContextMenuSeparator \| ContextMenuSubmenu)[]` | ### `ContextMenuSeparator` -| Field | Type | Description | +| Field | Description | Type | | ------ | ------------- | ------------- | -| `type` | `'separator'` | Discriminator | +| `type` | Discriminator | `'separator'` | ### Ref — `ContextMenuRef` -| Method | Signature | Description | +| Method | Description | Signature | | --------- | ----------------------------------- | ----------------------------------- | -| `show` | `(event: React.MouseEvent) => void` | Open the menu at the event position | -| `hideAll` | `() => void` | Close all context menus | +| `show` | Open the menu at the event position | `(event: React.MouseEvent) => void` | +| `hideAll` | Close all context menus | `() => void` | ## Notes diff --git a/.storybook/stories/components/ContextMenu/api-doc.zh-CN.md b/.storybook/stories/components/ContextMenu/api-doc.zh-CN.md index 07a3d23..48908a8 100644 --- a/.storybook/stories/components/ContextMenu/api-doc.zh-CN.md +++ b/.storybook/stories/components/ContextMenu/api-doc.zh-CN.md @@ -1,44 +1,44 @@ ## API -| Prop | Type | Default | Description | -| -------------- | ------------------------------------------------------------------- | ----------------- | ----------------------------- | -| `items` | `(ContextMenuItem \| ContextMenuSeparator \| ContextMenuSubmenu)[]` | - | Menu items to display | -| `trigger` | `('click' \| 'doubleClick' \| 'hover' \| 'contextMenu')[]` | `['contextMenu']` | How the menu is triggered | -| `triggerProps` | `{ className?: string; style?: CSSProperties }` | - | Props for the trigger element | -| `prefixCls` | `string` | - | Custom CSS class prefix | +| Prop | Description | Type | Default | +| -------------- | ----------------------------- | ------------------------------------------------------------------- | ----------------- | +| `items` | Menu items to display | `(ContextMenuItem \| ContextMenuSeparator \| ContextMenuSubmenu)[]` | - | +| `trigger` | How the menu is triggered | `('click' \| 'doubleClick' \| 'hover' \| 'contextMenu')[]` | `['contextMenu']` | +| `triggerProps` | Props for the trigger element | `{ className?: string; style?: CSSProperties }` | - | +| `prefixCls` | Custom CSS class prefix | `string` | - | > Other `MenuProps` (from `react-contexify`) are forwarded to the underlying `<Menu>`. ### `ContextMenuItem` -| Field | Type | Description | -| ------------- | -------------------------------------- | ----------------------------------------- | -| `key` | `string` | Unique key | -| `label` | `ReactNode` | Item label | -| `icon` | `ReactNode` | Leading icon | -| `shortcutKey` | `Partial<KeyboardEvent> \| keyMatcher` | Keyboard shortcut matcher | -| `children` | `ReactNode` | Custom content (overrides `label`/`icon`) | +| Field | Description | Type | +| ------------- | ----------------------------------------- | -------------------------------------- | +| `key` | Unique key | `string` | +| `label` | Item label | `ReactNode` | +| `icon` | Leading icon | `ReactNode` | +| `shortcutKey` | Keyboard shortcut matcher | `Partial<KeyboardEvent> \| keyMatcher` | +| `children` | Custom content (overrides `label`/`icon`) | `ReactNode` | ### `ContextMenuSubmenu` -| Field | Type | Description | -| ------- | ------------------------------------------------------------------- | ------------- | -| `key` | `string` | Unique key | -| `type` | `'submenu'` | Discriminator | -| `items` | `(ContextMenuItem \| ContextMenuSeparator \| ContextMenuSubmenu)[]` | Nested items | +| Field | Description | Type | +| ------- | ------------- | ------------------------------------------------------------------- | +| `key` | Unique key | `string` | +| `type` | Discriminator | `'submenu'` | +| `items` | Nested items | `(ContextMenuItem \| ContextMenuSeparator \| ContextMenuSubmenu)[]` | ### `ContextMenuSeparator` -| Field | Type | Description | +| Field | Description | Type | | ------ | ------------- | ------------- | -| `type` | `'separator'` | Discriminator | +| `type` | Discriminator | `'separator'` | ### Ref — `ContextMenuRef` -| Method | Signature | Description | +| Method | Description | Signature | | --------- | ----------------------------------- | ----------------------------------- | -| `show` | `(event: React.MouseEvent) => void` | Open the menu at the event position | -| `hideAll` | `() => void` | Close all context menus | +| `show` | Open the menu at the event position | `(event: React.MouseEvent) => void` | +| `hideAll` | Close all context menus | `() => void` | ## 说明 diff --git a/.storybook/stories/components/FloatDrawer/api-doc.en-US.md b/.storybook/stories/components/FloatDrawer/api-doc.en-US.md index 5ce2fbb..e182526 100644 --- a/.storybook/stories/components/FloatDrawer/api-doc.en-US.md +++ b/.storybook/stories/components/FloatDrawer/api-doc.en-US.md @@ -1,25 +1,25 @@ ## API -| Prop | Type | Default | Description | -| --------------------- | ------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------ | -| `open` | `boolean` | - | Whether the drawer is open (controlled) | -| `position` | `'left' \| 'right' \| 'top' \| 'bottom'` | `'right'` | Drawer position | -| `defaultSize` | `number` | - | Default size (width for left/right, height otherwise); when unset, adapts to content | -| `minSize` | `number` | `0` | Minimum size | -| `maxSize` | `number` | `Infinity` | Maximum size | -| `edgeOffset` | `CSSProperties['width']` | `0` | Offset from the parent edge; tune so the drawer hides fully outside the container | -| `showToggle` | `boolean` | `true` | Whether to show the toggle handle | -| `resizable` | `boolean` | `true` | Whether the drawer is resizable | -| `destroyOnClose` | `boolean` | `false` | Destroy content when closed | -| `cacheKey` | `string` | - | localStorage key to persist drawer size | -| `cardProps` | `Omit<CardProps, 'children'>` | - | Props for the inner `Card` | -| `className` / `style` | `string` / `CSSProperties` | - | Root class / style | -| `classNames` | `{ drawer; toggle; resizeHandle; handleIcon; content; card }` | - | Class names for specific parts | -| `styles` | `{ drawer; toggle; resizeHandle; handleIcon; content; card }` | - | Styles for specific parts | -| `onOpenChange` | `(open: boolean) => void` | - | Called when open state changes | -| `onResize` | `(size: number) => void` | - | Called when the drawer is resized | -| `onClick` | `(e: React.MouseEvent) => void` | - | Click handler for the drawer container | -| `children` | `ReactNode` | - | Drawer content | +| Prop | Description | Type | Default | +| --------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------- | ---------- | +| `open` | Whether the drawer is open (controlled) | `boolean` | - | +| `position` | Drawer position | `'left' \| 'right' \| 'top' \| 'bottom'` | `'right'` | +| `defaultSize` | Default size (width for left/right, height otherwise); when unset, adapts to content | `number` | - | +| `minSize` | Minimum size | `number` | `0` | +| `maxSize` | Maximum size | `number` | `Infinity` | +| `edgeOffset` | Offset from the parent edge; tune so the drawer hides fully outside the container | `CSSProperties['width']` | `0` | +| `showToggle` | Whether to show the toggle handle | `boolean` | `true` | +| `resizable` | Whether the drawer is resizable | `boolean` | `true` | +| `destroyOnClose` | Destroy content when closed | `boolean` | `false` | +| `cacheKey` | localStorage key to persist drawer size | `string` | - | +| `cardProps` | Props for the inner `Card` | `Omit<CardProps, 'children'>` | - | +| `className` / `style` | Root class / style | `string` / `CSSProperties` | - | +| `classNames` | Class names for specific parts | `{ drawer; toggle; resizeHandle; handleIcon; content; card }` | - | +| `styles` | Styles for specific parts | `{ drawer; toggle; resizeHandle; handleIcon; content; card }` | - | +| `onOpenChange` | Called when open state changes | `(open: boolean) => void` | - | +| `onResize` | Called when the drawer is resized | `(size: number) => void` | - | +| `onClick` | Click handler for the drawer container | `(e: React.MouseEvent) => void` | - | +| `children` | Drawer content | `ReactNode` | - | ## Notes diff --git a/.storybook/stories/components/FloatDrawer/api-doc.zh-CN.md b/.storybook/stories/components/FloatDrawer/api-doc.zh-CN.md index 5864693..551a47f 100644 --- a/.storybook/stories/components/FloatDrawer/api-doc.zh-CN.md +++ b/.storybook/stories/components/FloatDrawer/api-doc.zh-CN.md @@ -1,25 +1,25 @@ ## API -| Prop | Type | Default | Description | -| --------------------- | ------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------ | -| `open` | `boolean` | - | Whether the drawer is open (controlled) | -| `position` | `'left' \| 'right' \| 'top' \| 'bottom'` | `'right'` | Drawer position | -| `defaultSize` | `number` | - | Default size (width for left/right, height otherwise); when unset, adapts to content | -| `minSize` | `number` | `0` | Minimum size | -| `maxSize` | `number` | `Infinity` | Maximum size | -| `edgeOffset` | `CSSProperties['width']` | `0` | Offset from the parent edge; tune so the drawer hides fully outside the container | -| `showToggle` | `boolean` | `true` | Whether to show the toggle handle | -| `resizable` | `boolean` | `true` | Whether the drawer is resizable | -| `destroyOnClose` | `boolean` | `false` | Destroy content when closed | -| `cacheKey` | `string` | - | localStorage key to persist drawer size | -| `cardProps` | `Omit<CardProps, 'children'>` | - | Props for the inner `Card` | -| `className` / `style` | `string` / `CSSProperties` | - | Root class / style | -| `classNames` | `{ drawer; toggle; resizeHandle; handleIcon; content; card }` | - | Class names for specific parts | -| `styles` | `{ drawer; toggle; resizeHandle; handleIcon; content; card }` | - | Styles for specific parts | -| `onOpenChange` | `(open: boolean) => void` | - | Called when open state changes | -| `onResize` | `(size: number) => void` | - | Called when the drawer is resized | -| `onClick` | `(e: React.MouseEvent) => void` | - | Click handler for the drawer container | -| `children` | `ReactNode` | - | Drawer content | +| Prop | Description | Type | Default | +| --------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------- | ---------- | +| `open` | Whether the drawer is open (controlled) | `boolean` | - | +| `position` | Drawer position | `'left' \| 'right' \| 'top' \| 'bottom'` | `'right'` | +| `defaultSize` | Default size (width for left/right, height otherwise); when unset, adapts to content | `number` | - | +| `minSize` | Minimum size | `number` | `0` | +| `maxSize` | Maximum size | `number` | `Infinity` | +| `edgeOffset` | Offset from the parent edge; tune so the drawer hides fully outside the container | `CSSProperties['width']` | `0` | +| `showToggle` | Whether to show the toggle handle | `boolean` | `true` | +| `resizable` | Whether the drawer is resizable | `boolean` | `true` | +| `destroyOnClose` | Destroy content when closed | `boolean` | `false` | +| `cacheKey` | localStorage key to persist drawer size | `string` | - | +| `cardProps` | Props for the inner `Card` | `Omit<CardProps, 'children'>` | - | +| `className` / `style` | Root class / style | `string` / `CSSProperties` | - | +| `classNames` | Class names for specific parts | `{ drawer; toggle; resizeHandle; handleIcon; content; card }` | - | +| `styles` | Styles for specific parts | `{ drawer; toggle; resizeHandle; handleIcon; content; card }` | - | +| `onOpenChange` | Called when open state changes | `(open: boolean) => void` | - | +| `onResize` | Called when the drawer is resized | `(size: number) => void` | - | +| `onClick` | Click handler for the drawer container | `(e: React.MouseEvent) => void` | - | +| `children` | Drawer content | `ReactNode` | - | ## 说明 diff --git a/.storybook/stories/components/FormItemControl/api-doc.en-US.md b/.storybook/stories/components/FormItemControl/api-doc.en-US.md index 54bcf7a..cae39b5 100644 --- a/.storybook/stories/components/FormItemControl/api-doc.en-US.md +++ b/.storybook/stories/components/FormItemControl/api-doc.en-US.md @@ -1,8 +1,8 @@ ## API -| Prop | Type | Description | -| ---------- | ------------------------------------------------------------------------ | ----------------------------------------------------------------- | -| `children` | `(options: { value: any; onChange: (value: any) => void }) => ReactNode` | Render prop receiving the current `value` and a setter `onChange` | +| Prop | Description | Type | +| ---------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------ | +| `children` | Render prop receiving the current `value` and a setter `onChange` | `(options: { value: any; onChange: (value: any) => void }) => ReactNode` | ## Usage diff --git a/.storybook/stories/components/FormItemControl/api-doc.zh-CN.md b/.storybook/stories/components/FormItemControl/api-doc.zh-CN.md index b4f9e7c..783b75f 100644 --- a/.storybook/stories/components/FormItemControl/api-doc.zh-CN.md +++ b/.storybook/stories/components/FormItemControl/api-doc.zh-CN.md @@ -1,8 +1,8 @@ ## API -| Prop | Type | Description | -| ---------- | ------------------------------------------------------------------------ | ----------------------------------------------------------------- | -| `children` | `(options: { value: any; onChange: (value: any) => void }) => ReactNode` | Render prop receiving the current `value` and a setter `onChange` | +| Prop | Description | Type | +| ---------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------ | +| `children` | Render prop receiving the current `value` and a setter `onChange` | `(options: { value: any; onChange: (value: any) => void }) => ReactNode` | ## 用法 diff --git a/.storybook/stories/components/Iconfont/api-doc.en-US.md b/.storybook/stories/components/Iconfont/api-doc.en-US.md index 973a88d..69c0d5f 100644 --- a/.storybook/stories/components/Iconfont/api-doc.en-US.md +++ b/.storybook/stories/components/Iconfont/api-doc.en-US.md @@ -2,16 +2,16 @@ **Factory API** -| Parameter | Type | Default | Description | -| -------------------- | -------- | ------- | --------------------------------------------------------------------------------- | -| `scriptUrl` | `string` | - | iconfont script URL generated from iconfont.cn | -| `options.iconPrefix` | `string` | `''` | Prefix automatically prepended to `type` unless it already starts with the prefix | +| Parameter | Description | Type | Default | +| -------------------- | --------------------------------------------------------------------------------- | -------- | ------- | +| `scriptUrl` | iconfont script URL generated from iconfont.cn | `string` | - | +| `options.iconPrefix` | Prefix automatically prepended to `type` unless it already starts with the prefix | `string` | `''` | **Component props** -| Prop | Type | Default | Description | -| -------- | --------------------------- | ------- | ---------------------------------------------------- | -| `type` | `T` | - | Icon name. Find it in iconfont and click `Copy Code` | -| `size` | `CSSProperties['fontSize']` | - | Alias of `style.fontSize` | -| `spin` | `boolean` | `false` | Whether the icon spins continuously | -| `rotate` | `number` | `0` | Fixed clockwise rotation angle | +| Prop | Description | Type | Default | +| -------- | ---------------------------------------------------- | --------------------------- | ------- | +| `type` | Icon name. Find it in iconfont and click `Copy Code` | `T` | - | +| `size` | Alias of `style.fontSize` | `CSSProperties['fontSize']` | - | +| `spin` | Whether the icon spins continuously | `boolean` | `false` | +| `rotate` | Fixed clockwise rotation angle | `number` | `0` | diff --git a/.storybook/stories/components/Iconfont/api-doc.zh-CN.md b/.storybook/stories/components/Iconfont/api-doc.zh-CN.md index 34a2c1a..fa8f00a 100644 --- a/.storybook/stories/components/Iconfont/api-doc.zh-CN.md +++ b/.storybook/stories/components/Iconfont/api-doc.zh-CN.md @@ -2,16 +2,16 @@ **工厂 API** -| 参数 | 类型 | 默认值 | 说明 | -| -------------------- | -------- | ------ | ---------------------------------------------------------- | -| `scriptUrl` | `string` | - | 在 iconfont.cn 上生成的脚本地址 | -| `options.iconPrefix` | `string` | `''` | 自动拼接到 `type` 的前缀,若 `type` 已包含该前缀则不再拼接 | +| 参数 | 说明 | 类型 | 默认值 | +| -------------------- | ---------------------------------------------------------- | -------- | ------ | +| `scriptUrl` | 在 iconfont.cn 上生成的脚本地址 | `string` | - | +| `options.iconPrefix` | 自动拼接到 `type` 的前缀,若 `type` 已包含该前缀则不再拼接 | `string` | `''` | **组件 props** -| 属性 | 类型 | 默认值 | 说明 | -| -------- | --------------------------- | ------- | ---------------------------------------------------- | -| `type` | `T` | - | 图标名称。在 iconfont 中找到某个图标,点击`复制代码` | -| `size` | `CSSProperties['fontSize']` | - | `style.fontSize` 的别名 | -| `spin` | `boolean` | `false` | 图标是否持续旋转 | -| `rotate` | `number` | `0` | 图标顺时针旋转一个固定角度 | +| 属性 | 说明 | 类型 | 默认值 | +| -------- | ---------------------------------------------------- | --------------------------- | ------- | +| `type` | 图标名称。在 iconfont 中找到某个图标,点击`复制代码` | `T` | - | +| `size` | `style.fontSize` 的别名 | `CSSProperties['fontSize']` | - | +| `spin` | 图标是否持续旋转 | `boolean` | `false` | +| `rotate` | 图标顺时针旋转一个固定角度 | `number` | `0` | diff --git a/.storybook/stories/components/Loading/api-doc.en-US.md b/.storybook/stories/components/Loading/api-doc.en-US.md index 6310553..5b8b77b 100644 --- a/.storybook/stories/components/Loading/api-doc.en-US.md +++ b/.storybook/stories/components/Loading/api-doc.en-US.md @@ -2,11 +2,11 @@ Extends Ant Design `SpinProps` and adds: -| Prop | Type | Default | Description | -| --------------- | ---------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------- | -| `mode` | `'absolute' \| 'flex'` | `'flex'` | Positioning of the standalone animation. `absolute` centers via absolute positioning; `flex` fills the parent with flexbox | -| `rootClassName` | `string` | - | Class name for the mask parent container (standalone mode) | -| `rootStyle` | `CSSProperties` | - | Style for the mask parent container (standalone mode) | +| Prop | Description | Type | Default | +| --------------- | -------------------------------------------------------------------------------------------------------------------------- | ---------------------- | -------- | +| `mode` | Positioning of the standalone animation. `absolute` centers via absolute positioning; `flex` fills the parent with flexbox | `'absolute' \| 'flex'` | `'flex'` | +| `rootClassName` | Class name for the mask parent container (standalone mode) | `string` | - | +| `rootStyle` | Style for the mask parent container (standalone mode) | `CSSProperties` | - | ## Notes diff --git a/.storybook/stories/components/Loading/api-doc.zh-CN.md b/.storybook/stories/components/Loading/api-doc.zh-CN.md index 64787f6..af2adb3 100644 --- a/.storybook/stories/components/Loading/api-doc.zh-CN.md +++ b/.storybook/stories/components/Loading/api-doc.zh-CN.md @@ -2,11 +2,11 @@ Extends Ant Design `SpinProps` and adds: -| Prop | Type | Default | Description | -| --------------- | ---------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------- | -| `mode` | `'absolute' \| 'flex'` | `'flex'` | Positioning of the standalone animation. `absolute` centers via absolute positioning; `flex` fills the parent with flexbox | -| `rootClassName` | `string` | - | Class name for the mask parent container (standalone mode) | -| `rootStyle` | `CSSProperties` | - | Style for the mask parent container (standalone mode) | +| Prop | Description | Type | Default | +| --------------- | -------------------------------------------------------------------------------------------------------------------------- | ---------------------- | -------- | +| `mode` | Positioning of the standalone animation. `absolute` centers via absolute positioning; `flex` fills the parent with flexbox | `'absolute' \| 'flex'` | `'flex'` | +| `rootClassName` | Class name for the mask parent container (standalone mode) | `string` | - | +| `rootStyle` | Style for the mask parent container (standalone mode) | `CSSProperties` | - | ## 说明 diff --git a/.storybook/stories/components/OverflowTags/api-doc.en-US.md b/.storybook/stories/components/OverflowTags/api-doc.en-US.md index 5f5f6f4..a4e42c4 100644 --- a/.storybook/stories/components/OverflowTags/api-doc.en-US.md +++ b/.storybook/stories/components/OverflowTags/api-doc.en-US.md @@ -2,16 +2,16 @@ `OverflowTagsProps<T>` extends `rc-overflow`'s `OverflowProps<T>` (minus `renderItem`) and adds: -| Prop | Type | Default | Description | -| ----------------------- | ------------------------------------------------------------ | ------------------------- | --------------------------------------------------------------------- | -| `tags` | `T[]` | `[]` | Tag data collection | -| `getTagName` | `(tag: T) => ReactNode` | `tag.label` or `tag.name` | Resolve the tag display name | -| `getTagKey` | `(tag: T) => React.Key` | `tag.value` or `tag.id` | Resolve the tag unique key | -| `renderTag` | `OverflowProps<T>['renderItem']` | - | Custom tag renderer | -| `tagProps` | `TagProps \| ((tag: T, { tags }) => TagProps)` | - | Props for the `Tag` component | -| `ellipsisTagProps` | `TagProps \| ((tag, { omittedItems, allTags }) => TagProps)` | - | Props for the ellipsis ("+N") tag | -| `ellipsisDropdownProps` | `DropdownProps` | - | Props for the overflow dropdown | -| `randomColors` | `boolean` | `false` | Use random preset colors. A `color` field on the tag takes precedence | +| Prop | Description | Type | Default | +| ----------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------ | ------------------------- | +| `tags` | Tag data collection | `T[]` | `[]` | +| `getTagName` | Resolve the tag display name | `(tag: T) => ReactNode` | `tag.label` or `tag.name` | +| `getTagKey` | Resolve the tag unique key | `(tag: T) => React.Key` | `tag.value` or `tag.id` | +| `renderTag` | Custom tag renderer | `OverflowProps<T>['renderItem']` | - | +| `tagProps` | Props for the `Tag` component | `TagProps \| ((tag: T, { tags }) => TagProps)` | - | +| `ellipsisTagProps` | Props for the ellipsis ("+N") tag | `TagProps \| ((tag, { omittedItems, allTags }) => TagProps)` | - | +| `ellipsisDropdownProps` | Props for the overflow dropdown | `DropdownProps` | - | +| `randomColors` | Use random preset colors. A `color` field on the tag takes precedence | `boolean` | `false` | ## Notes diff --git a/.storybook/stories/components/OverflowTags/api-doc.zh-CN.md b/.storybook/stories/components/OverflowTags/api-doc.zh-CN.md index 554d51a..ac6398b 100644 --- a/.storybook/stories/components/OverflowTags/api-doc.zh-CN.md +++ b/.storybook/stories/components/OverflowTags/api-doc.zh-CN.md @@ -2,16 +2,16 @@ `OverflowTagsProps<T>` 继承自 `rc-overflow` 的 `OverflowProps<T>`(不包括 `renderItem`)并增加了: -| 属性 | 类型 | 默认值 | 说明 | -| ----------------------- | ------------------------------------------------------------ | ------------------------- | ------------------------------------------------- | -| `tags` | `T[]` | `[]` | 标签数据集合 | -| `getTagName` | `(tag: T) => ReactNode` | `tag.label` or `tag.name` | 获取标签显示的内容 | -| `getTagKey` | `(tag: T) => React.Key` | `tag.value` or `tag.id` | 获取标签的唯一 key | -| `renderTag` | `OverflowProps<T>['renderItem']` | - | 自定义标签渲染器 | -| `tagProps` | `TagProps \| ((tag: T, { tags }) => TagProps)` | - | `Tag` 组件的属性 | -| `ellipsisTagProps` | `TagProps \| ((tag, { omittedItems, allTags }) => TagProps)` | - | 省略号("+N")标签的属性 | -| `ellipsisDropdownProps` | `DropdownProps` | - | 溢出下拉菜单的属性 | -| `randomColors` | `boolean` | `false` | 使用随机预设颜色。标签上的 `color` 字段优先级更高 | +| 属性 | 说明 | 类型 | 默认值 | +| ----------------------- | ------------------------------------------------- | ------------------------------------------------------------ | ------------------------- | +| `tags` | 标签数据集合 | `T[]` | `[]` | +| `getTagName` | 获取标签显示的内容 | `(tag: T) => ReactNode` | `tag.label` or `tag.name` | +| `getTagKey` | 获取标签的唯一 key | `(tag: T) => React.Key` | `tag.value` or `tag.id` | +| `renderTag` | 自定义标签渲染器 | `OverflowProps<T>['renderItem']` | - | +| `tagProps` | `Tag` 组件的属性 | `TagProps \| ((tag: T, { tags }) => TagProps)` | - | +| `ellipsisTagProps` | 省略号("+N")标签的属性 | `TagProps \| ((tag, { omittedItems, allTags }) => TagProps)` | - | +| `ellipsisDropdownProps` | 溢出下拉菜单的属性 | `DropdownProps` | - | +| `randomColors` | 使用随机预设颜色。标签上的 `color` 字段优先级更高 | `boolean` | `false` | ## 说明 From 54b0fc3bcc8419370d688d71efc6e54470dacc37 Mon Sep 17 00:00:00 2001 From: Fengbao Li <shijistar@gmail.com> Date: Tue, 4 Aug 2026 23:43:06 +0800 Subject: [PATCH 11/39] docs(storybook): enrich component docs (batch 1: BreakLines, ConfirmAction, ContextMenu) - Add structured bilingual introduce sections (Overview / When to use / Key features / Usage notes) to BreakLines, ConfirmAction, ContextMenu - ConfirmAction: add api-doc.en-US/zh-CN.md (component-specific props + ref methods) and wire it into index.stories.tsx imports/description - All content derived from component source (Props JSDoc, behavior) - prettier-formatted; build-storybook verified --- .../components/BreakLines/introduce.en-US.md | 15 ++++++++++- .../components/BreakLines/introduce.zh-CN.md | 15 ++++++++++- .../components/ConfirmAction/api-doc.en-US.md | 25 +++++++++++++++++++ .../components/ConfirmAction/api-doc.zh-CN.md | 25 +++++++++++++++++++ .../ConfirmAction/index.stories.tsx | 4 ++- .../ConfirmAction/introduce.en-US.md | 20 +++++++++++++++ .../ConfirmAction/introduce.zh-CN.md | 20 +++++++++++++++ .../components/ContextMenu/introduce.en-US.md | 18 ++++++++++++- .../components/ContextMenu/introduce.zh-CN.md | 18 ++++++++++++- 9 files changed, 155 insertions(+), 5 deletions(-) create mode 100644 .storybook/stories/components/ConfirmAction/api-doc.en-US.md create mode 100644 .storybook/stories/components/ConfirmAction/api-doc.zh-CN.md diff --git a/.storybook/stories/components/BreakLines/introduce.en-US.md b/.storybook/stories/components/BreakLines/introduce.en-US.md index 2ab018b..3e16e1e 100644 --- a/.storybook/stories/components/BreakLines/introduce.en-US.md +++ b/.storybook/stories/components/BreakLines/introduce.en-US.md @@ -2,4 +2,17 @@ Renders plain text with preserved line breaks by splitting with a configurable E ## When to use -Display backend-provided text that contains `\n` (or another EOL marker) and you want it shown as multiple visual lines instead of a single wrapped paragraph. +Display backend-provided text that contains `\n` (or another EOL marker) and you want it shown as multiple visual lines instead of a single wrapped paragraph. This is common for multi-line addresses, log snippets, poetry, or any string whose line structure carries meaning. + +## Key features + +- **Line break preservation** — splits the input by a configurable end-of-line character and renders each segment on its own line, joined by `<br />`. +- **Pluggable output tag** — when `tagName` is set, the whole content is wrapped in that HTML element (e.g. `div`, `span`, `pre`); when `tagName` is `false` (default) it renders a React fragment so it can be embedded inline. +- **Toggle at runtime** — the `enabled` prop turns conversion on/off without changing the source value. +- **Custom EOL** — `EOL` lets you split on any delimiter (e.g. `\r\n`, `|`, a custom token), not just `\n`. + +## Usage notes + +- When `tagName` is `false` (default), the result is a fragment with `<br />` between segments, so it can be placed inside running text without introducing an extra DOM node. `className` is ignored in this mode. +- `EOL` only affects splitting; it is not stripped from the rendered output — each segment keeps its own content. +- Long lines are still subject to the parent container's normal text wrapping; `BreakLines` does not force horizontal overflow. diff --git a/.storybook/stories/components/BreakLines/introduce.zh-CN.md b/.storybook/stories/components/BreakLines/introduce.zh-CN.md index a9cc7bd..c09aa24 100644 --- a/.storybook/stories/components/BreakLines/introduce.zh-CN.md +++ b/.storybook/stories/components/BreakLines/introduce.zh-CN.md @@ -2,4 +2,17 @@ ## 适用场景 -展示后端返回、包含 `\n`(或其它换行符)的文本,希望按原换行分段显示,而不是被当作一整段自动折行。 +展示后端返回、包含 `\n`(或其它换行符)的文本,希望按原换行分段显示,而不是被当作一整段自动折行。常见于多行地址、日志片段、诗文,或任何"换行本身具有语义"的字符串。 + +## 核心特性 + +- **保留换行** —— 按可配置的换行符拆分输入,并将每一段渲染为独立的一行,段间以 `<br />` 连接。 +- **可选输出标签** —— 设置 `tagName` 后,整段内容会被包裹到该 HTML 元素中(如 `div`、`span`、`pre`);为 `false`(默认)时以 React 片段渲染,可内联嵌入。 +- **运行时可切换** —— `enabled` 属性可在不修改源数据的情况下开关换行转换。 +- **自定义换行符** —— `EOL` 支持任意分隔符(如 `\r\n`、`|`、自定义标记),不局限于 `\n`。 + +## 使用注意 + +- 当 `tagName` 为 `false`(默认)时,结果为片段且段间使用 `<br />`,可放入行内文本而不会额外产生 DOM 节点;此模式下 `className` 不生效。 +- `EOL` 仅影响拆分,不会被从渲染结果中剔除——每段保留自身内容。 +- 长行仍受父容器正常文本折行约束,`BreakLines` 不会强制横向溢出。 diff --git a/.storybook/stories/components/ConfirmAction/api-doc.en-US.md b/.storybook/stories/components/ConfirmAction/api-doc.en-US.md new file mode 100644 index 0000000..f97ae85 --- /dev/null +++ b/.storybook/stories/components/ConfirmAction/api-doc.en-US.md @@ -0,0 +1,25 @@ +## API + +The component extends antd's `ModalFuncProps` (title, content, okText, cancelText, okButtonProps, …). The props below are specific to `ConfirmAction`. + +| Prop | Description | Type | Default | +| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | -------------------- | +| `danger` | Whether to render in red danger mode, affecting the title, icon and confirm button color. `DeleteConfirmAction` defaults to `true`. | `boolean` | `false` | +| `titleColor` | Color of the confirm box title. | `TextProps['type'] \| 'primary'` | `warning` | +| `contentColor` | Color of the confirm box content text. | `TextProps['type'] \| 'primary'` | - | +| `iconColor` | Color of the confirm box title icon; defaults to the same as `titleColor`. | `TextProps['type'] \| 'primary'` | same as `titleColor` | +| `triggerComponent` | The component used as the trigger that opens the dialog. | `ComponentType<TriggerProp>` | `Button` | +| `triggerProps` | Props passed to the trigger component. | `TriggerProp` | - | +| `triggerEvent` | The event name on the trigger that opens the dialog (e.g. `onClick`, `onChange`). | `keyof TriggerProp` | `'onClick'` | +| `children` | Custom content of the trigger. | `ReactNode` | - | +| `onBeforeOpen` | Callback before opening; if it throws/rejects, the dialog won't open. | `() => Promise<unknown> \| unknown` | - | +| `onOk` | Callback when the confirm button is clicked; can be async. | `(...args) => unknown \| Promise<unknown>` | - | +| `afterOk` | Callback after `onOk` resolves successfully; won't run if `onOk` fails. | `(data?) => void` | - | + +### Ref — `ConfirmActionRef` + +| Method | Signature | Description | +| --------- | ------------------------------------------------------------- | ------------------------------------- | +| `show` | `(props?: Parameters<ModalFunc>[0]) => ReturnType<ModalFunc>` | Open the confirm dialog imperatively. | +| `update` | `(props?: Parameters<ModalFunc>[0]) => ReturnType<ModalFunc>` | Update the open dialog's props. | +| `destroy` | `() => void` | Close and destroy the dialog. | diff --git a/.storybook/stories/components/ConfirmAction/api-doc.zh-CN.md b/.storybook/stories/components/ConfirmAction/api-doc.zh-CN.md new file mode 100644 index 0000000..b676ae1 --- /dev/null +++ b/.storybook/stories/components/ConfirmAction/api-doc.zh-CN.md @@ -0,0 +1,25 @@ +## API + +该组件继承 antd 的 `ModalFuncProps`(title、content、okText、cancelText、okButtonProps 等)。以下属性为 `ConfirmAction` 特有。 + +| 属性 | 说明 | 类型 | 默认值 | +| ------------------ | --------------------------------------------------------------------------------------------- | ------------------------------------------ | --------------- | +| `danger` | 是否以红色危险模式渲染,影响标题、图标与确认按钮的颜色。`DeleteConfirmAction` 默认为 `true`。 | `boolean` | `false` | +| `titleColor` | 确认框标题颜色。 | `TextProps['type'] \| 'primary'` | `warning` | +| `contentColor` | 确认框内容文本颜色。 | `TextProps['type'] \| 'primary'` | - | +| `iconColor` | 确认框标题图标颜色,默认与 `titleColor` 相同。 | `TextProps['type'] \| 'primary'` | 同 `titleColor` | +| `triggerComponent` | 用作触发器、点击后打开对话框的组件。 | `ComponentType<TriggerProp>` | `Button` | +| `triggerProps` | 传给触发器组件的属性。 | `TriggerProp` | - | +| `triggerEvent` | 触发器上用于打开对话框的事件名(如 `onClick`、`onChange`)。 | `keyof TriggerProp` | `'onClick'` | +| `children` | 触发器的自定义内容。 | `ReactNode` | - | +| `onBeforeOpen` | 打开前的回调;若抛错或 reject,对话框不会打开。 | `() => Promise<unknown> \| unknown` | - | +| `onOk` | 点击确认按钮的回调,可为异步。 | `(...args) => unknown \| Promise<unknown>` | - | +| `afterOk` | `onOk` 成功 resolve 后的回调;若 `onOk` 失败则不执行。 | `(data?) => void` | - | + +### Ref —— `ConfirmActionRef` + +| 方法 | 签名 | 说明 | +| --------- | ------------------------------------------------------------- | ---------------------------- | +| `show` | `(props?: Parameters<ModalFunc>[0]) => ReturnType<ModalFunc>` | 以命令式方式打开确认对话框。 | +| `update` | `(props?: Parameters<ModalFunc>[0]) => ReturnType<ModalFunc>` | 更新已打开对话框的属性。 | +| `destroy` | `() => void` | 关闭并销毁对话框。 | diff --git a/.storybook/stories/components/ConfirmAction/index.stories.tsx b/.storybook/stories/components/ConfirmAction/index.stories.tsx index 079a8d5..28417db 100644 --- a/.storybook/stories/components/ConfirmAction/index.stories.tsx +++ b/.storybook/stories/components/ConfirmAction/index.stories.tsx @@ -6,6 +6,8 @@ import type { LinkProps } from 'antd/es/typography/Link'; import type { ConfirmActionProps } from '../../../../src/components/ConfirmAction'; import ConfirmAction from '../../../../src/components/ConfirmAction'; import storyI18n, { storyT } from '../../../locales'; +import apiDocEN from './api-doc.en-US.md?raw'; +import apiDocCN from './api-doc.zh-CN.md?raw'; import introduceEN from './introduce.en-US.md?raw'; import introduceCN from './introduce.zh-CN.md?raw'; @@ -20,7 +22,7 @@ const meta: Meta<ConfirmActionStoryArgs> = { parameters: { docs: { description: { - component: storyI18n.language === 'zh-CN' ? introduceCN : introduceEN, + component: storyI18n.language === 'zh-CN' ? introduceCN + apiDocCN : introduceEN + apiDocEN, }, }, }, diff --git a/.storybook/stories/components/ConfirmAction/introduce.en-US.md b/.storybook/stories/components/ConfirmAction/introduce.en-US.md index 63cd9dc..679f360 100644 --- a/.storybook/stories/components/ConfirmAction/introduce.en-US.md +++ b/.storybook/stories/components/ConfirmAction/introduce.en-US.md @@ -1 +1,21 @@ Wraps a trigger component and opens a confirm-style modal before executing the action. Can switch between Button, Switch, and Link trigger types. + +## When to use + +Use `ConfirmAction` whenever a user action is destructive, irreversible, or worth a second thought — deleting a record, resetting configuration, submitting a paid order, etc. It binds the confirm dialog to the trigger so the action only runs after the user explicitly confirms. + +## Key features + +- **Trigger-agnostic** — wrap any component as the trigger (`Button`, `Switch`, `Link`, or your own) via `triggerComponent` + `triggerProps`, and choose which of its events opens the dialog with `triggerEvent`. +- **Two-way confirmation flow** — `onOk` runs the real action and can be async; `afterOk` fires only after `onOk` resolves successfully, which is the right place to do follow-up navigation or state refresh. +- **Danger mode** — `danger` (default `true` for `DeleteConfirmAction`) tints the title, icon, and confirm button red; `titleColor` / `contentColor` / `iconColor` let you fine-tune the palette and override the danger styling. +- **Programmatic open** — grab a ref and call `show(props?)` to open the dialog imperatively, e.g. from a table row handler. +- **Global defaults** — default title/content and other props can be supplied through `ConfigProvider`, so you don't repeat them on every instance. +- **Inherits antd** — everything from antd's `ModalFuncProps` (title, content, okText, cancelText, okButtonProps, …) is available. + +## Usage notes + +- `onBeforeOpen` returning (or rejecting with) a value prevents the dialog from opening; use it for permission or pre-condition checks. +- When `onOk` returns a Promise, the confirm button shows a loading state automatically until it settles; throwing inside `onOk` cancels the flow and `afterOk` will not run. +- `afterOk` is the success callback — do not put the actual action logic there, keep that in `onOk`. +- The dialog reads its default title/content from `ConfigProvider` (`defaultConfirmTitle` / `defaultConfirmContent`) unless overridden per instance. diff --git a/.storybook/stories/components/ConfirmAction/introduce.zh-CN.md b/.storybook/stories/components/ConfirmAction/introduce.zh-CN.md index 296e1ee..85f74a7 100644 --- a/.storybook/stories/components/ConfirmAction/introduce.zh-CN.md +++ b/.storybook/stories/components/ConfirmAction/introduce.zh-CN.md @@ -1 +1,21 @@ 对触发器组件进行封装,在真正执行操作前弹出确认对话框,可切换 Button、Switch、Link 三种触发方式。 + +## 适用场景 + +当用户操作具有破坏性、不可逆,或值得"再确认一次"时——删除记录、重置配置、提交付费订单等——都应使用 `ConfirmAction`。它将确认框绑定到触发器,只有在用户明确确认后才会真正执行操作。 + +## 核心特性 + +- **触发器无关** —— 通过 `triggerComponent` + `triggerProps` 将任意组件作为触发器(`Button`、`Switch`、`Link` 或自定义组件),并用 `triggerEvent` 选择触发对话框的事件。 +- **双向确认流程** —— `onOk` 执行真正的操作且可异步;`afterOk` 仅在 `onOk` 成功 resolve 后触发,适合做后续跳转或状态刷新。 +- **危险模式** —— `danger`(`DeleteConfirmAction` 默认为 `true`)会将标题、图标与确认按钮染红;`titleColor` / `contentColor` / `iconColor` 可微调配色并覆盖危险样式。 +- **命令式打开** —— 通过 ref 调用 `show(props?)` 以命令式打开对话框,例如来自表格行的处理函数。 +- **全局默认值** —— 默认标题/内容等可通过 `ConfigProvider` 统一注入,无需在每个实例重复。 +- **继承 antd** —— antd 的 `ModalFuncProps`(title、content、okText、cancelText、okButtonProps 等)均可使用。 + +## 使用注意 + +- `onBeforeOpen` 返回(或 reject)值会阻止对话框打开,可用于权限或前置条件校验。 +- 当 `onOk` 返回 Promise 时,确认按钮会自动进入加载态直到其完成;`onOk` 抛错会中断流程,`afterOk` 不会执行。 +- `afterOk` 是成功回调——不要把实际操作逻辑放在这里,逻辑应写在 `onOk` 中。 +- 对话框默认标题/内容取自 `ConfigProvider`(`defaultConfirmTitle` / `defaultConfirmContent`),除非在实例上覆盖。 diff --git a/.storybook/stories/components/ContextMenu/introduce.en-US.md b/.storybook/stories/components/ContextMenu/introduce.en-US.md index 4afee67..9a2d1fa 100644 --- a/.storybook/stories/components/ContextMenu/introduce.en-US.md +++ b/.storybook/stories/components/ContextMenu/introduce.en-US.md @@ -2,4 +2,20 @@ Renders a configurable context menu with support for shortcuts, separators, and ## When to use -You need a right-click (or other trigger) menu on an element, with keyboard shortcuts, separators and multi-level submenus. +You need a right-click (or other trigger) menu on an element, with keyboard shortcuts, separators and multi-level submenus. Typical cases: a grid/canvas context menu, a row action menu in a table, or any "secondary actions" surface that should appear on demand without cluttering the layout. + +## Key features + +- **Multiple triggers** — open on `contextMenu` (default), `click`, `doubleClick`, or `hover` via the `trigger` prop; combine several if needed. +- **Declarative items** — describe the whole menu with the `items` array (`ContextMenuItem` / `ContextMenuSeparator` / `ContextMenuSubmenu`), including icons, shortcuts, and labels. +- **Shortcuts** — each item can declare a `shortcutKey` matcher so the action fires from the keyboard even when the menu is closed. +- **Nested submenus & separators** — build hierarchy with `ContextMenuSubmenu` and visually group with `ContextMenuSeparator`. +- **Imperative control** — use a ref to call `show(event, options?)` and open the menu at an arbitrary position, or `hideAll()` to close every open menu. +- **Theming** — integrates with antd `ConfigProvider` for prefix/class customization via `prefixCls`. + +## Usage notes + +- The menu is rendered through `react-contexify`; its `MenuProps` (except `renderItem`) are forwarded, so you can rely on that library's item model. +- `items` is required — an empty or `undefined` value yields no menu. +- `shortcutKey` works globally; avoid binding the same shortcut to multiple items to prevent ambiguous triggers. +- `trigger='hover'` pairs `onPointerEnter` with `onPointerLeave`; make sure the trigger element has enough hit area for a stable hover. diff --git a/.storybook/stories/components/ContextMenu/introduce.zh-CN.md b/.storybook/stories/components/ContextMenu/introduce.zh-CN.md index 6298bbf..01ae357 100644 --- a/.storybook/stories/components/ContextMenu/introduce.zh-CN.md +++ b/.storybook/stories/components/ContextMenu/introduce.zh-CN.md @@ -2,4 +2,20 @@ ## 适用场景 -你需要在元素上提供右键(或其它触发方式)菜单,并支持快捷键、分隔线与多级子菜单。 +你需要在元素上提供右键(或其它触发方式)菜单,并支持快捷键、分隔线与多级子菜单。典型场景:画布/网格的右键菜单、表格行的操作菜单,或任何"按需出现、不占用布局"的次级操作面板。 + +## 核心特性 + +- **多种触发方式** —— 通过 `trigger` 属性可选择 `contextMenu`(默认)、`click`、`doubleClick` 或 `hover` 打开,也可组合多种触发。 +- **声明式菜单项** —— 使用 `items` 数组(`ContextMenuItem` / `ContextMenuSeparator` / `ContextMenuSubmenu`)描述整个菜单,包含图标、快捷键与标签。 +- **快捷键** —— 每个菜单项可声明 `shortcutKey` 匹配器,即使菜单未打开也能通过键盘触发对应操作。 +- **嵌套子菜单与分隔线** —— 通过 `ContextMenuSubmenu` 构建层级,用 `ContextMenuSeparator` 做视觉分组。 +- **命令式控制** —— 通过 ref 调用 `show(event, options?)` 在任意位置打开菜单,或 `hideAll()` 关闭所有已打开的菜单。 +- **主题定制** —— 借助 antd `ConfigProvider`,可通过 `prefixCls` 自定义类名前缀。 + +## 使用注意 + +- 菜单基于 `react-contexify` 渲染,其 `MenuProps`(除 `renderItem` 外)均被透传,可直接沿用该库的菜单项模型。 +- `items` 为必填——为空或 `undefined` 时不会渲染任何菜单。 +- `shortcutKey` 为全局生效;避免将同一快捷键绑定到多个菜单项,以免造成触发歧义。 +- 当 `trigger='hover'` 时,会同时绑定 `onPointerEnter` 与 `onPointerLeave`;请确保触发器元素有足够的命中区域以获得稳定的悬停体验。 From e76aadd44235d69cb1cf02ec415b0698bf7bf5d8 Mon Sep 17 00:00:00 2001 From: Fengbao Li <shijistar@gmail.com> Date: Wed, 5 Aug 2026 00:20:57 +0800 Subject: [PATCH 12/39] docs(storybook): enrich all 16 component docs (introduce + api-doc) - Add structured bilingual introduce (Overview / When to use / Key features / Usage notes) for all 16 components - Add api-doc.en-US/zh-CN for the 7 components that lacked one (ConfigProvider, DeleteConfirmAction, EditableText, EllipsisTypography, ModalAction, PulseAnimation, VirtualTextViewer), wiring imports + description in each index.stories.tsx - api-doc rule: list component-specific props fully + core/inherited props (not exhaustive) - All content derived from component source (Props + behavior) - prettier-formatted; build-storybook verified --- .../ColumnSetting/introduce.en-US.md | 18 ++++++++++++-- .../ColumnSetting/introduce.zh-CN.md | 18 ++++++++++++-- .../ConfigProvider/api-doc.en-US.md | 14 +++++++++++ .../ConfigProvider/api-doc.zh-CN.md | 14 +++++++++++ .../ConfigProvider/index.stories.tsx | 4 +++- .../ConfigProvider/introduce.en-US.md | 24 ++++++++++++++++++- .../ConfigProvider/introduce.zh-CN.md | 24 ++++++++++++++++++- .../DeleteConfirmAction/api-doc.en-US.md | 21 ++++++++++++++++ .../DeleteConfirmAction/api-doc.zh-CN.md | 21 ++++++++++++++++ .../DeleteConfirmAction/index.stories.tsx | 4 +++- .../DeleteConfirmAction/introduce.en-US.md | 19 ++++++++++++++- .../DeleteConfirmAction/introduce.zh-CN.md | 19 ++++++++++++++- .../components/EditableText/api-doc.en-US.md | 17 +++++++++++++ .../components/EditableText/api-doc.zh-CN.md | 17 +++++++++++++ .../components/EditableText/index.stories.tsx | 4 +++- .../EditableText/introduce.en-US.md | 21 ++++++++++++++-- .../EditableText/introduce.zh-CN.md | 20 +++++++++++++++- .../EllipsisTypography/api-doc.en-US.md | 14 +++++++++++ .../EllipsisTypography/api-doc.zh-CN.md | 14 +++++++++++ .../EllipsisTypography/index.stories.tsx | 4 +++- .../EllipsisTypography/introduce.en-US.md | 20 ++++++++++++++-- .../EllipsisTypography/introduce.zh-CN.md | 19 ++++++++++++++- .../components/FloatDrawer/introduce.en-US.md | 18 ++++++++++++-- .../components/FloatDrawer/introduce.zh-CN.md | 18 ++++++++++++-- .../FormItemControl/introduce.en-US.md | 16 +++++++++++-- .../FormItemControl/introduce.zh-CN.md | 16 +++++++++++-- .../components/Iconfont/introduce.en-US.md | 19 ++++++++++++++- .../components/Iconfont/introduce.zh-CN.md | 19 ++++++++++++++- .../components/Loading/introduce.en-US.md | 19 +++++++++++---- .../components/Loading/introduce.zh-CN.md | 19 +++++++++++---- .../components/ModalAction/api-doc.en-US.md | 16 +++++++++++++ .../components/ModalAction/api-doc.zh-CN.md | 16 +++++++++++++ .../components/ModalAction/index.stories.tsx | 4 +++- .../components/ModalAction/introduce.en-US.md | 20 +++++++++++++++- .../components/ModalAction/introduce.zh-CN.md | 20 +++++++++++++++- .../OverflowTags/introduce.en-US.md | 18 ++++++++++++-- .../OverflowTags/introduce.zh-CN.md | 18 ++++++++++++-- .../PulseAnimation/api-doc.en-US.md | 15 ++++++++++++ .../PulseAnimation/api-doc.zh-CN.md | 15 ++++++++++++ .../PulseAnimation/index.stories.tsx | 4 +++- .../PulseAnimation/introduce.en-US.md | 21 +++++++++++++--- .../PulseAnimation/introduce.zh-CN.md | 20 ++++++++++++++-- .../VirtualTextViewer/api-doc.en-US.md | 17 +++++++++++++ .../VirtualTextViewer/api-doc.zh-CN.md | 17 +++++++++++++ .../VirtualTextViewer/index.stories.tsx | 4 +++- .../VirtualTextViewer/introduce.en-US.md | 19 ++++++++++++++- .../VirtualTextViewer/introduce.zh-CN.md | 19 ++++++++++++++- 47 files changed, 703 insertions(+), 54 deletions(-) create mode 100644 .storybook/stories/components/ConfigProvider/api-doc.en-US.md create mode 100644 .storybook/stories/components/ConfigProvider/api-doc.zh-CN.md create mode 100644 .storybook/stories/components/DeleteConfirmAction/api-doc.en-US.md create mode 100644 .storybook/stories/components/DeleteConfirmAction/api-doc.zh-CN.md create mode 100644 .storybook/stories/components/EditableText/api-doc.en-US.md create mode 100644 .storybook/stories/components/EditableText/api-doc.zh-CN.md create mode 100644 .storybook/stories/components/EllipsisTypography/api-doc.en-US.md create mode 100644 .storybook/stories/components/EllipsisTypography/api-doc.zh-CN.md create mode 100644 .storybook/stories/components/ModalAction/api-doc.en-US.md create mode 100644 .storybook/stories/components/ModalAction/api-doc.zh-CN.md create mode 100644 .storybook/stories/components/PulseAnimation/api-doc.en-US.md create mode 100644 .storybook/stories/components/PulseAnimation/api-doc.zh-CN.md create mode 100644 .storybook/stories/components/VirtualTextViewer/api-doc.en-US.md create mode 100644 .storybook/stories/components/VirtualTextViewer/api-doc.zh-CN.md diff --git a/.storybook/stories/components/ColumnSetting/introduce.en-US.md b/.storybook/stories/components/ColumnSetting/introduce.en-US.md index abb33b2..b4207a6 100644 --- a/.storybook/stories/components/ColumnSetting/introduce.en-US.md +++ b/.storybook/stories/components/ColumnSetting/introduce.en-US.md @@ -1,5 +1,19 @@ -Helps users manage table columns such as visibility and ordering, and can persist preferences with a storage key. +Helps users manage table columns — visibility, ordering, and a "check all" / reset flow — and can persist preferences with a storage key. It renders a dropdown triggered by a button. ## When to use -You have an Ant Design `Table` and want to let users toggle column visibility / reorder columns, optionally remembering their choice across sessions. +Use `ColumnSetting` wherever a table exposes column customization: data grids, admin lists, report builders — anywhere users benefit from hiding, reordering, or re-showing columns and having that choice remembered. + +## Key features + +- **Visibility & order** — `columns` carries each column's display state, order, and `disabled` flag; the component emits the updated array via `onChange`. +- **Persistence** — `storageKey` saves the selection to `localStorage` so preferences survive reloads. +- **Custom titles** — `renderColumnTitle` overrides how each column header is labeled in the panel. +- **Composable triggers** — `triggerProps` / `dropdownProps` / `popupProps` / `checkAllProps` / `resetProps` customize the button, dropdown, popup, and action buttons. +- **Inherits Ant Design** — column items follow `ColumnType`, so your existing column definitions drop in directly. + +## Usage notes + +- At least one column always stays visible (the last visible checkbox is disabled) to prevent hiding everything. +- When `storageKey` is set, the selection is read on mount and written on every `onChange`. +- Mark a column `disabled` to lock it visible while still listing it. diff --git a/.storybook/stories/components/ColumnSetting/introduce.zh-CN.md b/.storybook/stories/components/ColumnSetting/introduce.zh-CN.md index f8382d2..97b4c43 100644 --- a/.storybook/stories/components/ColumnSetting/introduce.zh-CN.md +++ b/.storybook/stories/components/ColumnSetting/introduce.zh-CN.md @@ -1,5 +1,19 @@ -用于管理表格列的显示与顺序,并可通过 `storageKey` 持久化用户偏好。 +帮助用户管理表格列——显示状态、排序,以及"全选/重置"操作——并可通过 storage key 持久化偏好。它渲染一个由按钮触发的下拉面板。 ## 适用场景 -你使用 Ant Design 的 `Table`,希望让用户开关列显示 / 调整列顺序,并可选地在会话间记住其选择。 +当表格需要提供列自定义能力时:数据网格、管理后台列表、报表构建器等——只要用户能从中受益于隐藏、重排或重新显示列,并希望该选择被记住,就使用 `ColumnSetting`。 + +## 核心特性 + +- **可见性与顺序** —— `columns` 携带每列的显示状态、顺序与 `disabled` 标记;组件通过 `onChange` 输出更新后的数组。 +- **持久化** —— `storageKey` 将选择保存到 `localStorage`,使偏好在刷新后保留。 +- **自定义标题** —— `renderColumnTitle` 覆盖面板中每列的标签渲染。 +- **可组合触发器** —— `triggerProps` / `dropdownProps` / `popupProps` / `checkAllProps` / `resetProps` 分别定制按钮、下拉、弹出层与操作按钮。 +- **继承 Ant Design** —— 列项遵循 `ColumnType`,你现有的列定义可直接接入。 + +## 使用注意 + +- 至少保留一列可见(最后一个可见复选框会被禁用),以防止全部隐藏。 +- 设置 `storageKey` 时,选择会在挂载时读取、并在每次 `onChange` 时写入。 +- 将某列标记为 `disabled` 可将其锁定为始终可见,同时仍列在面板中。 diff --git a/.storybook/stories/components/ConfigProvider/api-doc.en-US.md b/.storybook/stories/components/ConfigProvider/api-doc.en-US.md new file mode 100644 index 0000000..3e21ae4 --- /dev/null +++ b/.storybook/stories/components/ConfigProvider/api-doc.en-US.md @@ -0,0 +1,14 @@ +## API + +`ConfigProvider` extends the ReactEasy context props (`ReactEasyContextProps`), so global settings like `lang`, default confirm/delete titles and content, and component defaults are accepted. The props below are specific to the provider wrapper. + +| Prop | Description | Type | Default | +| ----------- | ----------------------------------------------------------------------------------- | --------------------------- | ------- | +| `children` | Child elements wrapped by the provider. | `ReactNode` | - | +| `lang` | Active UI language (e.g. `en-US`, `zh-CN`). Changing it switches i18n at runtime. | `string` | `en-US` | +| `locales` | Custom i18n resource bundle; overrides an existing language or registers a new one. | `Partial<typeof localesEn>` | - | +| `prefixCls` | Custom CSS class prefix for the provider root. | `string` | - | +| `className` | Class name of the root element. | `string` | - | +| `style` | Inline style of the root element. | `CSSProperties` | - | + +> Inherits `ReactEasyContextProps` (e.g. `defaultConfirmTitle`, `defaultConfirmContent`, `ConfirmAction`, `DeletionConfirmAction`, …) — see the context definition for the full set. diff --git a/.storybook/stories/components/ConfigProvider/api-doc.zh-CN.md b/.storybook/stories/components/ConfigProvider/api-doc.zh-CN.md new file mode 100644 index 0000000..ea17b41 --- /dev/null +++ b/.storybook/stories/components/ConfigProvider/api-doc.zh-CN.md @@ -0,0 +1,14 @@ +## API + +`ConfigProvider` 扩展自 ReactEasy 上下文属性(`ReactEasyContextProps`),因此接受语言、默认确认/删除确认标题与内容、组件默认值等全局设置。以下属性为 Provider 外层包装所特有。 + +| 属性 | 说明 | 类型 | 默认值 | +| ----------- | ------------------------------------------------------------- | --------------------------- | ------- | +| `children` | 被 Provider 包裹的子元素。 | `ReactNode` | - | +| `lang` | 当前界面语言(如 `en-US`、`zh-CN`)。修改即运行时切换国际化。 | `string` | `en-US` | +| `locales` | 自定义本地化资源包;覆盖已有语言或注册新语言。 | `Partial<typeof localesEn>` | - | +| `prefixCls` | 根元素的自定义 CSS 类前缀。 | `string` | - | +| `className` | 根元素的类名。 | `string` | - | +| `style` | 根元素的内联样式。 | `CSSProperties` | - | + +> 继承 `ReactEasyContextProps`(如 `defaultConfirmTitle`、`defaultConfirmContent`、`ConfirmAction`、`DeletionConfirmAction` 等)——完整集合见上下文定义。 diff --git a/.storybook/stories/components/ConfigProvider/index.stories.tsx b/.storybook/stories/components/ConfigProvider/index.stories.tsx index a43c563..6b60eaa 100644 --- a/.storybook/stories/components/ConfigProvider/index.stories.tsx +++ b/.storybook/stories/components/ConfigProvider/index.stories.tsx @@ -3,6 +3,8 @@ import { App as AntdApp, Space, Typography } from 'antd'; import ConfigProvider, { type ConfigProviderProps } from '../../../../src/components/ConfigProvider'; import ConfirmAction from '../../../../src/components/ConfirmAction'; import storyI18n, { storyT, useStoryT } from '../../../locales'; +import apiDocEN from './api-doc.en-US.md?raw'; +import apiDocCN from './api-doc.zh-CN.md?raw'; import introduceEN from './introduce.en-US.md?raw'; import introduceCN from './introduce.zh-CN.md?raw'; @@ -14,7 +16,7 @@ const meta: Meta<ConfigProviderStoryArgs> = { parameters: { docs: { description: { - component: storyI18n.language === 'zh-CN' ? introduceCN : introduceEN, + component: storyI18n.language === 'zh-CN' ? introduceCN + apiDocCN : introduceEN + apiDocEN, }, }, }, diff --git a/.storybook/stories/components/ConfigProvider/introduce.en-US.md b/.storybook/stories/components/ConfigProvider/introduce.en-US.md index 545ddde..5a66994 100644 --- a/.storybook/stories/components/ConfigProvider/introduce.en-US.md +++ b/.storybook/stories/components/ConfigProvider/introduce.en-US.md @@ -1 +1,23 @@ -Provides shared configuration such as locale-aware copy and default action text for components in ReactEasy. +Provides global configuration for ReactEasy, including language, default confirm/delete dialog titles and content, and CSS prefix customization. Wrap your app (or a subtree) with it once. + +## When to use + +Use `ConfigProvider` at the root of your application (or around any subtree that should share settings) whenever you need to: + +- Switch or override the UI language for ReactEasy components. +- Set default titles/content for the global confirm and delete-confirm dialogs. +- Register custom i18n resource bundles. +- Customize the component CSS class prefix. + +## Key features + +- **Language control** — set `lang` to switch the active locale; child components re-render automatically. +- **Global dialog defaults** — supply default `title`/`content` (and more) for `ConfirmAction` and `DeleteConfirmAction` so you don't repeat them per instance. +- **Custom i18n** — pass `locales` to override an existing language pack or register a brand-new one. +- **Prefix customization** — `prefixCls` retunes the generated CSS class namespace. + +## Usage notes + +- It must wrap the parts of the tree that should inherit the configuration; nesting multiple providers creates scoped overrides. +- `lang` changes i18n at runtime, but you should keep a single source of truth to avoid flicker. +- `locales` merges with (not replaces) the built-in bundles; omit a language to keep the default. diff --git a/.storybook/stories/components/ConfigProvider/introduce.zh-CN.md b/.storybook/stories/components/ConfigProvider/introduce.zh-CN.md index 5d66f36..567d3ec 100644 --- a/.storybook/stories/components/ConfigProvider/introduce.zh-CN.md +++ b/.storybook/stories/components/ConfigProvider/introduce.zh-CN.md @@ -1 +1,23 @@ -为ReactEasy提供共享配置,例如本地化文案和确认类组件的默认文本。 +为 ReactEasy 提供全局配置,包括语言、默认的确认/删除确认弹框标题与内容,以及 CSS 类名前缀定制。在应用根节点(或任意子树)上包裹一次即可。 + +## 适用场景 + +当需要满足以下任一需求时,在应用根部(或需要共享配置的子树)使用 `ConfigProvider`: + +- 切换或覆盖 ReactEasy 组件的界面语言。 +- 为全局的确认框与删除确认框设置默认标题/内容。 +- 注册自定义的本地化资源包。 +- 自定义组件的 CSS 类名前缀。 + +## 核心特性 + +- **语言控制** —— 通过 `lang` 切换当前语言,子组件会自动重渲染。 +- **全局弹框默认值** —— 为 `ConfirmAction` 与 `DeleteConfirmAction` 提供默认的 `title`/`content` 等,避免在每个实例上重复设置。 +- **自定义国际化** —— 通过 `locales` 覆盖已有语言包或注册全新的语言。 +- **前缀定制** —— `prefixCls` 可调整生成的 CSS 类命名空间。 + +## 使用注意 + +- 它必须包裹需要继承配置的那部分组件树;嵌套多个 Provider 会产生作用域覆盖。 +- `lang` 会在运行时切换语言,但建议保持单一数据源以避免闪烁。 +- `locales` 是与内置语言包**合并**而非替换;省略某语言即保留默认。 diff --git a/.storybook/stories/components/DeleteConfirmAction/api-doc.en-US.md b/.storybook/stories/components/DeleteConfirmAction/api-doc.en-US.md new file mode 100644 index 0000000..ffde383 --- /dev/null +++ b/.storybook/stories/components/DeleteConfirmAction/api-doc.en-US.md @@ -0,0 +1,21 @@ +## API + +`DeleteConfirmAction` is a pre-configured `ConfirmAction` with `confirmType: 'delete'` and `danger: true`. It does **not** add new props — it reuses the full `ConfirmActionProps` and exposes static trigger builders. + +| Prop | Description | Type | Default | +| ------------------ | ------------------------------------------------------------------------ | ------------------------------------------ | ----------- | +| `triggerComponent` | Component used as the trigger that opens the dialog. | `ComponentType<TriggerProp>` | `Button` | +| `triggerProps` | Props passed to the trigger component. | `TriggerProp` | - | +| `triggerEvent` | Event on the trigger that opens the dialog (e.g. `onClick`, `onChange`). | `keyof TriggerProp` | `'onClick'` | +| `danger` | Renders in red danger mode (default `true` here). | `boolean` | `true` | +| `onOk` | Callback when confirm is clicked; can be async. | `(...args) => unknown \| Promise<unknown>` | - | +| `afterOk` | Callback after `onOk` resolves; skipped on failure. | `(data?) => void` | - | +| `onBeforeOpen` | Runs before opening; throwing/rejecting prevents the dialog. | `() => Promise<unknown> \| unknown` | - | + +### Static builders + +- `DeleteConfirmAction.Button` — trigger is an antd `Button` (`onClick`). +- `DeleteConfirmAction.Switch` — trigger is an antd `Switch` (`onChange`). +- `DeleteConfirmAction.Link` — trigger is a `Typography.Link` (`onClick`). + +> All other `ConfirmActionProps` (title, content, okText, iconColor, …) and the ref methods `show` / `update` / `destroy` are available — see `ConfirmAction`. diff --git a/.storybook/stories/components/DeleteConfirmAction/api-doc.zh-CN.md b/.storybook/stories/components/DeleteConfirmAction/api-doc.zh-CN.md new file mode 100644 index 0000000..9c9762f --- /dev/null +++ b/.storybook/stories/components/DeleteConfirmAction/api-doc.zh-CN.md @@ -0,0 +1,21 @@ +## API + +`DeleteConfirmAction` 是预设了 `confirmType: 'delete'` 与 `danger: true` 的 `ConfirmAction`,**不新增属性**,复用完整的 `ConfirmActionProps` 并暴露静态触发器构造器。 + +| 属性 | 说明 | 类型 | 默认值 | +| ------------------ | ------------------------------------------------------ | ------------------------------------------ | ----------- | +| `triggerComponent` | 用作触发器、点击后打开对话框的组件。 | `ComponentType<TriggerProp>` | `Button` | +| `triggerProps` | 传给触发器组件的属性。 | `TriggerProp` | - | +| `triggerEvent` | 触发器上触发对话框的事件(如 `onClick`、`onChange`)。 | `keyof TriggerProp` | `'onClick'` | +| `danger` | 以红色危险模式渲染(此处默认为 `true`)。 | `boolean` | `true` | +| `onOk` | 点击确认时的回调,可异步。 | `(...args) => unknown \| Promise<unknown>` | - | +| `afterOk` | `onOk` 成功后触发;失败时不执行。 | `(data?) => void` | - | +| `onBeforeOpen` | 打开前执行;抛错或 reject 会阻止对话框。 | `() => Promise<unknown> \| unknown` | - | + +### 静态构造器 + +- `DeleteConfirmAction.Button` —— 触发器为 antd `Button`(`onClick`)。 +- `DeleteConfirmAction.Switch` —— 触发器为 antd `Switch`(`onChange`)。 +- `DeleteConfirmAction.Link` —— 触发器为 `Typography.Link`(`onClick`)。 + +> 其余 `ConfirmActionProps`(title、content、okText、iconColor 等)以及 ref 方法 `show` / `update` / `destroy` 均可用——详见 `ConfirmAction`。 diff --git a/.storybook/stories/components/DeleteConfirmAction/index.stories.tsx b/.storybook/stories/components/DeleteConfirmAction/index.stories.tsx index 868b735..f4645e1 100644 --- a/.storybook/stories/components/DeleteConfirmAction/index.stories.tsx +++ b/.storybook/stories/components/DeleteConfirmAction/index.stories.tsx @@ -6,6 +6,8 @@ import type { LinkProps } from 'antd/es/typography/Link'; import type { ConfirmActionProps } from '../../../../src/components/ConfirmAction'; import DeleteConfirmAction from '../../../../src/components/DeleteConfirmAction'; import storyI18n, { storyT } from '../../../locales'; +import apiDocEN from './api-doc.en-US.md?raw'; +import apiDocCN from './api-doc.zh-CN.md?raw'; import introduceEN from './introduce.en-US.md?raw'; import introduceCN from './introduce.zh-CN.md?raw'; @@ -20,7 +22,7 @@ const meta: Meta<DeleteConfirmActionStoryArgs> = { parameters: { docs: { description: { - component: storyI18n.language === 'zh-CN' ? introduceCN : introduceEN, + component: storyI18n.language === 'zh-CN' ? introduceCN + apiDocCN : introduceEN + apiDocEN, }, }, }, diff --git a/.storybook/stories/components/DeleteConfirmAction/introduce.en-US.md b/.storybook/stories/components/DeleteConfirmAction/introduce.en-US.md index d802744..48ad417 100644 --- a/.storybook/stories/components/DeleteConfirmAction/introduce.en-US.md +++ b/.storybook/stories/components/DeleteConfirmAction/introduce.en-US.md @@ -1 +1,18 @@ -Wraps a trigger component and opens a delete confirmation modal before executing the action. Can switch between Button, Switch, and Link trigger types. +A pre-configured variant of `ConfirmAction` for deletion. It opens a red danger-mode confirm dialog before the action runs, and exposes `Button` / `Switch` / `Link` trigger builders. + +## When to use + +Use `DeleteConfirmAction` (instead of plain `ConfirmAction`) for any destructive, hard-to-undo deletion — removing a record, clearing a dataset, unlinking resources, etc. The danger styling signals risk and reduces accidental clicks. + +## Key features + +- **Danger by default** — `danger` is `true` and the icon is a delete glyph, so the dialog reads as destructive without extra config. +- **Same API as ConfirmAction** — everything from `ConfirmActionProps` applies: `triggerComponent`, `triggerProps`, `triggerEvent`, `onOk`, `afterOk`, `onBeforeOpen`, ref `show`/`update`/`destroy`. +- **Convenience triggers** — `DeleteConfirmAction.Button` / `.Switch` / `.Link` are ready-made trigger variants. +- **Global defaults** — default title/content come from `ConfigProvider`'s deletion settings. + +## Usage notes + +- Because it is just `ConfirmAction` with `confirmType: 'delete'`, you can override `titleColor` / `iconColor` / `okButtonProps.type` per instance if you need a non-default look. +- Put the real deletion logic in `onOk`; use `afterOk` for post-success UI updates. +- Reusing the `Button`/`Switch`/`Link` builders keeps trigger markup consistent across your app. diff --git a/.storybook/stories/components/DeleteConfirmAction/introduce.zh-CN.md b/.storybook/stories/components/DeleteConfirmAction/introduce.zh-CN.md index 97e7647..1445ef2 100644 --- a/.storybook/stories/components/DeleteConfirmAction/introduce.zh-CN.md +++ b/.storybook/stories/components/DeleteConfirmAction/introduce.zh-CN.md @@ -1 +1,18 @@ -对触发器组件进行封装,在真正执行操作前弹出删除确认框,可切换 Button、Switch、Link 三种触发方式。 +`ConfirmAction` 的预设变体,专门用于删除场景。它在操作执行前弹出红色危险模式确认框,并提供 `Button` / `Switch` / `Link` 触发器构造器。 + +## 适用场景 + +当操作具有破坏性、难以撤销(如删除记录、清空数据集、解除资源关联等)时,使用 `DeleteConfirmAction`(而非普通 `ConfirmAction`)。危险样式的视觉提示可降低误点击风险。 + +## 核心特性 + +- **默认危险模式** —— `danger` 为 `true` 且图标为删除字形,无需额外配置即可表达"破坏性"。 +- **与 ConfirmAction 同款 API** —— `ConfirmActionProps` 的全部能力均可用:`triggerComponent`、`triggerProps`、`triggerEvent`、`onOk`、`afterOk`、`onBeforeOpen`,以及 ref 的 `show`/`update`/`destroy`。 +- **便捷触发器** —— `DeleteConfirmAction.Button` / `.Switch` / `.Link` 是开箱即用的触发器变体。 +- **全局默认值** —— 默认标题/内容取自 `ConfigProvider` 的删除确认配置。 + +## 使用注意 + +- 它本质就是 `confirmType: 'delete'` 的 `ConfirmAction`,如需非默认外观,可在实例上覆盖 `titleColor` / `iconColor` / `okButtonProps.type`。 +- 真正的删除逻辑放在 `onOk` 中;`afterOk` 用于成功后的 UI 更新。 +- 复用 `Button`/`Switch`/`Link` 构造器可保持触发元素在全应用的一致性。 diff --git a/.storybook/stories/components/EditableText/api-doc.en-US.md b/.storybook/stories/components/EditableText/api-doc.en-US.md new file mode 100644 index 0000000..1caf23a --- /dev/null +++ b/.storybook/stories/components/EditableText/api-doc.en-US.md @@ -0,0 +1,17 @@ +## API + +`EditableTextProps<V, TT, IT>` extends `EditableFormProps<V, IT>` (which carries `value`, `required`, `textComp`, `onOk`, `afterOk`, and the render hooks). Key props are listed below. + +| Prop | Description | Type | Default | +| ----------------- | ------------------------------------------------------------------------------------------------- | --------------------------------------------------- | -------- | +| `value` | Current text value displayed and edited by the component. | `V` | - | +| `editable` | Whether the edit affordance is shown. Set `false` to disable editing. | `boolean` | `true` | +| `editing` | Force the component into editing mode when controlled externally. | `boolean` | `false` | +| `required` | Marks the editor as required when confirming changes. | `boolean` | `false` | +| `textComp` | Typography component type used to render the read-only state (`Text`/`Paragraph`/`Title`/`Link`). | `'Text' \| 'Paragraph' \| 'Title' \| 'Link'` | `'Text'` | +| `displayText` | Custom read-only text, replacing the `value` display. | `boolean \| ReactNode \| ((value: V) => ReactNode)` | `true` | +| `block` | Display as block-level (full width) in view/edit modes. | `boolean \| { view?: boolean; editing?: boolean }` | `false` | +| `onChange` | Called with the new value after a successful `onOk` save. | `(value: V \| undefined) => void` | - | +| `onEditingChange` | Called when the editing state changes. | `(editing: boolean) => void` | - | + +> `renderView` / `renderEdit` / `renderInput` (from `EditableFormProps`) customize each phase; `className` / `style` / `classNames` style the surface. diff --git a/.storybook/stories/components/EditableText/api-doc.zh-CN.md b/.storybook/stories/components/EditableText/api-doc.zh-CN.md new file mode 100644 index 0000000..8e0adeb --- /dev/null +++ b/.storybook/stories/components/EditableText/api-doc.zh-CN.md @@ -0,0 +1,17 @@ +## API + +`EditableTextProps<V, TT, IT>` 扩展自 `EditableFormProps<V, IT>`(后者携带 `value`、`required`、`textComp`、`onOk`、`afterOk` 及渲染钩子)。主要属性如下。 + +| 属性 | 说明 | 类型 | 默认值 | +| ----------------- | --------------------------------------------------------------------- | --------------------------------------------------- | -------- | +| `value` | 组件当前展示和编辑的文本值。 | `V` | - | +| `editable` | 是否显示编辑入口。设为 `false` 可禁用编辑。 | `boolean` | `true` | +| `editing` | 外部受控地将组件切换到编辑状态。 | `boolean` | `false` | +| `required` | 在确认修改时将输入视为必填。 | `boolean` | `false` | +| `textComp` | 只读文本态所使用的排版组件类型(`Text`/`Paragraph`/`Title`/`Link`)。 | `'Text' \| 'Paragraph' \| 'Title' \| 'Link'` | `'Text'` | +| `displayText` | 自定义只读文本,替代 `value` 的展示。 | `boolean \| ReactNode \| ((value: V) => ReactNode)` | `true` | +| `block` | 只读/编辑态是否显示为块级(整行宽度)。 | `boolean \| { view?: boolean; editing?: boolean }` | `false` | +| `onChange` | 在 `onOk` 保存成功后,以新值调用。 | `(value: V \| undefined) => void` | - | +| `onEditingChange` | 编辑状态改变时调用。 | `(editing: boolean) => void` | - | + +> `renderView` / `renderEdit` / `renderInput`(来自 `EditableFormProps`)可定制各阶段;`className` / `style` / `classNames` 用于样式定制。 diff --git a/.storybook/stories/components/EditableText/index.stories.tsx b/.storybook/stories/components/EditableText/index.stories.tsx index 524752d..84fb7be 100644 --- a/.storybook/stories/components/EditableText/index.stories.tsx +++ b/.storybook/stories/components/EditableText/index.stories.tsx @@ -2,6 +2,8 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { fn } from 'storybook/test'; import EditableText, { type EditableTextProps } from '../../../../src/components/EditableText'; import storyI18n, { storyT } from '../../../locales'; +import apiDocEN from './api-doc.en-US.md?raw'; +import apiDocCN from './api-doc.zh-CN.md?raw'; import introduceEN from './introduce.en-US.md?raw'; import introduceCN from './introduce.zh-CN.md?raw'; @@ -11,7 +13,7 @@ const meta: Meta<EditableTextProps> = { parameters: { docs: { description: { - component: storyI18n.language === 'zh-CN' ? introduceCN : introduceEN, + component: storyI18n.language === 'zh-CN' ? introduceCN + apiDocCN : introduceEN + apiDocEN, }, }, }, diff --git a/.storybook/stories/components/EditableText/introduce.en-US.md b/.storybook/stories/components/EditableText/introduce.en-US.md index f3239c7..4397b06 100644 --- a/.storybook/stories/components/EditableText/introduce.en-US.md +++ b/.storybook/stories/components/EditableText/introduce.en-US.md @@ -1,2 +1,19 @@ -Editable text component, providing a read-only display and an edit mode, supporting -inline text editing, with edit state, validation-related switches, and edit lifecycle callbacks. +An inline-editable text component built on Ant Design Typography. It shows content as read-only text and switches to an input/textarea editor on demand, supporting `Text`, `Paragraph`, `Title`, and `Link` renderers. + +## When to use + +Use `EditableText` when a piece of text should be both displayed and quickly edited in place — user profiles, inline labels, configurable captions, or any field where a full form feels heavy. + +## Key features + +- **Typography-native** — renders as `Text`/`Paragraph`/`Title`/`Link` in view mode, so styling and ellipsis behavior match Ant Design. +- **Inline editing** — toggles to an `Input`/`TextArea` editor without leaving the layout; `editable` controls whether the edit affordance shows. +- **Controlled & async save** — `value`/`onChange` for the data, `editing` to force edit mode, and `onOk`/`afterOk` for save flow; `required` and `textComp` tune validation and typography. +- **Custom renderers** — `displayText`/`children` override the read-only presentation, while `renderView`/`renderEdit`/`renderInput` (from `EditableFormProps`) customize each phase. +- **Block or inline** — the `block` prop chooses full-width block vs inline display in view/edit modes. + +## Usage notes + +- `editable={false}` hides the edit button entirely; drive `editing` from outside for programmatic control. +- Save failures should be handled inside `onOk`; throwing keeps the editor open. +- In view mode the text can still use Ant Design ellipsis config, but `children`/`displayText` overrides disable truncation. diff --git a/.storybook/stories/components/EditableText/introduce.zh-CN.md b/.storybook/stories/components/EditableText/introduce.zh-CN.md index eb7e442..6cfd424 100644 --- a/.storybook/stories/components/EditableText/introduce.zh-CN.md +++ b/.storybook/stories/components/EditableText/introduce.zh-CN.md @@ -1 +1,19 @@ -可编辑文本组件,提供只读显示和编辑模式,支持行内文本编辑,提供编辑态、校验相关开关和编辑生命周期回调。 +基于 Ant Design Typography 的内联可编辑文本组件。默认以只读文本展示,按需切换到输入框/文本域编辑器,支持 `Text`、`Paragraph`、`Title`、`Link` 多种渲染形态。 + +## 适用场景 + +当一段文本既需要展示、又需要就地快速编辑时(如用户资料、内联标签、可配置标题,或任何"用完整表单显得过重"的字段),使用 `EditableText`。 + +## 核心特性 + +- **原生 Typography** —— 只读态渲染为 `Text`/`Paragraph`/`Title`/`Link`,样式与省略行为与原生 Ant Design 一致。 +- **内联编辑** —— 无需离开布局即可切换为 `Input`/`TextArea` 编辑器;`editable` 控制是否展示编辑入口。 +- **受控与异步保存** —— 通过 `value`/`onChange` 管理数据,`editing` 强制进入编辑态,`onOk`/`afterOk` 处理保存流程;`required`、`textComp` 用于校验与排版微调。 +- **自定义渲染** —— `displayText`/`children` 覆盖只读展示,`renderView`/`renderEdit`/`renderInput`(来自 `EditableFormProps`)定制各阶段。 +- **块级或行内** —— `block` 属性选择只读/编辑态下的整行块级还是行内展示。 + +## 使用注意 + +- `editable={false}` 会完全隐藏编辑按钮;可通过外部控制 `editing` 实现编程式编辑。 +- 保存失败应在 `onOk` 中处理,抛出错误可保持编辑器打开。 +- 只读态仍可沿用 Ant Design 的省略配置,但 `children`/`displayText` 覆盖会禁用截断效果。 diff --git a/.storybook/stories/components/EllipsisTypography/api-doc.en-US.md b/.storybook/stories/components/EllipsisTypography/api-doc.en-US.md new file mode 100644 index 0000000..5cfcff2 --- /dev/null +++ b/.storybook/stories/components/EllipsisTypography/api-doc.en-US.md @@ -0,0 +1,14 @@ +## API + +`EllipsisText`, `EllipsisParagraph`, `EllipsisTitle`, and `EllipsisLink` are thin wrappers over the corresponding Ant Design Typography components, adding automatic ellipsis + tooltip. They accept the **same props as the underlying Ant Design typography component** plus the `ellipsis` enhancements below. + +| Prop | Description | Type | Default | +| ------------------ | ------------------------------------------------------------------------------------------------------ | --------------------------- | ------- | +| `ellipsis` | Enables ellipsis. `true` / `ellipsis.tooltip` / `ellipsis.tooltip.title` all turn on the auto tooltip. | `boolean \| EllipsisConfig` | `false` | +| `ellipsis.tooltip` | Show a tooltip with the full content when overflowing. | `boolean \| TooltipProps` | - | +| `ellipsis.rows` | Maximum number of lines before clamping (paragraphs/titles). | `number` | - | +| `children` | Text content. If omitted, `children` can be used as fallback. | `ReactNode` | - | +| `level` | Title level (for `EllipsisTitle`). | `1\|2\|3\|4\|5` | `1` | +| `href` | Link target URL (for `EllipsisLink`). | `string` | - | + +> Inherits all props of Ant Design `Typography.Text` / `Paragraph` / `Title` / `Link` (e.g. `style`, `className`, `strong`, `code`, `copyable`). diff --git a/.storybook/stories/components/EllipsisTypography/api-doc.zh-CN.md b/.storybook/stories/components/EllipsisTypography/api-doc.zh-CN.md new file mode 100644 index 0000000..e6a531f --- /dev/null +++ b/.storybook/stories/components/EllipsisTypography/api-doc.zh-CN.md @@ -0,0 +1,14 @@ +## API + +`EllipsisText`、`EllipsisParagraph`、`EllipsisTitle`、`EllipsisLink` 分别是对应 Ant Design Typography 组件的轻量封装,额外提供自动省略号 + tooltip。它们**接受底层 Ant Design 排版组件的相同 props**,并补充以下 `ellipsis` 增强。 + +| 属性 | 说明 | 类型 | 默认值 | +| ------------------ | ----------------------------------------------------------------------------------------- | --------------------------- | ------- | +| `ellipsis` | 开启省略号。`true` / `ellipsis.tooltip` / `ellipsis.tooltip.title` 均可开启自动 tooltip。 | `boolean \| EllipsisConfig` | `false` | +| `ellipsis.tooltip` | 溢出时以 tooltip 展示完整内容。 | `boolean \| TooltipProps` | - | +| `ellipsis.rows` | 截断前的最大行数(段落/标题)。 | `number` | - | +| `children` | 文本内容。若省略,`children` 可作为兜底。 | `ReactNode` | - | +| `level` | 标题级别(用于 `EllipsisTitle`)。 | `1\|2\|3\|4\|5` | `1` | +| `href` | 链接跳转地址(用于 `EllipsisLink`)。 | `string` | - | + +> 继承 Ant Design `Typography.Text` / `Paragraph` / `Title` / `Link` 的全部 props(如 `style`、`className`、`strong`、`code`、`copyable`)。 diff --git a/.storybook/stories/components/EllipsisTypography/index.stories.tsx b/.storybook/stories/components/EllipsisTypography/index.stories.tsx index 58283df..2ff4490 100644 --- a/.storybook/stories/components/EllipsisTypography/index.stories.tsx +++ b/.storybook/stories/components/EllipsisTypography/index.stories.tsx @@ -4,6 +4,8 @@ import EllipsisParagraphComponent from '../../../../src/components/EllipsisTypog import EllipsisTextComponent from '../../../../src/components/EllipsisTypography/EllipsisText'; import EllipsisTitleComponent from '../../../../src/components/EllipsisTypography/EllipsisTitle'; import storyI18n, { storyT } from '../../../locales'; +import apiDocEN from './api-doc.en-US.md?raw'; +import apiDocCN from './api-doc.zh-CN.md?raw'; import introduceEN from './introduce.en-US.md?raw'; import introduceCN from './introduce.zh-CN.md?raw'; @@ -14,7 +16,7 @@ const meta: Meta = { parameters: { docs: { description: { - component: storyI18n.language === 'zh-CN' ? introduceCN : introduceEN, + component: storyI18n.language === 'zh-CN' ? introduceCN + apiDocCN : introduceEN + apiDocEN, }, }, }, diff --git a/.storybook/stories/components/EllipsisTypography/introduce.en-US.md b/.storybook/stories/components/EllipsisTypography/introduce.en-US.md index b913689..d6983f8 100644 --- a/.storybook/stories/components/EllipsisTypography/introduce.en-US.md +++ b/.storybook/stories/components/EllipsisTypography/introduce.en-US.md @@ -1,2 +1,18 @@ -A set of typography wrapper components with ellipsis capabilities, covering text, paragraph, title, and link variants. -When the text overflows, the ellipsis effect is displayed; if the text does not overflow, the tooltip is not shown. +A family of typography components (`Text` / `Paragraph` / `Title` / `Link`) with enhanced ellipsis and automatic tooltip. When content overflows, a tooltip shows the full text; when it fits, no tooltip appears. + +## When to use + +Use these components instead of plain Ant Design Typography whenever long text may overflow its container and you want a graceful "show more on hover" experience — single-line labels, multi-line paragraphs, headings, or links. + +## Key features + +- **Smart tooltip** — enable automatic ellipsis+tooltip in three equivalent ways: `ellipsis={true}`, `ellipsis.tooltip={true}`, or `ellipsis.tooltip.title={true}`. The tooltip only renders when text actually overflows. +- **Four variants** — `EllipsisText`, `EllipsisParagraph`, `EllipsisTitle`, `EllipsisLink` map to the corresponding Ant Design typography elements. +- **Rich ellipsis config** — supports row count (`rows`), tooltip title/content, and all of Ant Design's `ellipsis` options. +- **Drop-in** — accepts the same props as the underlying Ant Design typography component, so migration is usually a one-line swap. + +## Usage notes + +- The tooltip is suppressed automatically when content fits, so you don't need conditional logic. +- Prefer `rows` for paragraph/title multi-line clamping; single-line uses the default behavior. +- Because it forwards Ant Design typography props, styling via `style`/`className` works as expected. diff --git a/.storybook/stories/components/EllipsisTypography/introduce.zh-CN.md b/.storybook/stories/components/EllipsisTypography/introduce.zh-CN.md index 8adbd95..ecf5080 100644 --- a/.storybook/stories/components/EllipsisTypography/introduce.zh-CN.md +++ b/.storybook/stories/components/EllipsisTypography/introduce.zh-CN.md @@ -1 +1,18 @@ -一组带省略能力的排版封装组件,覆盖文本、段落、标题和链接几种形态。在文本溢出时显示省略效果,如果文本没有溢出,则不显示tooltip。 +具备增强省略号与自动 tooltip 能力的排版组件族(`Text` / `Paragraph` / `Title` / `Link`)。当内容溢出时自动以 tooltip 展示全文,未溢出则不显示 tooltip。 + +## 适用场景 + +当长文本可能超出容器、且希望提供"悬停查看完整内容"的优雅体验时(单行标签、多行段落、标题或链接),用这些组件替代普通 Ant Design Typography。 + +## 核心特性 + +- **智能 tooltip** —— 三种等价方式开启自动省略+tooltip:`ellipsis={true}`、`ellipsis.tooltip={true}` 或 `ellipsis.tooltip.title={true}`。仅当文本真正溢出时才渲染 tooltip。 +- **四种变体** —— `EllipsisText`、`EllipsisParagraph`、`EllipsisTitle`、`EllipsisLink` 分别对应 Ant Design 的相应排版元素。 +- **丰富的省略配置** —— 支持行数(`rows`)、tooltip 标题/内容,以及 Ant Design 的全部 `ellipsis` 选项。 +- **即插即用** —— 接受底层 Ant Design 排版组件的相同 props,迁移通常只需替换一行。 + +## 使用注意 + +- 内容未溢出时会自动抑制 tooltip,无需自行写条件逻辑。 +- 段落/标题的多行截断优先使用 `rows`;单行使用默认行为即可。 +- 由于透传 Ant Design 排版 props,通过 `style`/`className` 设置样式与预期一致。 diff --git a/.storybook/stories/components/FloatDrawer/introduce.en-US.md b/.storybook/stories/components/FloatDrawer/introduce.en-US.md index a303fed..d1592b7 100644 --- a/.storybook/stories/components/FloatDrawer/introduce.en-US.md +++ b/.storybook/stories/components/FloatDrawer/introduce.en-US.md @@ -1,5 +1,19 @@ -FloatDrawer is a draggable, resizable drawer that can sit at any edge of a parent container, with a toggle handle and optional size persistence. +A draggable, resizable edge drawer that can sit on any side of its parent. Unlike a modal, it shares the parent's layout and can stay open alongside content. ## When to use -You want a side panel that overlays part of a container (not a full-screen modal), can be collapsed, resized by dragging, and optionally remembers its size. +Use `FloatDrawer` for persistent side panels — filters, inspectors, tool palettes, chat/assistants — that should hug a screen edge, be resized by the user, and not block the underlying content like a modal does. + +## Key features + +- **Four edges** — `position` picks `left` / `right` / `top` / `bottom`; `edgeOffset` nudges it from the edge. +- **Resizable & toggle** — `resizable` lets users drag the handle; `showToggle` shows the expand/collapse button. +- **Sizing** — `defaultSize` / `minSize` / `maxSize` bound the dimension (width for side drawers, height for top/bottom). +- **Persistence** — `cacheKey` remembers the size in `localStorage`. +- **Inherits antd Card** — the surface accepts `CardProps` for header/footer/extra. + +## Usage notes + +- It is positioned relative to its parent, so the parent needs `position: relative` (or non-static) for correct placement. +- `destroyOnClose` controls whether inner content is unmounted when closed; keep it `false` to preserve state. +- When fully collapsed it hides outside the parent edge; if the parent has padding/border, tweak `edgeOffset` so it tucks away completely. diff --git a/.storybook/stories/components/FloatDrawer/introduce.zh-CN.md b/.storybook/stories/components/FloatDrawer/introduce.zh-CN.md index b6b4af0..d02abf9 100644 --- a/.storybook/stories/components/FloatDrawer/introduce.zh-CN.md +++ b/.storybook/stories/components/FloatDrawer/introduce.zh-CN.md @@ -1,5 +1,19 @@ -FloatDrawer 是一个可拖动、可调整大小的浮动抽屉,可停靠在父容器的任意边缘,带展开/收起手柄,并可选记忆宽度。 +可拖拽、可调整大小的边缘抽屉,可停靠在父容器的任意一侧。与模态框不同,它与父布局共存,可在内容之上保持打开。 ## 适用场景 -你需要一个覆盖在容器局部的侧边面板(而非全屏弹窗),可收起、可通过拖拽调整大小,并可选择性记忆尺寸。 +当需要一个常驻的侧边面板——筛选器、检查器、工具面板、聊天/助手——应贴附屏幕边缘、可被用户调整大小,且不像模态框那样遮挡底层内容时,使用 `FloatDrawer`。 + +## 核心特性 + +- **四边停靠** —— `position` 选择 `left` / `right` / `top` / `bottom`;`edgeOffset` 可将其从边缘微调。 +- **可缩放与开合** —— `resizable` 允许用户拖动手柄;`showToggle` 显示展开/收起按钮。 +- **尺寸约束** —— `defaultSize` / `minSize` / `maxSize` 限定尺寸(侧边抽屉为宽度,上下抽屉为高度)。 +- **持久化** —— `cacheKey` 在 `localStorage` 中记忆尺寸。 +- **继承 antd Card** —— 面板表面接受 `CardProps`,可配置 header/footer/extra。 + +## 使用注意 + +- 它相对父容器定位,因此父容器需为 `position: relative`(或非 static)才能正确摆放。 +- `destroyOnClose` 控制关闭时是否卸载内部内容;保持 `false` 可保留状态。 +- 完全收起时会隐藏到父边缘之外;若父容器有 padding/border,请调整 `edgeOffset` 使其完全收纳。 diff --git a/.storybook/stories/components/FormItemControl/introduce.en-US.md b/.storybook/stories/components/FormItemControl/introduce.en-US.md index 6fc1f6e..16b674f 100644 --- a/.storybook/stories/components/FormItemControl/introduce.en-US.md +++ b/.storybook/stories/components/FormItemControl/introduce.en-US.md @@ -1,5 +1,17 @@ -FormItemControl wraps custom content into a valid `Form.Item` control. It must be used as a direct child of `Form.Item`, and provides its children with `value` and `onChange` to interact with the form state. +Wraps arbitrary custom content into a valid Ant Design `Form.Item` control. It must be a direct child of `Form.Item` and hands your render function a `value` and `onChange`, bridging custom UI to form state. ## When to use -You have a custom widget (slider, color picker, rich editor, …) that you want to plug into an Ant Design `Form` without reimplementing `value`/`onChange` wiring. +Use `FormItemControl` when a form field's UI is not covered by Ant Design's built-in controls — a slider, a custom picker, a canvas-based input — but you still want it to participate in `Form` validation, value binding, and submission. + +## Key features + +- **Render-prop bridge** — `children` receives `{ value, onChange }`, exactly the contract Ant Design controls expect. +- **Form-native** — validation, `initialValues`, `getFieldsValue`, and submit all work because it is a real `Form.Item` child. +- **Zero markup** — you supply only the control UI; the wrapper handles the form wiring. + +## Usage notes + +- It must be placed directly inside `<Form.Item>`; nesting other elements between breaks the binding. +- Call `onChange` with the new value from within your custom control to update form state. +- Useful pairings: a `Slider` for numeric fields, a color picker, or a third-party input library. diff --git a/.storybook/stories/components/FormItemControl/introduce.zh-CN.md b/.storybook/stories/components/FormItemControl/introduce.zh-CN.md index e443296..a3f6316 100644 --- a/.storybook/stories/components/FormItemControl/introduce.zh-CN.md +++ b/.storybook/stories/components/FormItemControl/introduce.zh-CN.md @@ -1,5 +1,17 @@ -FormItemControl 将自定义内容包装成有效的 `Form.Item` 控件,必须作为 `Form.Item` 的直接子节点使用,并向子组件提供 `value` 与 `onChange` 以与表单状态交互。 +将任意自定义内容包装成有效的 Ant Design `Form.Item` 控件。它必须作为 `Form.Item` 的直接子节点,并向你的渲染函数提供 `value` 与 `onChange`,从而把自定义 UI 接入表单状态。 ## 适用场景 -你有一个自定义控件(滑块、取色器、富文本编辑器等),希望接入 Ant Design 的 `Form`,而不必自己重新实现 `value`/`onChange` 的对接。 +当某个表单字段的 UI 不被 Ant Design 内置控件覆盖——滑块、自定义选择器、基于 canvas 的输入——但你仍希望它参与 `Form` 的校验、取值与提交流程时,使用 `FormItemControl`。 + +## 核心特性 + +- **渲染属性桥接** —— `children` 接收 `{ value, onChange }`,正是 Ant Design 控件所期望的契约。 +- **表单原生** —— 校验、`initialValues`、`getFieldsValue`、提交全部可用,因为它本身就是真正的 `Form.Item` 子节点。 +- **零样板** —— 你只需提供控件 UI,包装层负责表单接线。 + +## 使用注意 + +- 它必须直接放在 `<Form.Item>` 内部;中间夹入其它元素会破坏绑定。 +- 在你的自定义控件内部调用 `onChange` 并传入新值,即可更新表单状态。 +- 常见搭配:用 `Slider` 处理数值字段、颜色选择器,或第三方输入库。 diff --git a/.storybook/stories/components/Iconfont/introduce.en-US.md b/.storybook/stories/components/Iconfont/introduce.en-US.md index 614a1f9..2909a85 100644 --- a/.storybook/stories/components/Iconfont/introduce.en-US.md +++ b/.storybook/stories/components/Iconfont/introduce.en-US.md @@ -1 +1,18 @@ -`createIconfont` is a factory that turns an iconfont script URL into a ready-to-use icon component. It wraps Ant Design's `createFromIconfontCN` and adds an `iconPrefix` option plus a `size` alias for `style.fontSize`. +Renders icons from an iconfont.cn script, powered by Ant Design's `IconFont` loader. Point it at a script URL and reference icons by name. + +## When to use + +Use `Iconfont` when your project's icon set lives on iconfont.cn (or a self-hosted iconfont script) rather than the bundled Ant Design icon library — team-custom glyphs, brand icons, or a curated set. + +## Key features + +- **Script-based** — `scriptUrl` loads the iconfont script once; all icons in that script become available by `type`. +- **Standard props** — inherits Ant Design `IconFontProps` (`spin`, `rotate`, `style`, `onClick`, …), so usage mirrors the normal `Icon` component. +- **Size & color** — `size` (`control`) and `color` tune the glyph; `rotate` (`control`) sets a fixed angle. +- **Prefix handling** — `iconPrefix` (demo helper) is prepended to `type` unless already present. + +## Usage notes + +- Load the script via `scriptUrl` before icons render; a wrong/missing URL yields blank glyphs. +- `type` is the icon name from the iconfont project; the `iconPrefix` is auto-prepended when needed. +- Because it forwards Ant Design icon props, `spin`/`rotate`/`style` behave exactly as with `<Icon />`. diff --git a/.storybook/stories/components/Iconfont/introduce.zh-CN.md b/.storybook/stories/components/Iconfont/introduce.zh-CN.md index b92425a..f740faa 100644 --- a/.storybook/stories/components/Iconfont/introduce.zh-CN.md +++ b/.storybook/stories/components/Iconfont/introduce.zh-CN.md @@ -1 +1,18 @@ -`createIconfont` 是一个工厂函数:传入 iconfont 脚本地址,返回一个可直接使用的图标组件。它封装了 Ant Design 的 `createFromIconfontCN`,额外提供 `iconPrefix` 选项与 `size`(`style.fontSize` 的别名)属性。 +基于 Ant Design 的 `IconFont` 加载器,渲染来自 iconfont.cn 脚本的图标。指向脚本 URL 后,按名称引用图标即可。 + +## 适用场景 + +当项目的图标集托管在 iconfont.cn(或自托管的 iconfont 脚本)而非内置的 Ant Design 图标库时——团队自定义字形、品牌图标或精选图标集——使用 `Iconfont`。 + +## 核心特性 + +- **基于脚本** —— `scriptUrl` 加载一次 iconfont 脚本;该脚本中的全部图标即可通过 `type` 使用。 +- **标准 props** —— 继承 Ant Design `IconFontProps`(`spin`、`rotate`、`style`、`onClick` 等),用法与常规 `Icon` 组件一致。 +- **尺寸与颜色** —— `size`(`control`)与 `color` 调整字形;`rotate`(`control`)设定固定角度。 +- **前缀处理** —— `iconPrefix`(演示辅助)会在 `type` 前自动拼接,除非 `type` 已包含该前缀。 + +## 使用注意 + +- 图标渲染前需通过 `scriptUrl` 加载脚本;URL 错误或缺失会导致字形空白。 +- `type` 为 iconfont 项目中的图标名;必要时 `iconPrefix` 会自动前置。 +- 由于透传 Ant Design 图标 props,`spin`/`rotate`/`style` 的行为与 `<Icon />` 完全一致。 diff --git a/.storybook/stories/components/Loading/introduce.en-US.md b/.storybook/stories/components/Loading/introduce.en-US.md index 13d84e4..f5525b9 100644 --- a/.storybook/stories/components/Loading/introduce.en-US.md +++ b/.storybook/stories/components/Loading/introduce.en-US.md @@ -1,10 +1,19 @@ -Displays a loading indicator that can work as an overlay (wrapping children) or as a standalone visual state that fills and centers within its parent. +Displays a loading indicator that works either as an overlay (wrapping children with a spinner) or as a standalone visual state that fills its parent. ## When to use -Show a spinner over existing content, or render a standalone centered loading placeholder inside a relatively-positioned container. +Use `Loading` to signal an in-progress state: wrap a region with the overlay spinner, or drop in the standalone animation as a centered placeholder while data loads. -## Two usage modes +## Key features -1. **Spin (overlay)** — pass `children`; the component wraps them with Ant Design `Spin` and toggles the animation via `spinning`. -2. **Standalone** — no children; renders a self-filled, centered loading animation. Hidden automatically when `spinning` is `false`. +- **Two usage modes** + 1. **Spin** — when the component wraps `children`, it overlays Ant Design's `Spin` on top of them. + 2. **Standalone** — with no children, it renders a loader that auto-fills and centers within the parent container. +- **Inherits Spin** — extends Ant Design `SpinProps`, so `tip`, `size`, `indicator`, `spinning`, etc. all apply. +- **Standalone extras** — `mode` (`absolute` | `flex`) controls standalone positioning; `rootClassName` / `rootStyle` style the mask container. + +## Usage notes + +- In Spin mode the children stay in the DOM and are merely covered; in standalone mode there are no children. +- `mode` only matters for the standalone variant; `absolute` centers via absolute positioning, `flex` fills the parent. +- Because it extends `SpinProps`, most styling and behavior matches Ant Design's `Spin`. diff --git a/.storybook/stories/components/Loading/introduce.zh-CN.md b/.storybook/stories/components/Loading/introduce.zh-CN.md index d2f7aab..9295552 100644 --- a/.storybook/stories/components/Loading/introduce.zh-CN.md +++ b/.storybook/stories/components/Loading/introduce.zh-CN.md @@ -1,10 +1,19 @@ -用于展示加载状态,可作为覆盖层(包裹 children),也可单独作为视觉占位:自动撑满父容器并居中显示。 +展示加载状态,既可作为覆盖层(在子内容外包裹 spinner),也可作为独立视觉占位填充父容器。 ## 适用场景 -在已有内容上叠加 spinner,或在相对定位的容器内渲染一个独立、居中、撑满的加载占位。 +当需要表达"进行中"状态时:用覆盖模式为某区域包裹旋转 spinner,或以独立动画作为数据加载时的居中占位,使用 `Loading`。 -## 两种用法 +## 核心特性 -1. **Spin (overlay)** — pass `children`; the component wraps them with Ant Design `Spin` and toggles the animation via `spinning`. -2. **Standalone** — no children; renders a self-filled, centered loading animation. Hidden automatically when `spinning` is `false`. +- **两种使用方式** + 1. **Spin** —— 当组件包裹 `children` 时,在它们之上叠加 Ant Design 的 `Spin`。 + 2. **独立** —— 无 children 时,渲染一个自动填满并居中于父容器的加载动画。 +- **继承 Spin** —— 扩展 Ant Design `SpinProps`,因此 `tip`、`size`、`indicator`、`spinning` 等均可用。 +- **独立模式增强** —— `mode`(`absolute` | `flex`)控制独立定位;`rootClassName` / `rootStyle` 设置遮罩容器样式。 + +## 使用注意 + +- Spin 模式下子内容仍保留在 DOM 中,只是被覆盖;独立模式下没有 children。 +- `mode` 仅对独立变体有意义:`absolute` 通过绝对定位居中,`flex` 填满父容器。 +- 由于扩展自 `SpinProps`,多数样式与行为都与 Ant Design 的 `Spin` 一致。 diff --git a/.storybook/stories/components/ModalAction/api-doc.en-US.md b/.storybook/stories/components/ModalAction/api-doc.en-US.md new file mode 100644 index 0000000..5dc56e7 --- /dev/null +++ b/.storybook/stories/components/ModalAction/api-doc.en-US.md @@ -0,0 +1,16 @@ +## API + +`ModalActionProps<FormData, P, TriggerProp, Event, Ref>` extends antd `ModalProps` (minus `onOk`) plus a trigger definition. It renders a modal hosting your form component. Key props are listed below. + +| Prop | Description | Type | Default | +| ------------------ | ----------------------------------------------------------------------------------------------- | ---------------------------------------------------- | ----------- | +| `formComp` | Your form component; the parent creates the `Form` instance and injects `form` + save handlers. | `ComponentType<P & RefAttributes<Ref>>` | - | +| `formProps` | Props passed to `formComp` (excluding the auto-injected form contract). | `Omit<P, keyof FormCompPropsConstraint<FormData>>` | - | +| `triggerComponent` | Component used as the trigger that opens the modal. | `ComponentType<TriggerProp>` | `Button` | +| `triggerProps` | Props passed to the trigger component. | `TriggerProp` | - | +| `triggerEvent` | Event on the trigger that opens the modal. | `keyof TriggerProp` | `'onClick'` | +| `onOk` | Called with `formData` on confirm; return `SubmitWithoutClosingSymbol` to keep open. | `(formData, ...args) => unknown \| Promise<unknown>` | - | +| `afterOk` | Called after a successful `onOk`; receives its return value. | `(data?) => void` | - | +| `onBeforeOpen` | Runs before opening; throwing/rejecting prevents the modal. | `() => Promise<unknown> \| unknown` | - | + +> Inherits all antd `ModalProps` (title, width, okText, open, `modalRender`, …). The form component receives `form`, `onSave`, `onOpenChange` via `FormCompPropsConstraint`. diff --git a/.storybook/stories/components/ModalAction/api-doc.zh-CN.md b/.storybook/stories/components/ModalAction/api-doc.zh-CN.md new file mode 100644 index 0000000..7641c09 --- /dev/null +++ b/.storybook/stories/components/ModalAction/api-doc.zh-CN.md @@ -0,0 +1,16 @@ +## API + +`ModalActionProps<FormData, P, TriggerProp, Event, Ref>` 继承 antd `ModalProps`(去掉 `onOk`)并附加触发器定义。它渲染一个承载你表单组件的弹窗。主要属性如下。 + +| 属性 | 说明 | 类型 | 默认值 | +| ------------------ | ------------------------------------------------------------------------ | ---------------------------------------------------- | ----------- | +| `formComp` | 你的表单组件;父组件创建 `Form` 实例并注入 `form` 与保存处理函数。 | `ComponentType<P & RefAttributes<Ref>>` | - | +| `formProps` | 传给 `formComp` 的属性(不含自动注入的表单契约)。 | `Omit<P, keyof FormCompPropsConstraint<FormData>>` | - | +| `triggerComponent` | 用作触发器、点击后打开弹窗的组件。 | `ComponentType<TriggerProp>` | `Button` | +| `triggerProps` | 传给触发器组件的属性。 | `TriggerProp` | - | +| `triggerEvent` | 触发器上打开弹窗的事件。 | `keyof TriggerProp` | `'onClick'` | +| `onOk` | 确认时以 `formData` 调用;返回 `SubmitWithoutClosingSymbol` 可保持打开。 | `(formData, ...args) => unknown \| Promise<unknown>` | - | +| `afterOk` | `onOk` 成功后调用,接收其返回值。 | `(data?) => void` | - | +| `onBeforeOpen` | 打开前执行;抛错或 reject 会阻止弹窗。 | `() => Promise<unknown> \| unknown` | - | + +> 继承全部 antd `ModalProps`(title、width、okText、open、`modalRender` 等)。表单组件通过 `FormCompPropsConstraint` 接收 `form`、`onSave`、`onOpenChange`。 diff --git a/.storybook/stories/components/ModalAction/index.stories.tsx b/.storybook/stories/components/ModalAction/index.stories.tsx index 5e5c58b..a05a25e 100644 --- a/.storybook/stories/components/ModalAction/index.stories.tsx +++ b/.storybook/stories/components/ModalAction/index.stories.tsx @@ -6,6 +6,8 @@ import type { LinkProps } from 'antd/es/typography/Link'; import type { FormCompPropsConstraint, ModalActionProps } from '../../../../src/components/ModalAction'; import { withModalAction } from '../../../../src/components/ModalAction'; import storyI18n, { storyT, useStoryT } from '../../../locales'; +import apiDocEN from './api-doc.en-US.md?raw'; +import apiDocCN from './api-doc.zh-CN.md?raw'; import introduceEN from './introduce.en-US.md?raw'; import introduceCN from './introduce.zh-CN.md?raw'; @@ -56,7 +58,7 @@ const meta: Meta<ModalActionStoryArgs> = { parameters: { docs: { description: { - component: storyI18n.language === 'zh-CN' ? introduceCN : introduceEN, + component: storyI18n.language === 'zh-CN' ? introduceCN + apiDocCN : introduceEN + apiDocEN, }, }, }, diff --git a/.storybook/stories/components/ModalAction/introduce.en-US.md b/.storybook/stories/components/ModalAction/introduce.en-US.md index a82410e..d6988b0 100644 --- a/.storybook/stories/components/ModalAction/introduce.en-US.md +++ b/.storybook/stories/components/ModalAction/introduce.en-US.md @@ -1 +1,19 @@ -A modal-based action component that wraps a trigger and opens a form dialog before executing the action. The editing form is provided by the caller via `formComp`; the parent automatically creates the form instance and wires save/close lifecycle. Can switch between Button, Switch, and Link trigger types. +Opens a modal containing a form before running an action. Like `ConfirmAction` it binds to a trigger, but instead of a simple confirm box it renders a full editable form and wires the form instance for you. + +## When to use + +Use `ModalAction` when the pre-action step needs structured input — creating or editing a record, collecting parameters, or any flow where `ConfirmAction`'s plain dialog is not enough but a separate page is overkill. + +## Key features + +- **Form-in-modal** — supply `formComp` (your form component) and ModalAction creates the `Form` instance and the `Modal`, then injects `form` + save handlers into your component. +- **Trigger-agnostic** — `triggerComponent` / `triggerProps` / `triggerEvent` pick any trigger, just like `ConfirmAction`. +- **Async save** — `onOk` receives the form data and can be async; returning `SubmitWithoutClosingSymbol` keeps the modal open (useful for "save and continue"). +- **afterOk** — fires only after a successful save, for navigation/refresh. +- **Inherits antd** — all `ModalProps` (title, width, okText, …) are available. + +## Usage notes + +- Do not render a `<Form>` inside `formComp`; the parent already provides the instance — use the injected `form` and register save via `onSave`. +- `onOk` returning `SubmitWithoutClosingSymbol` prevents auto-close; any other return value is forwarded to `afterOk`. +- Keep the actual submit logic in `onOk`; `afterOk` is purely for post-success side effects. diff --git a/.storybook/stories/components/ModalAction/introduce.zh-CN.md b/.storybook/stories/components/ModalAction/introduce.zh-CN.md index bc296d9..a665953 100644 --- a/.storybook/stories/components/ModalAction/introduce.zh-CN.md +++ b/.storybook/stories/components/ModalAction/introduce.zh-CN.md @@ -1 +1,19 @@ -基于弹框的动作组件,封装触发器并在真正执行操作前打开表单对话框。编辑表单由调用方通过 `formComp` 提供,父组件自动创建表单实例并接管保存/关闭生命周期。可切换 Button、Switch、Link 三种触发方式。 +在执行业务操作前打开一个包含表单的弹窗。与 `ConfirmAction` 类似,它绑定触发器,但弹出的不是简单确认框,而是一个完整可编辑表单,并为你自动管理表单实例。 + +## 适用场景 + +当操作前的步骤需要结构化输入时(创建或编辑记录、收集参数,或任何"ConfirmAction 的普通弹框不够、但单独开一个页面又过重"的流程),使用 `ModalAction`。 + +## 核心特性 + +- **弹窗内表单** —— 传入 `formComp`(你的表单组件),ModalAction 会创建 `Form` 实例与 `Modal`,并将 `form` 及保存处理函数注入到你的组件中。 +- **触发器无关** —— 通过 `triggerComponent` / `triggerProps` / `triggerEvent` 选择任意触发器,与 `ConfirmAction` 一致。 +- **异步保存** —— `onOk` 接收表单数据且可异步;返回 `SubmitWithoutClosingSymbol` 可保持弹窗不关闭(适合"保存并继续")。 +- **afterOk** —— 仅在保存成功后触发,用于跳转/刷新。 +- **继承 antd** —— 全部 `ModalProps`(title、width、okText 等)均可用。 + +## 使用注意 + +- 不要在 `formComp` 内部再渲染 `<Form>`;父组件已提供实例——请使用注入的 `form` 并通过 `onSave` 注册保存。 +- `onOk` 返回 `SubmitWithoutClosingSymbol` 会阻止自动关闭;返回其它值会透传给 `afterOk`。 +- 实际提交逻辑放在 `onOk` 中;`afterOk` 仅用于成功后的副作用。 diff --git a/.storybook/stories/components/OverflowTags/introduce.en-US.md b/.storybook/stories/components/OverflowTags/introduce.en-US.md index aee962e..6ce418b 100644 --- a/.storybook/stories/components/OverflowTags/introduce.en-US.md +++ b/.storybook/stories/components/OverflowTags/introduce.en-US.md @@ -1,5 +1,19 @@ -Renders a tag collection with overflow collapsing, suitable for dense metadata or filter summaries. When the number of tags exceeds the maximum count, an ellipsis tag is shown; custom tag rendering and per-tag props are supported. +Renders a collection of tags with overflow collapsing — when there are too many, the extras fold into an ellipsis tag that opens a dropdown. Built for dense metadata or filter summaries. ## When to use -You show many tags in a tight space and want the extras collapsed into a "+N" popover instead of wrapping or scrolling. +Use `OverflowTags` to show a variable number of tags (selected filters, assigned labels, skills, categories) in a constrained space, gracefully collapsing the overflow instead of wrapping awkwardly or overflowing. + +## Key features + +- **Overflow collapsing** — once tags exceed the visible count, the rest collapse into an ellipsis tag that reveals them in a dropdown. +- **Flexible data** — `tags` is a generic `T[]`; `getTagName` / `getTagKey` map each item to label/key (defaulting to `label`/`name` and `value`/`id`). +- **Custom rendering** — `renderTag` overrides the tag, `tagProps` / `ellipsisTagProps` style the normal and ellipsis tags, `ellipsisDropdownProps` styles the overflow dropdown. +- **Colors** — `randomColors` assigns preset colors; per-tag `color` takes precedence. +- **Inherits rc-overflow** — `OverflowProps` (except `renderItem`) are forwarded, so sizing/spacing follow that library. + +## Usage notes + +- Provide a stable `getTagKey` so React reconciliation and the dropdown stay correct when the list changes. +- `renderTag` replaces the default tag entirely; if you only need styling, prefer `tagProps`. +- `randomColors` is best for non-semantic sets; for meaningful categories, set `color` per tag instead. diff --git a/.storybook/stories/components/OverflowTags/introduce.zh-CN.md b/.storybook/stories/components/OverflowTags/introduce.zh-CN.md index 881adf6..673aecb 100644 --- a/.storybook/stories/components/OverflowTags/introduce.zh-CN.md +++ b/.storybook/stories/components/OverflowTags/introduce.zh-CN.md @@ -1,5 +1,19 @@ -用于渲染可折叠溢出的标签集合,适合密集元信息或筛选条件摘要场景。当标签数量超过最大显示数时,会显示省略号标签,并支持自定义标签渲染与属性。 +渲染可折叠溢出的标签集合——标签过多时,多余部分收拢为一个省略号标签,点击展开下拉。专为密集元信息或筛选条件摘要场景设计。 ## 适用场景 -在有限空间内展示大量标签,希望多余的标签折叠成 "+N" 弹出层,而不是换行或滚动。 +当需要在有限空间内展示数量可变的标签(已选筛选、分配标签、技能、分类)时,用 `OverflowTags` 优雅收拢溢出部分,而非尴尬地换行或溢出。 + +## 核心特性 + +- **溢出折叠** —— 标签数量超过可见上限后,其余收拢为一个省略号标签,点击在下拉中展开。 +- **灵活数据** —— `tags` 为泛型 `T[]`;`getTagName` / `getTagKey` 将每项映射为标签/键(默认取 `label`/`name` 与 `value`/`id`)。 +- **自定义渲染** —— `renderTag` 覆盖标签本身,`tagProps` / `ellipsisTagProps` 分别设置普通与省略号标签样式,`ellipsisDropdownProps` 设置溢出下拉样式。 +- **配色** —— `randomColors` 分配预设颜色;单个标签的 `color` 优先级更高。 +- **继承 rc-overflow** —— `OverflowProps`(除 `renderItem` 外)均被透传,尺寸/间距遵循该库。 + +## 使用注意 + +- 请提供稳定的 `getTagKey`,以便列表变化时 React 协调与下拉表现正确。 +- `renderTag` 会整体替换默认标签;若仅需改样式,优先使用 `tagProps`。 +- `randomColors` 适合无语义的集合;对有明确含义的分类,建议逐标签设置 `color`。 diff --git a/.storybook/stories/components/PulseAnimation/api-doc.en-US.md b/.storybook/stories/components/PulseAnimation/api-doc.en-US.md new file mode 100644 index 0000000..f3c44dc --- /dev/null +++ b/.storybook/stories/components/PulseAnimation/api-doc.en-US.md @@ -0,0 +1,15 @@ +## API + +| Prop | Description | Type | Default | +| ----------- | ----------------------------------------------------------------------------------- | ---------------------------------- | ----------- | +| `bars` | Number of animated bars. | `number` | `8` | +| `barGap` | Gap between bars. | `CSSProperties['gap']` | `4px` | +| `barColor` | Background color of the bars; defaults to the theme's `colorFillSecondary`. | `CSSProperties['backgroundColor']` | theme token | +| `duration` | Animation duration in seconds. | `number` | `1.6` | +| `delayRate` | Delay offset rate; bar delay = `(index) * delayRate` for the traveling-wave effect. | `number` | `0.09` | +| `barStyle` | Extra styles applied to each bar. | `CSSProperties` | - | +| `className` | Root element class name. | `string` | - | +| `style` | Root element inline style. | `CSSProperties` | - | +| `prefixCls` | Custom CSS class prefix. | `string` | - | + +> Use the `token.AnimationPulse` theme token to globally configure `barMinSize` / `barMaxSize`. diff --git a/.storybook/stories/components/PulseAnimation/api-doc.zh-CN.md b/.storybook/stories/components/PulseAnimation/api-doc.zh-CN.md new file mode 100644 index 0000000..0ab92cc --- /dev/null +++ b/.storybook/stories/components/PulseAnimation/api-doc.zh-CN.md @@ -0,0 +1,15 @@ +## API + +| 属性 | 说明 | 类型 | 默认值 | +| ----------- | -------------------------------------------------------------------- | ---------------------------------- | ---------- | +| `bars` | 动画柱条数量。 | `number` | `8` | +| `barGap` | 柱条之间的间距。 | `CSSProperties['gap']` | `4px` | +| `barColor` | 柱条背景色,默认主题的 `colorFillSecondary`。 | `CSSProperties['backgroundColor']` | 主题 token | +| `duration` | 动画时长,单位为秒。 | `number` | `1.6` | +| `delayRate` | 延迟百分比系数;柱条延迟 = `(index) * delayRate`,形成行进波形效果。 | `number` | `0.09` | +| `barStyle` | 应用于每根柱条的额外样式。 | `CSSProperties` | - | +| `className` | 根元素类名。 | `string` | - | +| `style` | 根元素内联样式。 | `CSSProperties` | - | +| `prefixCls` | 自定义 CSS 类前缀。 | `string` | - | + +> 通过 `token.AnimationPulse` 主题 token 可全局配置 `barMinSize` / `barMaxSize`。 diff --git a/.storybook/stories/components/PulseAnimation/index.stories.tsx b/.storybook/stories/components/PulseAnimation/index.stories.tsx index 7983121..5cdc6c3 100644 --- a/.storybook/stories/components/PulseAnimation/index.stories.tsx +++ b/.storybook/stories/components/PulseAnimation/index.stories.tsx @@ -2,6 +2,8 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { Card, Space, Typography } from 'antd'; import PulseAnimation from '../../../../src/components/Animation/Pulse'; import storyI18n, { storyT, useStoryT } from '../../../locales'; +import apiDocEN from './api-doc.en-US.md?raw'; +import apiDocCN from './api-doc.zh-CN.md?raw'; import introduceEN from './introduce.en-US.md?raw'; import introduceCN from './introduce.zh-CN.md?raw'; @@ -11,7 +13,7 @@ const meta: Meta<typeof PulseAnimation> = { parameters: { docs: { description: { - component: storyI18n.language === 'zh-CN' ? introduceCN : introduceEN, + component: storyI18n.language === 'zh-CN' ? introduceCN + apiDocCN : introduceEN + apiDocEN, }, }, }, diff --git a/.storybook/stories/components/PulseAnimation/introduce.en-US.md b/.storybook/stories/components/PulseAnimation/introduce.en-US.md index 565eb45..9b765d6 100644 --- a/.storybook/stories/components/PulseAnimation/introduce.en-US.md +++ b/.storybook/stories/components/PulseAnimation/introduce.en-US.md @@ -1,4 +1,19 @@ -A pulse bar animation component, suitable for audio activity or lightweight loading -feedback scenarios. +A lightweight pulse-bar animation component, ideal for audio activity visualization or subtle loading feedback. It renders a row of bars whose heights animate in a staggered wave. -> Use `token.AnimationPulse` to globally configure `barMinSize` and `barMaxSize` properties +## When to use + +Use `PulseAnimation` to hint "something is active" without text — audio recording/playing indicators, live data streams, or a minimal loading placeholder where a spinner feels too heavy. + +## Key features + +- **Configurable bars** — `bars` sets the count, `barGap` the spacing, `barColor` the fill color. +- **Staggered wave** — `delayRate` offsets each bar's animation so the group reads as a traveling pulse. +- **Timing** — `duration` controls the cycle length in seconds. +- **Themable** — `barStyle` and the `token.AnimationPulse` token tune the look globally. +- **No deps** — pure CSS animation, cheap to mount many instances. + +## Usage notes + +- `bars`, `barGap`, `duration`, `delayRate` all have sensible defaults, so a bare `<PulseAnimation />` already animates. +- For a custom palette, set `barColor` per instance or configure `token.AnimationPulse` once for the whole app. +- It fills its parent's width via CSS grid; give the parent a height or the bars will collapse. diff --git a/.storybook/stories/components/PulseAnimation/introduce.zh-CN.md b/.storybook/stories/components/PulseAnimation/introduce.zh-CN.md index 9fdc684..a71aea7 100644 --- a/.storybook/stories/components/PulseAnimation/introduce.zh-CN.md +++ b/.storybook/stories/components/PulseAnimation/introduce.zh-CN.md @@ -1,3 +1,19 @@ -一个脉冲柱状动画组件,适合音频活动态或轻量加载反馈场景。 +轻量的脉冲柱状动画组件,适合音频活动态可视化或细微的加载反馈。它渲染一排高度按错峰波形起伏的柱子。 -> 使用 `token.AnimationPulse` 来全局配置 `barMinSize` 和 `barMaxSize` 属性 +## 适用场景 + +当想用无文字的方式提示"某事物正在活动"时——录音/播放指示、实时数据流,或觉得 spinner 过重时的极简加载占位——使用 `PulseAnimation`。 + +## 核心特性 + +- **可配置柱条** —— `bars` 设置数量,`barGap` 设置间距,`barColor` 设置填充色。 +- **错峰波形** —— `delayRate` 为每个柱子的动画添加偏移,使整体呈现行进的脉冲感。 +- **节奏控制** —— `duration` 控制单周期时长(秒)。 +- **主题化** —— `barStyle` 与 `token.AnimationPulse` token 可全局微调外观。 +- **无依赖** —— 纯 CSS 动画,挂载大量实例也很廉价。 + +## 使用注意 + +- `bars`、`barGap`、`duration`、`delayRate` 均有合理默认值,裸 `<PulseAnimation />` 即可动起来。 +- 需要自定义配色时,可在实例上设 `barColor`,或通过 `token.AnimationPulse` 全局统一配置。 +- 组件通过 CSS grid 填满父容器宽度;请给父容器一个高度,否则柱子会塌陷。 diff --git a/.storybook/stories/components/VirtualTextViewer/api-doc.en-US.md b/.storybook/stories/components/VirtualTextViewer/api-doc.en-US.md new file mode 100644 index 0000000..983ab3e --- /dev/null +++ b/.storybook/stories/components/VirtualTextViewer/api-doc.en-US.md @@ -0,0 +1,17 @@ +## API + +`VirtualTextViewerProps` extends `Omit<HTMLAttributes<HTMLDivElement>, 'children'>`, so all standard div attributes (except `children`) apply. Key props are listed below. + +| Prop | Description | Type | Default | +| ----------------------------------- | --------------------------------------------------------------------------------- | -------------------------- | ---------------- | +| `value` | Large plain-text content to render. | `string \| null` | - | +| `height` | Height of the scroll viewport. | `CSSProperties['height']` | `'100%'` | +| `lineHeight` | Fixed line height used by both Pretext layout and row projection. | `number` | `22` | +| `overscan` | Extra rows rendered before and after the viewport. | `number` | `8` | +| `font` | Canvas font shorthand passed to Pretext; keep in sync with the rendered CSS font. | `string` | monospace preset | +| `letterSpacing` | Letter spacing in CSS pixels, forwarded to Pretext and CSS. | `number` | `0` | +| `wordBreak` | Word-break mode forwarded to Pretext. | `WordBreakMode` | `'normal'` | +| `tabSize` | CSS tab-size for preserved tab characters. | `number` | `8` | +| `empty` | Content shown when the input is empty. | `ReactNode` | - | +| `contentClassName` / `contentStyle` | Class / style for the absolute content canvas. | `string` / `CSSProperties` | - | +| `lineClassName` / `lineStyle` | Class / style for each projected row. | `string` / `CSSProperties` | - | diff --git a/.storybook/stories/components/VirtualTextViewer/api-doc.zh-CN.md b/.storybook/stories/components/VirtualTextViewer/api-doc.zh-CN.md new file mode 100644 index 0000000..556d4f9 --- /dev/null +++ b/.storybook/stories/components/VirtualTextViewer/api-doc.zh-CN.md @@ -0,0 +1,17 @@ +## API + +`VirtualTextViewerProps` 扩展自 `Omit<HTMLAttributes<HTMLDivElement>, 'children'>`,因此除 `children` 外的所有标准 div 属性均可用。主要属性如下。 + +| 属性 | 说明 | 类型 | 默认值 | +| ----------------------------------- | ---------------------------------------------------------------- | -------------------------- | ---------- | +| `value` | 要渲染的超大纯文本内容。 | `string \| null` | - | +| `height` | 滚动视口的高度。 | `CSSProperties['height']` | `'100%'` | +| `lineHeight` | 同时用于 Pretext 布局与行投影的固定行高。 | `number` | `22` | +| `overscan` | 视口前后额外渲染的缓冲行数。 | `number` | `8` | +| `font` | 传给 Pretext 的 Canvas font 简写,需与实际渲染的 CSS font 一致。 | `string` | 等宽预设 | +| `letterSpacing` | 字间距(CSS 像素),同时传给 Pretext 与 CSS。 | `number` | `0` | +| `wordBreak` | 透传给 Pretext 的断词模式。 | `WordBreakMode` | `'normal'` | +| `tabSize` | 用于渲染保留制表符的 CSS tab-size。 | `number` | `8` | +| `empty` | 输入为空时显示的内容。 | `ReactNode` | - | +| `contentClassName` / `contentStyle` | 绝对定位内容画布的类名 / 样式。 | `string` / `CSSProperties` | - | +| `lineClassName` / `lineStyle` | 每一条投影行的类名 / 样式。 | `string` / `CSSProperties` | - | diff --git a/.storybook/stories/components/VirtualTextViewer/index.stories.tsx b/.storybook/stories/components/VirtualTextViewer/index.stories.tsx index 26f54d4..015765c 100644 --- a/.storybook/stories/components/VirtualTextViewer/index.stories.tsx +++ b/.storybook/stories/components/VirtualTextViewer/index.stories.tsx @@ -4,6 +4,8 @@ import type { ThemeVars } from 'storybook/theming'; import VirtualTextViewer from '../../../../src/components/VirtualTextViewer'; import storyI18n, { storyT, useStoryT } from '../../../locales'; import { getGlobalValueFromUrl } from '../../../utils/global'; +import apiDocEN from './api-doc.en-US.md?raw'; +import apiDocCN from './api-doc.zh-CN.md?raw'; import introduceEN from './introduce.en-US.md?raw'; import introduceCN from './introduce.zh-CN.md?raw'; @@ -65,7 +67,7 @@ const meta: Meta<typeof VirtualTextViewer> = { parameters: { docs: { description: { - component: storyI18n.language === 'zh-CN' ? introduceCN : introduceEN, + component: storyI18n.language === 'zh-CN' ? introduceCN + apiDocCN : introduceEN + apiDocEN, }, }, }, diff --git a/.storybook/stories/components/VirtualTextViewer/introduce.en-US.md b/.storybook/stories/components/VirtualTextViewer/introduce.en-US.md index 9fec4a2..7016a79 100644 --- a/.storybook/stories/components/VirtualTextViewer/introduce.en-US.md +++ b/.storybook/stories/components/VirtualTextViewer/introduce.en-US.md @@ -1 +1,18 @@ -A simplified large-text virtualization component built on top of Pretext. It predicts wrapped line geometry without DOM text measurement and only materializes the visible line window. +A virtualized plain-text viewer for very large documents. It measures text with a canvas-based layout engine (Pretext) and only renders the visible lines, so it stays smooth at hundreds of thousands of lines. + +## When to use + +Use `VirtualTextViewer` to display huge read-only text — logs, source files, SQL dumps, generated artifacts — where rendering the whole string with a normal element would freeze the browser. + +## Key features + +- **Canvas layout** — uses Pretext to compute line wrapping with a real font metric, so projection matches what a browser would paint. +- **Windowing** — only visible lines plus an `overscan` buffer are mounted; scrolling is O(1) in DOM nodes. +- **Typography control** — `lineHeight`, `font`, `letterSpacing`, `tabSize`, `wordBreak` tune the rendering and stay in sync with the canvas metric. +- **Per-row styling** — `lineClassName` / `lineStyle` / `contentClassName` / `contentStyle` let you theme the surface and individual rows. + +## Usage notes + +- Keep `font` in sync with the actual CSS font; a mismatch makes the canvas metric diverge from the painted text. +- `value` of `null`/`undefined` shows the `empty` placeholder instead of crashing. +- For best performance, fix `height` (viewport) and `lineHeight`; avoid changing `font` on every render. diff --git a/.storybook/stories/components/VirtualTextViewer/introduce.zh-CN.md b/.storybook/stories/components/VirtualTextViewer/introduce.zh-CN.md index a7b399d..e3c5dbf 100644 --- a/.storybook/stories/components/VirtualTextViewer/introduce.zh-CN.md +++ b/.storybook/stories/components/VirtualTextViewer/introduce.zh-CN.md @@ -1 +1,18 @@ -一个基于 Pretext 的简化版大文本虚拟查看组件。它不依赖 DOM 文本测量来预测换行几何,并且只物化可见行窗口。 +面向超大文本文档的虚拟化处理只读文本查看器。它使用基于 canvas 的排版引擎(Pretext)测量文本,仅渲染可见行,因此在数十万行规模下依然流畅。 + +## 适用场景 + +当需要展示超大的只读文本——日志、源码文件、SQL dump、生成产物——而用普通元素渲染整段字符串会让浏览器卡死时,使用 `VirtualTextViewer`。 + +## 核心特性 + +- **Canvas 排版** —— 借助 Pretext 以真实字体度量计算换行,使行投影与浏览器实际绘制一致。 +- **窗口化** —— 仅挂载可见行及 `overscan` 缓冲,滚动时 DOM 节点数为 O(1)。 +- **排版可控** —— `lineHeight`、`font`、`letterSpacing`、`tabSize`、`wordBreak` 调整渲染,并与 canvas 度量保持同步。 +- **逐行样式** —— `lineClassName` / `lineStyle` / `contentClassName` / `contentStyle` 可对整体与单行进行主题定制。 + +## 使用注意 + +- 请让 `font` 与实际 CSS 字体保持一致;不一致会导致 canvas 度量与绘制文本错位。 +- `value` 为 `null`/`undefined` 时显示 `empty` 占位而非报错。 +- 为获得最佳性能,请固定 `height`(视口)与 `lineHeight`,并避免在每次渲染时改动 `font`。 From 23ecffce673df4d94b196dac6ec5ba7ea1177246 Mon Sep 17 00:00:00 2001 From: Fengbao Li <shijistar@gmail.com> Date: Wed, 5 Aug 2026 11:04:56 +0800 Subject: [PATCH 13/39] docs(storybook): add newline between intro and api-doc in component descriptions --- .storybook/stories/components/BreakLines/index.stories.tsx | 2 +- .storybook/stories/components/ColumnSetting/index.stories.tsx | 2 +- .storybook/stories/components/ConfigProvider/index.stories.tsx | 2 +- .storybook/stories/components/ConfirmAction/index.stories.tsx | 2 +- .storybook/stories/components/ContextMenu/index.stories.tsx | 2 +- .../stories/components/DeleteConfirmAction/index.stories.tsx | 2 +- .storybook/stories/components/EditableText/index.stories.tsx | 2 +- .../stories/components/EllipsisTypography/index.stories.tsx | 2 +- .storybook/stories/components/FloatDrawer/index.stories.tsx | 2 +- .storybook/stories/components/FormItemControl/index.stories.tsx | 2 +- .storybook/stories/components/Iconfont/index.stories.tsx | 2 +- .storybook/stories/components/Loading/index.stories.tsx | 2 +- .storybook/stories/components/ModalAction/index.stories.tsx | 2 +- .storybook/stories/components/OverflowTags/index.stories.tsx | 2 +- .storybook/stories/components/PulseAnimation/index.stories.tsx | 2 +- .../stories/components/VirtualTextViewer/index.stories.tsx | 2 +- .storybook/stories/hooks/useAudioPlayer.stories.tsx | 1 - 17 files changed, 16 insertions(+), 17 deletions(-) diff --git a/.storybook/stories/components/BreakLines/index.stories.tsx b/.storybook/stories/components/BreakLines/index.stories.tsx index 7f9d747..60a2534 100644 --- a/.storybook/stories/components/BreakLines/index.stories.tsx +++ b/.storybook/stories/components/BreakLines/index.stories.tsx @@ -12,7 +12,7 @@ const meta: Meta<typeof BreakLines> = { parameters: { docs: { description: { - component: storyI18n.language === 'zh-CN' ? introduceCN + apiDocCN : introduceEN + apiDocEN, + component: storyI18n.language === 'zh-CN' ? `${introduceCN}\n${apiDocCN}` : `${introduceEN}\n${apiDocEN}`, }, }, }, diff --git a/.storybook/stories/components/ColumnSetting/index.stories.tsx b/.storybook/stories/components/ColumnSetting/index.stories.tsx index 857b7db..0f9c839 100644 --- a/.storybook/stories/components/ColumnSetting/index.stories.tsx +++ b/.storybook/stories/components/ColumnSetting/index.stories.tsx @@ -17,7 +17,7 @@ const meta: Meta<ColumnSettingProps> = { parameters: { docs: { description: { - component: storyI18n.language === 'zh-CN' ? introduceCN + apiDocCN : introduceEN + apiDocEN, + component: storyI18n.language === 'zh-CN' ? `${introduceCN}\n${apiDocCN}` : `${introduceEN}\n${apiDocEN}`, }, }, }, diff --git a/.storybook/stories/components/ConfigProvider/index.stories.tsx b/.storybook/stories/components/ConfigProvider/index.stories.tsx index 6b60eaa..a19bae8 100644 --- a/.storybook/stories/components/ConfigProvider/index.stories.tsx +++ b/.storybook/stories/components/ConfigProvider/index.stories.tsx @@ -16,7 +16,7 @@ const meta: Meta<ConfigProviderStoryArgs> = { parameters: { docs: { description: { - component: storyI18n.language === 'zh-CN' ? introduceCN + apiDocCN : introduceEN + apiDocEN, + component: storyI18n.language === 'zh-CN' ? `${introduceCN}\n${apiDocCN}` : `${introduceEN}\n${apiDocEN}`, }, }, }, diff --git a/.storybook/stories/components/ConfirmAction/index.stories.tsx b/.storybook/stories/components/ConfirmAction/index.stories.tsx index 28417db..376880b 100644 --- a/.storybook/stories/components/ConfirmAction/index.stories.tsx +++ b/.storybook/stories/components/ConfirmAction/index.stories.tsx @@ -22,7 +22,7 @@ const meta: Meta<ConfirmActionStoryArgs> = { parameters: { docs: { description: { - component: storyI18n.language === 'zh-CN' ? introduceCN + apiDocCN : introduceEN + apiDocEN, + component: storyI18n.language === 'zh-CN' ? `${introduceCN}\n${apiDocCN}` : `${introduceEN}\n${apiDocEN}`, }, }, }, diff --git a/.storybook/stories/components/ContextMenu/index.stories.tsx b/.storybook/stories/components/ContextMenu/index.stories.tsx index af9f644..37629d9 100644 --- a/.storybook/stories/components/ContextMenu/index.stories.tsx +++ b/.storybook/stories/components/ContextMenu/index.stories.tsx @@ -14,7 +14,7 @@ const meta: Meta<typeof ContextMenu> = { parameters: { docs: { description: { - component: storyI18n.language === 'zh-CN' ? introduceCN + apiDocCN : introduceEN + apiDocEN, + component: storyI18n.language === 'zh-CN' ? `${introduceCN}\n${apiDocCN}` : `${introduceEN}\n${apiDocEN}`, }, }, }, diff --git a/.storybook/stories/components/DeleteConfirmAction/index.stories.tsx b/.storybook/stories/components/DeleteConfirmAction/index.stories.tsx index f4645e1..76e095c 100644 --- a/.storybook/stories/components/DeleteConfirmAction/index.stories.tsx +++ b/.storybook/stories/components/DeleteConfirmAction/index.stories.tsx @@ -22,7 +22,7 @@ const meta: Meta<DeleteConfirmActionStoryArgs> = { parameters: { docs: { description: { - component: storyI18n.language === 'zh-CN' ? introduceCN + apiDocCN : introduceEN + apiDocEN, + component: storyI18n.language === 'zh-CN' ? `${introduceCN}\n${apiDocCN}` : `${introduceEN}\n${apiDocEN}`, }, }, }, diff --git a/.storybook/stories/components/EditableText/index.stories.tsx b/.storybook/stories/components/EditableText/index.stories.tsx index 84fb7be..9308f63 100644 --- a/.storybook/stories/components/EditableText/index.stories.tsx +++ b/.storybook/stories/components/EditableText/index.stories.tsx @@ -13,7 +13,7 @@ const meta: Meta<EditableTextProps> = { parameters: { docs: { description: { - component: storyI18n.language === 'zh-CN' ? introduceCN + apiDocCN : introduceEN + apiDocEN, + component: storyI18n.language === 'zh-CN' ? `${introduceCN}\n${apiDocCN}` : `${introduceEN}\n${apiDocEN}`, }, }, }, diff --git a/.storybook/stories/components/EllipsisTypography/index.stories.tsx b/.storybook/stories/components/EllipsisTypography/index.stories.tsx index 2ff4490..395ec9c 100644 --- a/.storybook/stories/components/EllipsisTypography/index.stories.tsx +++ b/.storybook/stories/components/EllipsisTypography/index.stories.tsx @@ -16,7 +16,7 @@ const meta: Meta = { parameters: { docs: { description: { - component: storyI18n.language === 'zh-CN' ? introduceCN + apiDocCN : introduceEN + apiDocEN, + component: storyI18n.language === 'zh-CN' ? `${introduceCN}\n${apiDocCN}` : `${introduceEN}\n${apiDocEN}`, }, }, }, diff --git a/.storybook/stories/components/FloatDrawer/index.stories.tsx b/.storybook/stories/components/FloatDrawer/index.stories.tsx index 6cab83e..dafbae1 100644 --- a/.storybook/stories/components/FloatDrawer/index.stories.tsx +++ b/.storybook/stories/components/FloatDrawer/index.stories.tsx @@ -13,7 +13,7 @@ const meta: Meta<typeof FloatDrawer> = { parameters: { docs: { description: { - component: storyI18n.language === 'zh-CN' ? introduceCN + apiDocCN : introduceEN + apiDocEN, + component: storyI18n.language === 'zh-CN' ? `${introduceCN}\n${apiDocCN}` : `${introduceEN}\n${apiDocEN}`, }, }, }, diff --git a/.storybook/stories/components/FormItemControl/index.stories.tsx b/.storybook/stories/components/FormItemControl/index.stories.tsx index f5e09e9..9d3626f 100644 --- a/.storybook/stories/components/FormItemControl/index.stories.tsx +++ b/.storybook/stories/components/FormItemControl/index.stories.tsx @@ -13,7 +13,7 @@ const meta: Meta<typeof FormItemControl> = { parameters: { docs: { description: { - component: storyI18n.language === 'zh-CN' ? introduceCN + apiDocCN : introduceEN + apiDocEN, + component: storyI18n.language === 'zh-CN' ? `${introduceCN}\n${apiDocCN}` : `${introduceEN}\n${apiDocEN}`, }, }, }, diff --git a/.storybook/stories/components/Iconfont/index.stories.tsx b/.storybook/stories/components/Iconfont/index.stories.tsx index e5a037e..7387103 100644 --- a/.storybook/stories/components/Iconfont/index.stories.tsx +++ b/.storybook/stories/components/Iconfont/index.stories.tsx @@ -35,7 +35,7 @@ const meta: Meta<IconfontStoryArgs> = { parameters: { docs: { description: { - component: storyI18n.language === 'zh-CN' ? introduceCN + apiDocCN : introduceEN + apiDocEN, + component: storyI18n.language === 'zh-CN' ? `${introduceCN}\n${apiDocCN}` : `${introduceEN}\n${apiDocEN}`, }, }, }, diff --git a/.storybook/stories/components/Loading/index.stories.tsx b/.storybook/stories/components/Loading/index.stories.tsx index 186e182..9ef738d 100644 --- a/.storybook/stories/components/Loading/index.stories.tsx +++ b/.storybook/stories/components/Loading/index.stories.tsx @@ -13,7 +13,7 @@ const meta: Meta<typeof Loading> = { parameters: { docs: { description: { - component: storyI18n.language === 'zh-CN' ? introduceCN + apiDocCN : introduceEN + apiDocEN, + component: storyI18n.language === 'zh-CN' ? `${introduceCN}\n${apiDocCN}` : `${introduceEN}\n${apiDocEN}`, }, }, }, diff --git a/.storybook/stories/components/ModalAction/index.stories.tsx b/.storybook/stories/components/ModalAction/index.stories.tsx index a05a25e..be2ed05 100644 --- a/.storybook/stories/components/ModalAction/index.stories.tsx +++ b/.storybook/stories/components/ModalAction/index.stories.tsx @@ -58,7 +58,7 @@ const meta: Meta<ModalActionStoryArgs> = { parameters: { docs: { description: { - component: storyI18n.language === 'zh-CN' ? introduceCN + apiDocCN : introduceEN + apiDocEN, + component: storyI18n.language === 'zh-CN' ? `${introduceCN}\n${apiDocCN}` : `${introduceEN}\n${apiDocEN}`, }, }, }, diff --git a/.storybook/stories/components/OverflowTags/index.stories.tsx b/.storybook/stories/components/OverflowTags/index.stories.tsx index e8a8992..6a09f12 100644 --- a/.storybook/stories/components/OverflowTags/index.stories.tsx +++ b/.storybook/stories/components/OverflowTags/index.stories.tsx @@ -35,7 +35,7 @@ const meta: Meta<typeof OverflowTags<TagItem>> = { parameters: { docs: { description: { - component: storyI18n.language === 'zh-CN' ? introduceCN + apiDocCN : introduceEn + apiDocEN, + component: storyI18n.language === 'zh-CN' ? `${introduceCN}\n${apiDocCN}` : `${introduceEn}\n${apiDocEN}`, }, }, }, diff --git a/.storybook/stories/components/PulseAnimation/index.stories.tsx b/.storybook/stories/components/PulseAnimation/index.stories.tsx index 5cdc6c3..e8d0319 100644 --- a/.storybook/stories/components/PulseAnimation/index.stories.tsx +++ b/.storybook/stories/components/PulseAnimation/index.stories.tsx @@ -13,7 +13,7 @@ const meta: Meta<typeof PulseAnimation> = { parameters: { docs: { description: { - component: storyI18n.language === 'zh-CN' ? introduceCN + apiDocCN : introduceEN + apiDocEN, + component: storyI18n.language === 'zh-CN' ? `${introduceCN}\n${apiDocCN}` : `${introduceEN}\n${apiDocEN}`, }, }, }, diff --git a/.storybook/stories/components/VirtualTextViewer/index.stories.tsx b/.storybook/stories/components/VirtualTextViewer/index.stories.tsx index 015765c..f77116c 100644 --- a/.storybook/stories/components/VirtualTextViewer/index.stories.tsx +++ b/.storybook/stories/components/VirtualTextViewer/index.stories.tsx @@ -67,7 +67,7 @@ const meta: Meta<typeof VirtualTextViewer> = { parameters: { docs: { description: { - component: storyI18n.language === 'zh-CN' ? introduceCN + apiDocCN : introduceEN + apiDocEN, + component: storyI18n.language === 'zh-CN' ? `${introduceCN}\n${apiDocCN}` : `${introduceEN}\n${apiDocEN}`, }, }, }, diff --git a/.storybook/stories/hooks/useAudioPlayer.stories.tsx b/.storybook/stories/hooks/useAudioPlayer.stories.tsx index 2869fc8..7f8af1f 100644 --- a/.storybook/stories/hooks/useAudioPlayer.stories.tsx +++ b/.storybook/stories/hooks/useAudioPlayer.stories.tsx @@ -3,7 +3,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { Button, Card, Divider, Input, List, Slider, Space, Tag, Typography } from 'antd'; import { useRefFunction } from '../../../src/hooks'; import useAudioPlayer from '../../../src/hooks/useAudioPlayer'; -// @ts-expect-error: because Vite handles mp3 imports natively import musicUrl from '../../assets/sample.mp3'; import { storyT, useStoryT } from '../../locales'; From 25c8e8c16686b7c50b93220140cf9624ac28dc55 Mon Sep 17 00:00:00 2001 From: Fengbao Li <shijistar@gmail.com> Date: Wed, 5 Aug 2026 11:08:41 +0800 Subject: [PATCH 14/39] refactor(storybook): remove subcomponents from ConfirmAction story metadata --- .../stories/components/ConfirmAction/index.stories.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.storybook/stories/components/ConfirmAction/index.stories.tsx b/.storybook/stories/components/ConfirmAction/index.stories.tsx index 376880b..bb1be3f 100644 --- a/.storybook/stories/components/ConfirmAction/index.stories.tsx +++ b/.storybook/stories/components/ConfirmAction/index.stories.tsx @@ -97,11 +97,6 @@ const meta: Meta<ConfirmActionStoryArgs> = { table: { defaultValue: { summary: 'undefined' } }, }, }, - subcomponents: { - Button: ConfirmAction.Button, - Switch: ConfirmAction.Switch, - Link: ConfirmAction.Link, - }, }; export default meta; From 582f844b23b49a61c67c56b582dca77c3c724875 Mon Sep 17 00:00:00 2001 From: Fengbao Li <shijistar@gmail.com> Date: Wed, 5 Aug 2026 11:22:57 +0800 Subject: [PATCH 15/39] docs(storybook): migrate hooks/utils stories to component-standard markdown docs - Convert flat hooks stories (useDebounce, useAudioPlayer, useStreamDownloader) to per-hook folders with bilingual introduce + api-doc markdown - Align Utils/StreamDownloader story with the same component doc standard - Wire index.stories.tsx component descriptions via storyI18n.language ternary - Remove un-referenced stray introduce.mdx in Utils/StreamDownloader --- .../hooks/useAudioPlayer/api-doc.en-US.md | 39 +++++++++++++++++++ .../hooks/useAudioPlayer/api-doc.zh-CN.md | 39 +++++++++++++++++++ .../index.stories.tsx} | 16 +++++--- .../hooks/useAudioPlayer/introduce.en-US.md | 22 +++++++++++ .../hooks/useAudioPlayer/introduce.zh-CN.md | 22 +++++++++++ .../hooks/useDebounce/api-doc.en-US.md | 19 +++++++++ .../hooks/useDebounce/api-doc.zh-CN.md | 19 +++++++++ .../index.stories.tsx} | 11 ++++-- .../hooks/useDebounce/introduce.en-US.md | 21 ++++++++++ .../hooks/useDebounce/introduce.zh-CN.md | 21 ++++++++++ .../useStreamDownloader/api-doc.en-US.md | 22 +++++++++++ .../useStreamDownloader/api-doc.zh-CN.md | 22 +++++++++++ .../index.stories.tsx} | 15 +++++-- .../useStreamDownloader/introduce.en-US.md | 21 ++++++++++ .../useStreamDownloader/introduce.zh-CN.md | 21 ++++++++++ .../utils/StreamDownloader/api-doc.en-US.md | 35 +++++++++++++++++ .../utils/StreamDownloader/api-doc.zh-CN.md | 35 +++++++++++++++++ .../utils/StreamDownloader/index.stories.tsx | 8 +++- .../utils/StreamDownloader/introduce.en-US.md | 23 +++++++++++ .../utils/StreamDownloader/introduce.mdx | 3 -- .../utils/StreamDownloader/introduce.zh-CN.md | 23 +++++++++++ 21 files changed, 439 insertions(+), 18 deletions(-) create mode 100644 .storybook/stories/hooks/useAudioPlayer/api-doc.en-US.md create mode 100644 .storybook/stories/hooks/useAudioPlayer/api-doc.zh-CN.md rename .storybook/stories/hooks/{useAudioPlayer.stories.tsx => useAudioPlayer/index.stories.tsx} (93%) create mode 100644 .storybook/stories/hooks/useAudioPlayer/introduce.en-US.md create mode 100644 .storybook/stories/hooks/useAudioPlayer/introduce.zh-CN.md create mode 100644 .storybook/stories/hooks/useDebounce/api-doc.en-US.md create mode 100644 .storybook/stories/hooks/useDebounce/api-doc.zh-CN.md rename .storybook/stories/hooks/{useDebounce.stories.tsx => useDebounce/index.stories.tsx} (93%) create mode 100644 .storybook/stories/hooks/useDebounce/introduce.en-US.md create mode 100644 .storybook/stories/hooks/useDebounce/introduce.zh-CN.md create mode 100644 .storybook/stories/hooks/useStreamDownloader/api-doc.en-US.md create mode 100644 .storybook/stories/hooks/useStreamDownloader/api-doc.zh-CN.md rename .storybook/stories/hooks/{useStreamDownloader.stories.tsx => useStreamDownloader/index.stories.tsx} (92%) create mode 100644 .storybook/stories/hooks/useStreamDownloader/introduce.en-US.md create mode 100644 .storybook/stories/hooks/useStreamDownloader/introduce.zh-CN.md create mode 100644 .storybook/stories/utils/StreamDownloader/api-doc.en-US.md create mode 100644 .storybook/stories/utils/StreamDownloader/api-doc.zh-CN.md create mode 100644 .storybook/stories/utils/StreamDownloader/introduce.en-US.md delete mode 100644 .storybook/stories/utils/StreamDownloader/introduce.mdx create mode 100644 .storybook/stories/utils/StreamDownloader/introduce.zh-CN.md diff --git a/.storybook/stories/hooks/useAudioPlayer/api-doc.en-US.md b/.storybook/stories/hooks/useAudioPlayer/api-doc.en-US.md new file mode 100644 index 0000000..8b8244f --- /dev/null +++ b/.storybook/stories/hooks/useAudioPlayer/api-doc.en-US.md @@ -0,0 +1,39 @@ +## API + +### Options — AudioPlayerInit + +| Name | Description | Type | Default | +| ------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------ | ------- | +| `source` | Audio source (URL or streaming data), optionally provided as a lazy factory | `AudioSource \| (() => AudioSource \| Promise<AudioSource>)` | - | +| `mimeType` | MIME type of the audio (e.g. `audio/mpeg`, `audio/wav`) | `string` | - | +| `volume` | Initial volume level (0-1) | `number` | `0.5` | +| `crossOrigin` | Cross-origin setting for the audio element | `HTMLMediaElement['crossOrigin']` | - | +| `onPlay` | Callback when audio starts playing | `() => void` | - | +| `onPause` | Callback when audio is paused | `() => void` | - | +| `onStop` | Callback when audio is stopped | `() => void` | - | +| `onPlayEnd` | Callback when audio playback ends | `() => void` | - | +| `onError` | Callback when an error occurs | `(error: any) => void` | - | + +### Return — AudioPlayer instance + +| Method | Description | Signature | +| --------------------- | ------------------------------------------------------------- | ---------------------------------------------------------------- | +| `isPlaying` | Whether audio is currently playing (getter) | `boolean` | +| `currentTime` | Current playback time in seconds (getter) | `number` | +| `duration` | Total audio duration in seconds (getter) | `number` | +| `volume` | Current volume value 0-1 (getter) | `number` | +| `play` | Play audio; resumes from the pause position | `() => Promise<void>` | +| `pause` | Pause playback; resumes from the current position | `() => void` | +| `stop` | Stop playback; progress resets to the beginning | `() => void` | +| `seek` | Set current playback time (seconds) | `(time: number) => void` | +| `seekForward` | Seek forward by a number of seconds | `(seconds: number) => void` | +| `seekBackward` | Seek backward by a number of seconds | `(seconds: number) => void` | +| `setAudioSource` | Update the audio source (URL or streaming data) | `(source?: AudioSource) => Promise<{ stopLoading: () => void }>` | +| `volumeUp` | Increase volume (default 10% per call) | `(percent?: number) => void` | +| `volumeDown` | Decrease volume (default 10% per call) | `(percent?: number) => void` | +| `setVolume` | Set volume to a specific value (0-1) | `(value: number) => void` | +| `addEventListener` | Add audio event listener (delegated to `HTMLAudioElement`) | `HTMLAudioElement['addEventListener']` | +| `removeEventListener` | Remove audio event listener | `HTMLAudioElement['removeEventListener']` | +| `dispose` | Release resources (pause, clear source, close `AudioContext`) | `() => void` | + +> `AudioSource = string \| ReadableStreamDefaultReader<Uint8Array> \| ArrayBuffer \| Uint8Array \| Blob` diff --git a/.storybook/stories/hooks/useAudioPlayer/api-doc.zh-CN.md b/.storybook/stories/hooks/useAudioPlayer/api-doc.zh-CN.md new file mode 100644 index 0000000..0382dd6 --- /dev/null +++ b/.storybook/stories/hooks/useAudioPlayer/api-doc.zh-CN.md @@ -0,0 +1,39 @@ +## API + +### 参数 —— AudioPlayerInit + +| 参数 | 说明 | 类型 | 默认值 | +| ------------- | ------------------------------------------------ | ------------------------------------------------------------ | ------ | +| `source` | 音频源(URL 或流数据),也可传入惰性工厂函数 | `AudioSource \| (() => AudioSource \| Promise<AudioSource>)` | - | +| `mimeType` | 音频的 MIME 类型(如 `audio/mpeg`、`audio/wav`) | `string` | - | +| `volume` | 初始音量(0-1) | `number` | `0.5` | +| `crossOrigin` | 音频元素的跨域设置 | `HTMLMediaElement['crossOrigin']` | - | +| `onPlay` | 音频开始播放时的回调 | `() => void` | - | +| `onPause` | 音频暂停时的回调 | `() => void` | - | +| `onStop` | 音频停止时的回调 | `() => void` | - | +| `onPlayEnd` | 音频播放结束时的回调 | `() => void` | - | +| `onError` | 发生错误时的回调 | `(error: any) => void` | - | + +### 返回值 —— AudioPlayer 实例 + +| 方法 | 说明 | 签名 | +| --------------------- | ----------------------------------------------- | ---------------------------------------------------------------- | +| `isPlaying` | 音频是否正在播放(getter) | `boolean` | +| `currentTime` | 当前播放时间(秒,getter) | `number` | +| `duration` | 音频总时长(秒,getter) | `number` | +| `volume` | 当前音量值 0-1(getter) | `number` | +| `play` | 播放音频;暂停后从暂停位置继续 | `() => Promise<void>` | +| `pause` | 暂停播放;再次播放时从当前位置继续 | `() => void` | +| `stop` | 停止播放;进度重置到开始位置 | `() => void` | +| `seek` | 设置当前播放时间(秒) | `(time: number) => void` | +| `seekForward` | 向前跳转一定秒数 | `(seconds: number) => void` | +| `seekBackward` | 向后跳转一定秒数 | `(seconds: number) => void` | +| `setAudioSource` | 更新音频源(URL 或流数据) | `(source?: AudioSource) => Promise<{ stopLoading: () => void }>` | +| `volumeUp` | 增加音量(每次默认 10%) | `(percent?: number) => void` | +| `volumeDown` | 降低音量(每次默认 10%) | `(percent?: number) => void` | +| `setVolume` | 将音量设置为特定值(0-1) | `(value: number) => void` | +| `addEventListener` | 添加音频事件监听器(委托给 `HTMLAudioElement`) | `HTMLAudioElement['addEventListener']` | +| `removeEventListener` | 移除音频事件监听器 | `HTMLAudioElement['removeEventListener']` | +| `dispose` | 释放资源(暂停、清空源、关闭 `AudioContext`) | `() => void` | + +> `AudioSource = string \| ReadableStreamDefaultReader<Uint8Array> \| ArrayBuffer \| Uint8Array \| Blob` diff --git a/.storybook/stories/hooks/useAudioPlayer.stories.tsx b/.storybook/stories/hooks/useAudioPlayer/index.stories.tsx similarity index 93% rename from .storybook/stories/hooks/useAudioPlayer.stories.tsx rename to .storybook/stories/hooks/useAudioPlayer/index.stories.tsx index 7f8af1f..98f22b6 100644 --- a/.storybook/stories/hooks/useAudioPlayer.stories.tsx +++ b/.storybook/stories/hooks/useAudioPlayer/index.stories.tsx @@ -1,10 +1,14 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; import { Button, Card, Divider, Input, List, Slider, Space, Tag, Typography } from 'antd'; -import { useRefFunction } from '../../../src/hooks'; -import useAudioPlayer from '../../../src/hooks/useAudioPlayer'; -import musicUrl from '../../assets/sample.mp3'; -import { storyT, useStoryT } from '../../locales'; +import { useRefFunction } from '../../../../src/hooks'; +import useAudioPlayer from '../../../../src/hooks/useAudioPlayer'; +import musicUrl from '../../../assets/sample.mp3'; +import storyI18n, { storyT, useStoryT } from '../../../locales'; +import apiDocEN from './api-doc.en-US.md?raw'; +import apiDocCN from './api-doc.zh-CN.md?raw'; +import introduceEN from './introduce.en-US.md?raw'; +import introduceCN from './introduce.zh-CN.md?raw'; interface UseAudioPlayerStoryArgs { source: string; @@ -22,7 +26,9 @@ const meta: Meta<UseAudioPlayerStoryArgs> = { title: 'Hooks/useAudioPlayer', parameters: { docs: { - description: {}, + description: { + component: storyI18n.language === 'zh-CN' ? `${introduceCN}\n${apiDocCN}` : `${introduceEN}\n${apiDocEN}`, + }, }, }, args: { diff --git a/.storybook/stories/hooks/useAudioPlayer/introduce.en-US.md b/.storybook/stories/hooks/useAudioPlayer/introduce.en-US.md new file mode 100644 index 0000000..aa4cc8a --- /dev/null +++ b/.storybook/stories/hooks/useAudioPlayer/introduce.en-US.md @@ -0,0 +1,22 @@ +Provides a stable `AudioPlayer` class instance for controlling audio playback without any UI. The instance is created once per mounted lifecycle and reused across renders, so imperative methods (`play`, `pause`, `seek`, `setVolume`, …) can be called from event handlers without recreating the player. + +## When to use + +- Building a fully custom player UI (buttons, sliders, progress bar) on top of a headless audio engine. +- Playing audio from streaming sources (`ReadableStreamDefaultReader<Uint8Array>`), `ArrayBuffer`, `Uint8Array`, or `Blob`, not just URLs. +- Cases where you need Web Audio gain-based volume control, fine-grained seek, or audio event listeners. + +## Key features + +- **Stable instance** — the player is memoized for the component lifetime and auto-disposed on unmount. +- **Multiple source types** — URL string or streaming data (`ReadableStreamDefaultReader`, `ArrayBuffer`, `Uint8Array`, `Blob`), including a lazy factory function. +- **Full transport control** — `play` / `pause` / `stop` / `seek` / `seekForward` / `seekBackward` / `setAudioSource`. +- **Volume management** — `setVolume` / `volumeUp` / `volumeDown` with Web Audio `GainNode` when available. +- **Event hooks** — `addEventListener` / `removeEventListener` delegate to the underlying `HTMLAudioElement`. + +## Usage notes + +- If the source is a URL, the audio source must support cross-origin access, otherwise there may be no sound. +- The player is created lazily with `useRef` and disposed in a cleanup effect; passing a new `source` prop does not recreate the instance — call `setAudioSource()` to swap sources. +- Streaming sources use `MediaSource` under the hood; when `MediaSource` is unsupported, playback falls back to one-time `Blob` buffering. +- Volume is clamped to `[0, 1]`; the default initial volume is `0.5`. diff --git a/.storybook/stories/hooks/useAudioPlayer/introduce.zh-CN.md b/.storybook/stories/hooks/useAudioPlayer/introduce.zh-CN.md new file mode 100644 index 0000000..81c34cb --- /dev/null +++ b/.storybook/stories/hooks/useAudioPlayer/introduce.zh-CN.md @@ -0,0 +1,22 @@ +提供稳定的 `AudioPlayer` 类实例用于控制音频播放,无任何 UI。实例在组件挂载期间只创建一次并在渲染间复用,因此可以直接在事件处理器中调用命令式方法(`play`、`pause`、`seek`、`setVolume` 等),无需反复重建播放器。 + +## 适用场景 + +- 基于无头音频引擎自建播放器 UI(按钮、滑块、进度条)。 +- 播放流式数据源(`ReadableStreamDefaultReader<Uint8Array>`)、`ArrayBuffer`、`Uint8Array` 或 `Blob`,而不只是 URL。 +- 需要 Web Audio gain 音量控制、精细 seek 或音频事件监听的场景。 + +## 核心特性 + +- **实例稳定** —— 播放器在组件生命周期内复用,卸载时自动 dispose。 +- **多种数据源** —— URL 字符串或流式数据(`ReadableStreamDefaultReader`、`ArrayBuffer`、`Uint8Array`、`Blob`),也支持惰性工厂函数。 +- **完整播放控制** —— `play` / `pause` / `stop` / `seek` / `seekForward` / `seekBackward` / `setAudioSource`。 +- **音量管理** —— `setVolume` / `volumeUp` / `volumeDown`,可用时基于 Web Audio `GainNode`。 +- **事件钩子** —— `addEventListener` / `removeEventListener` 委托给底层 `HTMLAudioElement`。 + +## 使用注意 + +- 若音频源是 URL,需要音频源支持跨域访问,否则可能没有声音。 +- 播放器通过 `useRef` 惰性创建并在清理 effect 中释放;更换 `source` prop 不会重建实例,需调用 `setAudioSource()`。 +- 流式源底层使用 `MediaSource`;不支持时会退化为一次性 `Blob` 缓冲播放。 +- 音量被限制在 `[0, 1]`,默认初始音量为 `0.5`。 diff --git a/.storybook/stories/hooks/useDebounce/api-doc.en-US.md b/.storybook/stories/hooks/useDebounce/api-doc.en-US.md new file mode 100644 index 0000000..190a8aa --- /dev/null +++ b/.storybook/stories/hooks/useDebounce/api-doc.en-US.md @@ -0,0 +1,19 @@ +## API + +### Options — UseDebounceOptions + +| Name | Description | Type | Default | +| --------- | ---------------------------------------------------------------- | --------- | ------- | +| `leading` | Whether to execute at the start of the wait period | `boolean` | `false` | +| `wait` | Regular debounce interval in milliseconds; `0` means no debounce | `number` | `0` | +| `maxWait` | Maximum wait time in milliseconds; `0` means no maximum | `number` | `0` | + +### Return — DebouncedFunc\<T\> + +| Method | Description | Signature | +| ------------ | ---------------------------------------------------------------------- | ------------------------------------------- | +| `fn` | The debounced function itself | `(...args: Parameters<T>) => ReturnType<T>` | +| `cancel` | Cancel any pending execution of the debounced function | `() => void` | +| `disable` | Disable the debounce; subsequent calls have no effect until re-enabled | `() => void` | +| `enable` | Re-enable the debounce after it has been disabled | `() => void` | +| `isDisabled` | Check whether the debounce is currently disabled | `() => boolean` | diff --git a/.storybook/stories/hooks/useDebounce/api-doc.zh-CN.md b/.storybook/stories/hooks/useDebounce/api-doc.zh-CN.md new file mode 100644 index 0000000..d0519e0 --- /dev/null +++ b/.storybook/stories/hooks/useDebounce/api-doc.zh-CN.md @@ -0,0 +1,19 @@ +## API + +### 参数 —— UseDebounceOptions + +| 参数 | 说明 | 类型 | 默认值 | +| --------- | ------------------------------------ | --------- | ------- | +| `leading` | 是否在等待周期开始时立即执行 | `boolean` | `false` | +| `wait` | 常规防抖间隔(毫秒),`0` 表示不防抖 | `number` | `0` | +| `maxWait` | 最大等待时间(毫秒),`0` 表示不限制 | `number` | `0` | + +### 返回值 —— DebouncedFunc\<T\> + +| 方法 | 说明 | 签名 | +| ------------ | ---------------------------------------------- | ------------------------------------------- | +| `fn` | 防抖处理后的函数本身 | `(...args: Parameters<T>) => ReturnType<T>` | +| `cancel` | 取消防抖函数的任何待执行操作 | `() => void` | +| `disable` | 禁用防抖;禁用后再次调用将不生效,直到重新启用 | `() => void` | +| `enable` | 重新启用被禁用的防抖函数 | `() => void` | +| `isDisabled` | 检查防抖函数当前是否被禁用 | `() => boolean` | diff --git a/.storybook/stories/hooks/useDebounce.stories.tsx b/.storybook/stories/hooks/useDebounce/index.stories.tsx similarity index 93% rename from .storybook/stories/hooks/useDebounce.stories.tsx rename to .storybook/stories/hooks/useDebounce/index.stories.tsx index 22c6b70..abe02b3 100644 --- a/.storybook/stories/hooks/useDebounce.stories.tsx +++ b/.storybook/stories/hooks/useDebounce/index.stories.tsx @@ -1,8 +1,12 @@ import { useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; import { Alert, Button, Card, Divider, Input, List, Space, Typography } from 'antd'; -import useDebounce from '../../../src/hooks/useDebounce'; -import { storyT, useStoryT } from '../../locales'; +import useDebounce from '../../../../src/hooks/useDebounce'; +import storyI18n, { storyT, useStoryT } from '../../../locales'; +import apiDocEN from './api-doc.en-US.md?raw'; +import apiDocCN from './api-doc.zh-CN.md?raw'; +import introduceEN from './introduce.en-US.md?raw'; +import introduceCN from './introduce.zh-CN.md?raw'; interface UseDebounceStoryArgs { wait: number; @@ -22,8 +26,7 @@ const meta: Meta<UseDebounceStoryArgs> = { parameters: { docs: { description: { - component: `- **EN:** Demonstrates how to use \`useDebounce\` to debounce a search-like action with configurable \`wait\`, \`leading\`, and \`maxWait\` behavior, plus runtime controls for canceling, disabling, and re-enabling execution. -- **CN:** 演示如何使用 \`useDebounce\` 对类似搜索的动作进行防抖,并支持 \`wait\`、\`leading\`、\`maxWait\` 配置,同时提供取消、禁用与重新启用等运行时控制。`, + component: storyI18n.language === 'zh-CN' ? `${introduceCN}\n${apiDocCN}` : `${introduceEN}\n${apiDocEN}`, }, }, }, diff --git a/.storybook/stories/hooks/useDebounce/introduce.en-US.md b/.storybook/stories/hooks/useDebounce/introduce.en-US.md new file mode 100644 index 0000000..17f0bd5 --- /dev/null +++ b/.storybook/stories/hooks/useDebounce/introduce.en-US.md @@ -0,0 +1,21 @@ +Runs a debounced version of a callback with two complementary trigger mechanisms: the classic trailing debounce (executes after the configured wait period without new calls) and a `maxWait` cap (forces execution even when calls keep arriving). The returned function additionally exposes `cancel`, `disable`, `enable`, and `isDisabled` for runtime control. + +## When to use + +- Search-as-you-type inputs where every keystroke would otherwise fire a request. +- Window `resize` / `scroll` handlers, form validation, or auto-save that must throttle bursts of events. +- Any flow where a slow operation must only run after the user pauses (trailing edge) or at a guaranteed interval (max wait). + +## Key features + +- **Dual trigger** — classic debounce plus a `maxWait` force-execution cap, so long-running bursts still make progress. +- **Leading execution** — `leading: true` fires immediately on the first call in a burst. +- **Runtime control** — `cancel()` drops pending execution, `disable()`/`enable()` toggle the debounce, `isDisabled()` reports state. +- **Dependency-driven recreation** — the debounced function is re-created when `deps` change, like `useCallback`. + +## Usage notes + +- `wait: 0` (default) disables debouncing entirely — calls execute immediately. +- The returned function keeps the latest callback via a ref, so the `fn` identity does not need to be stable across renders. +- `disable()` is not the same as `cancel()`: cancel only drops the pending timer, while disable makes subsequent calls no-ops until `enable()` is called. +- `maxWait` is measured from the last actual execution; it is only applied when `maxWait > 0`. diff --git a/.storybook/stories/hooks/useDebounce/introduce.zh-CN.md b/.storybook/stories/hooks/useDebounce/introduce.zh-CN.md new file mode 100644 index 0000000..1b94543 --- /dev/null +++ b/.storybook/stories/hooks/useDebounce/introduce.zh-CN.md @@ -0,0 +1,21 @@ +对回调执行防抖处理,提供两种互补触发机制:经典尾沿防抖(等待周期内无新调用后执行)和 `maxWait` 上限(持续调用时也强制执行)。返回的函数额外暴露 `cancel`、`disable`、`enable`、`isDisabled` 以便运行时控制。 + +## 适用场景 + +- 搜索即输入场景,避免每次按键都触发请求。 +- 窗口 `resize` / `scroll` 事件、表单校验或自动保存等需要抑制高频触发流的场景。 +- 任何"用户停顿后才执行(尾沿)"或"按固定间隔保证执行(maxWait)"的慢操作。 + +## 核心特性 + +- **双触发机制** —— 经典防抖之外,`maxWait` 可强制兜底执行,长时间高频调用也能持续推进。 +- **立即执行** —— `leading: true` 时,一次触发流的首次调用立即执行。 +- **运行时控制** —— `cancel()` 丢弃待执行调用,`disable()` / `enable()` 开关防抖,`isDisabled()` 查询状态。 +- **依赖驱动重建** —— `deps` 变化时重新创建防抖函数,行为类似 `useCallback`。 + +## 使用注意 + +- `wait: 0`(默认)表示不防抖,调用立即执行。 +- 返回函数通过 ref 持有最新回调,因此 `fn` 无需在渲染间保持稳定引用。 +- `disable()` 不同于 `cancel()`:cancel 只清除待执行定时器,disable 则让后续调用全部失效,直到 `enable()`。 +- `maxWait` 以最近一次实际执行为基准,仅在 `maxWait > 0` 时生效。 diff --git a/.storybook/stories/hooks/useStreamDownloader/api-doc.en-US.md b/.storybook/stories/hooks/useStreamDownloader/api-doc.en-US.md new file mode 100644 index 0000000..b4d4e99 --- /dev/null +++ b/.storybook/stories/hooks/useStreamDownloader/api-doc.en-US.md @@ -0,0 +1,22 @@ +## API + +### Options — UseStreamDownloaderOptions + +| Name | Description | Type | Default | +| -------------------- | ---------------------------------------------------------------- | -------------------------------- | ------- | +| `autoDispose` | Whether to dispose the downloader on component unmount | `boolean` | `true` | +| `defaultRequest` | Default request fragment merged into every `start(request)` call | `Partial<StreamDownloadRequest>` | - | +| `progressThrottleMs` | Minimum interval between progress snapshot emissions (ms) | `number` | `100` | + +> Inherits `StreamDownloaderInit` (`defaultRequest`, `progressThrottleMs`). + +### Return — UseStreamDownloaderResult + +| Member | Description | Type | +| ------------ | ---------------------------------------------------------------- | --------------------------------------------------------------------------- | +| `downloader` | Stable downloader class instance for advanced imperative control | `StreamDownloader` | +| `snapshot` | Reactive snapshot mirrored from the underlying class instance | `Readonly<StreamDownloadSnapshot>` | +| `isRunning` | Whether the current downloader has an active task | `boolean` | +| `start` | Start a download task | `(request?: StreamDownloadRequest) => Promise<StreamDownloadSuccessResult>` | +| `cancel` | Cancel the active task | `() => void` | +| `reset` | Reset the terminal snapshot back to idle | `() => void` | diff --git a/.storybook/stories/hooks/useStreamDownloader/api-doc.zh-CN.md b/.storybook/stories/hooks/useStreamDownloader/api-doc.zh-CN.md new file mode 100644 index 0000000..15c0c18 --- /dev/null +++ b/.storybook/stories/hooks/useStreamDownloader/api-doc.zh-CN.md @@ -0,0 +1,22 @@ +## API + +### 参数 —— UseStreamDownloaderOptions + +| 参数 | 说明 | 类型 | 默认值 | +| -------------------- | ------------------------------------------------ | -------------------------------- | ------ | +| `autoDispose` | 组件卸载时是否自动释放 downloader | `boolean` | `true` | +| `defaultRequest` | 会合并进每次 `start(request)` 调用的默认请求片段 | `Partial<StreamDownloadRequest>` | - | +| `progressThrottleMs` | 两次进度快照派发之间的最小间隔(毫秒) | `number` | `100` | + +> 继承 `StreamDownloaderInit`(`defaultRequest`、`progressThrottleMs`)。 + +### 返回值 —— UseStreamDownloaderResult + +| 成员 | 说明 | 类型 | +| ------------ | -------------------------------- | --------------------------------------------------------------------------- | +| `downloader` | 供高级命令式控制使用的稳定类实例 | `StreamDownloader` | +| `snapshot` | 从底层类实例镜像过来的响应式快照 | `Readonly<StreamDownloadSnapshot>` | +| `isRunning` | 当前是否存在活动下载任务 | `boolean` | +| `start` | 启动下载任务 | `(request?: StreamDownloadRequest) => Promise<StreamDownloadSuccessResult>` | +| `cancel` | 取消当前活动任务 | `() => void` | +| `reset` | 将终态快照重置回 idle | `() => void` | diff --git a/.storybook/stories/hooks/useStreamDownloader.stories.tsx b/.storybook/stories/hooks/useStreamDownloader/index.stories.tsx similarity index 92% rename from .storybook/stories/hooks/useStreamDownloader.stories.tsx rename to .storybook/stories/hooks/useStreamDownloader/index.stories.tsx index 261a22f..4248d9c 100644 --- a/.storybook/stories/hooks/useStreamDownloader.stories.tsx +++ b/.storybook/stories/hooks/useStreamDownloader/index.stories.tsx @@ -2,9 +2,16 @@ import { useMemo } from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; import { create as createAxios } from 'axios'; import { List, Space, Tag, Typography } from 'antd'; -import { type StreamDownloadSaveStrategy, useStreamDownloader } from '../../../src'; -import { storyT, useStoryT } from '../../locales'; -import StreamDownloaderDemoCard, { formatErrorLog, useStreamDownloaderDemoLogs } from '../shared/streamDownloaderDemo'; +import { type StreamDownloadSaveStrategy, useStreamDownloader } from '../../../../src'; +import storyI18n, { storyT, useStoryT } from '../../../locales'; +import StreamDownloaderDemoCard, { + formatErrorLog, + useStreamDownloaderDemoLogs, +} from '../../shared/streamDownloaderDemo'; +import apiDocEN from './api-doc.en-US.md?raw'; +import apiDocCN from './api-doc.zh-CN.md?raw'; +import introduceEN from './introduce.en-US.md?raw'; +import introduceCN from './introduce.zh-CN.md?raw'; const REAL_DOWNLOAD_URL = 'https://huggingface.co/gpt2/resolve/main/pytorch_model.bin'; @@ -83,7 +90,7 @@ const meta: Meta<UseStreamDownloaderStoryArgs> = { parameters: { docs: { description: { - component: `- **EN:** \`useStreamDownloader\` is the React adapter over \`StreamDownloader\`. This page focuses on the hook-specific surface while still using a real CORS-enabled large file and a real \`axios.create({ adapter: 'fetch' })\` instance. For the full transport and type contract, see [Utils/StreamDownloader](?path=/docs/utils-streamdownloader--playground).\n- **CN:** \`useStreamDownloader\` 是 \`StreamDownloader\` 的 React 适配层。本页聚焦 hook 自身接口,同时依旧使用真实、支持 CORS 的大文件和真实的 \`axios.create({ adapter: 'fetch' })\` 实例。完整的 transport 与类型契约请查看 [Utils/StreamDownloader](?path=/docs/utils-streamdownloader--playground)。`, + component: storyI18n.language === 'zh-CN' ? `${introduceCN}\n${apiDocCN}` : `${introduceEN}\n${apiDocEN}`, }, }, }, diff --git a/.storybook/stories/hooks/useStreamDownloader/introduce.en-US.md b/.storybook/stories/hooks/useStreamDownloader/introduce.en-US.md new file mode 100644 index 0000000..6562dbf --- /dev/null +++ b/.storybook/stories/hooks/useStreamDownloader/introduce.en-US.md @@ -0,0 +1,21 @@ +React adapter over the `StreamDownloader` class that exposes a stable downloader instance, a reactive snapshot, and bound action helpers. It subscribes the component to snapshot changes via `useSyncExternalStore`, so progress, status, and error state re-render automatically without manual subscription management. + +## When to use + +- Downloading large files in a React component while showing live progress, status, and error feedback. +- When you want the convenience of a reactive `snapshot` + `isRunning` while still keeping the full class API for advanced imperative control. +- As the hook-level counterpart to [Utils/StreamDownloader](?path=/docs/utils-streamdownloader--playground) for class-level transport and type details. + +## Key features + +- **Stable instance** — exactly one `StreamDownloader` is created per mounted lifecycle and reused across renders. +- **Reactive snapshot** — `snapshot` mirrors the class state and re-renders the component on every public change. +- **Bound helpers** — `start` / `cancel` / `reset` are pre-bound to the instance, so they can be passed around without losing `this`. +- **Auto-dispose** — the downloader is disposed on unmount by default; disable via `autoDispose: false` when the instance must outlive the component. + +## Usage notes + +- The full transport contract, save strategies, and type system live in the class-level page: [Utils/StreamDownloader](?path=/docs/utils-streamdownloader--playground). This page focuses on the hook-only surface. +- `start(request)` returns a `Promise<StreamDownloadSuccessResult>` and rejects with a `StreamDownloadError` (e.g. `DOWNLOAD_CANCELLED`) on failure/cancel — always handle the rejection. +- `snapshot` is a plain object; read `status`, `progress`, `errorCode`, etc. directly from it. +- The demo performs a real network transfer and a real save flow — prefer `file-system-access` when supported. diff --git a/.storybook/stories/hooks/useStreamDownloader/introduce.zh-CN.md b/.storybook/stories/hooks/useStreamDownloader/introduce.zh-CN.md new file mode 100644 index 0000000..e0f790d --- /dev/null +++ b/.storybook/stories/hooks/useStreamDownloader/introduce.zh-CN.md @@ -0,0 +1,21 @@ +`StreamDownloader` 类的 React 适配层,对外提供稳定的 downloader 实例、响应式快照以及已经绑定好的 action 方法。它通过 `useSyncExternalStore` 订阅快照变化,进度、状态与错误信息会自动触发组件重渲染,无需手动管理订阅。 + +## 适用场景 + +- 在 React 组件中下载大文件,并实时展示进度、状态与错误反馈。 +- 既想要 `snapshot` + `isRunning` 的响应式便利,又希望保留完整类 API 用于高级命令式控制。 +- 作为 [Utils/StreamDownloader](?path=/docs/utils-streamdownloader--playground) 的 hook 级对应页面,类级 transport 与类型细节请看后者。 + +## 核心特性 + +- **实例稳定** —— 每次挂载生命周期只创建一个 `StreamDownloader`,并在渲染间复用。 +- **响应式快照** —— `snapshot` 镜像类状态,任何公开变化都会触发组件重渲染。 +- **绑定好的方法** —— `start` / `cancel` / `reset` 已预绑定实例,可直接传递而不会丢失 `this`。 +- **自动释放** —— 默认在卸载时 dispose downloader;需要实例存活于组件之外时,可通过 `autoDispose: false` 关闭。 + +## 使用注意 + +- 完整的 transport 契约、保存策略与类型体系在类级页面: [Utils/StreamDownloader](?path=/docs/utils-streamdownloader--playground)。本页聚焦 hook 自身接口。 +- `start(request)` 返回 `Promise<StreamDownloadSuccessResult>`,失败或取消时会以 `StreamDownloadError`(如 `DOWNLOAD_CANCELLED`)拒绝 —— 请务必处理 rejection。 +- `snapshot` 是普通对象,直接读取 `status`、`progress`、`errorCode` 等字段即可。 +- demo 会真实触发网络传输与保存流程 —— 浏览器支持时优先使用 `file-system-access`。 diff --git a/.storybook/stories/utils/StreamDownloader/api-doc.en-US.md b/.storybook/stories/utils/StreamDownloader/api-doc.en-US.md new file mode 100644 index 0000000..83ddb61 --- /dev/null +++ b/.storybook/stories/utils/StreamDownloader/api-doc.en-US.md @@ -0,0 +1,35 @@ +## API + +### Constructor — StreamDownloaderInit + +| Name | Description | Type | Default | +| -------------------- | ---------------------------------------------------------------- | -------------------------------- | ------- | +| `defaultRequest` | Default request fragment merged into every `start(request)` call | `Partial<StreamDownloadRequest>` | - | +| `progressThrottleMs` | Minimum interval between progress snapshot emissions (ms) | `number` | `100` | + +### Instance members + +| Member | Description | Signature | +| ------------- | -------------------------------------------------------------- | --------------------------------------------------------------------------- | +| `getSnapshot` | Read the latest immutable snapshot | `(): Readonly<StreamDownloadSnapshot>` | +| `status` | Read the current task status (getter) | `(): StreamDownloadStatus` — getter property | +| `isRunning` | Whether an active task exists (getter) | `(): boolean` — getter property | +| `subscribe` | Subscribe to snapshot changes; returns an unsubscribe function | `(listener: StreamDownloadListener) => () => void` | +| `start` | Start a streaming download task | `(request?: StreamDownloadRequest) => Promise<StreamDownloadSuccessResult>` | +| `cancel` | Cancel the active task | `() => void` | +| `reset` | Reset a terminal snapshot back to idle | `() => void` | +| `dispose` | Cancel work, clear listeners, and release the instance | `() => void` | + +### Key types + +| Type | Description | +| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `StreamDownloadTransport` | `'fetch' \| 'axios'` — supported request transports | +| `StreamDownloadSaveStrategy` | `'auto' \| 'file-system-access' \| 'stream-saver'` | +| `StreamDownloadStatus` | `'idle' \| 'preparing' \| 'downloading' \| 'success' \| 'failed' \| 'cancelled'` | +| `StreamDownloadErrorCode` | Stable codes: `TASK_ALREADY_RUNNING`, `UNSUPPORTED_TRANSPORT`, `UNSUPPORTED_SAVE_STRATEGY`, `INVALID_REQUEST_URL`, `HTTP_ERROR`, `EMPTY_RESPONSE_STREAM`, `INVALID_AXIOS_INSTANCE`, `AXIOS_ADAPTER_NOT_SUPPORTED`, `WRITE_ABORTED`, `WRITE_FAILED`, `DOWNLOAD_CANCELLED` | +| `StreamDownloadProgress` | `loadedBytes`, `totalBytes?`, `percent?`, `speedBps?` | +| `StreamDownloadSnapshot` | `status`, `requestUrl?`, `fileName?`, `transport?`, `saveStrategy?`, `progress`, `errorCode?`, `errorMessage?` | +| `StreamDownloadRequest` | `FetchStreamDownloadRequest \| AxiosStreamDownloadRequest` | +| `StreamDownloadSuccessResult` | `status: 'success'`, `fileName`, `loadedBytes`, `totalBytes?`, `transport`, `saveStrategy` | +| `StreamDownloadError` | Error class carrying a stable `StreamDownloadErrorCode` | diff --git a/.storybook/stories/utils/StreamDownloader/api-doc.zh-CN.md b/.storybook/stories/utils/StreamDownloader/api-doc.zh-CN.md new file mode 100644 index 0000000..2f7dcc1 --- /dev/null +++ b/.storybook/stories/utils/StreamDownloader/api-doc.zh-CN.md @@ -0,0 +1,35 @@ +## API + +### 构造参数 —— StreamDownloaderInit + +| 参数 | 说明 | 类型 | 默认值 | +| -------------------- | ------------------------------------------------ | -------------------------------- | ------ | +| `defaultRequest` | 会合并进每次 `start(request)` 调用的默认请求片段 | `Partial<StreamDownloadRequest>` | - | +| `progressThrottleMs` | 两次进度快照派发之间的最小间隔(毫秒) | `number` | `100` | + +### 实例成员 + +| 成员 | 说明 | 签名 | +| ------------- | ---------------------------------- | --------------------------------------------------------------------------- | +| `getSnapshot` | 读取当前最新的只读快照 | `(): Readonly<StreamDownloadSnapshot>` | +| `status` | 读取当前任务状态(getter) | `(): StreamDownloadStatus` —— getter 属性 | +| `isRunning` | 判断当前是否存在活动任务(getter) | `(): boolean` —— getter 属性 | +| `subscribe` | 订阅快照变化,返回取消订阅函数 | `(listener: StreamDownloadListener) => () => void` | +| `start` | 启动流式下载任务 | `(request?: StreamDownloadRequest) => Promise<StreamDownloadSuccessResult>` | +| `cancel` | 取消当前活动任务 | `() => void` | +| `reset` | 将终态快照重置回 idle | `() => void` | +| `dispose` | 取消任务、清空监听器并释放实例 | `() => void` | + +### 关键类型 + +| 类型 | 说明 | +| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `StreamDownloadTransport` | `'fetch' \| 'axios'` —— 支持的请求传输方式 | +| `StreamDownloadSaveStrategy` | `'auto' \| 'file-system-access' \| 'stream-saver'` | +| `StreamDownloadStatus` | `'idle' \| 'preparing' \| 'downloading' \| 'success' \| 'failed' \| 'cancelled'` | +| `StreamDownloadErrorCode` | 稳定错误码:`TASK_ALREADY_RUNNING`、`UNSUPPORTED_TRANSPORT`、`UNSUPPORTED_SAVE_STRATEGY`、`INVALID_REQUEST_URL`、`HTTP_ERROR`、`EMPTY_RESPONSE_STREAM`、`INVALID_AXIOS_INSTANCE`、`AXIOS_ADAPTER_NOT_SUPPORTED`、`WRITE_ABORTED`、`WRITE_FAILED`、`DOWNLOAD_CANCELLED` | +| `StreamDownloadProgress` | `loadedBytes`、`totalBytes?`、`percent?`、`speedBps?` | +| `StreamDownloadSnapshot` | `status`、`requestUrl?`、`fileName?`、`transport?`、`saveStrategy?`、`progress`、`errorCode?`、`errorMessage?` | +| `StreamDownloadRequest` | `FetchStreamDownloadRequest \| AxiosStreamDownloadRequest` | +| `StreamDownloadSuccessResult` | `status: 'success'`、`fileName`、`loadedBytes`、`totalBytes?`、`transport`、`saveStrategy` | +| `StreamDownloadError` | 携带稳定 `StreamDownloadErrorCode` 的错误类 | diff --git a/.storybook/stories/utils/StreamDownloader/index.stories.tsx b/.storybook/stories/utils/StreamDownloader/index.stories.tsx index 0f0300c..332cfcd 100644 --- a/.storybook/stories/utils/StreamDownloader/index.stories.tsx +++ b/.storybook/stories/utils/StreamDownloader/index.stories.tsx @@ -6,12 +6,16 @@ import StreamDownloader, { type StreamDownloadRequest, type StreamDownloadSaveStrategy, } from '../../../../src/utils/StreamDownloader'; -import { storyT, useStoryT } from '../../../locales'; +import storyI18n, { storyT, useStoryT } from '../../../locales'; import StreamDownloaderDemoCard, { formatErrorLog, getCodeBlockStyle, useStreamDownloaderDemoLogs, } from '../../shared/streamDownloaderDemo'; +import apiDocEN from './api-doc.en-US.md?raw'; +import apiDocCN from './api-doc.zh-CN.md?raw'; +import introduceEN from './introduce.en-US.md?raw'; +import introduceCN from './introduce.zh-CN.md?raw'; const REAL_DOWNLOAD_URL = 'https://huggingface.co/gpt2/resolve/main/pytorch_model.bin'; @@ -104,7 +108,7 @@ const meta: Meta<StreamDownloaderStoryArgs> = { parameters: { docs: { description: { - component: `- **EN:** \`StreamDownloader\` is the class-level source of truth for browser-side large-file streaming downloads. This story shows the full public API surface and uses a real, CORS-enabled remote asset together with a real \`axios.create({ adapter: 'fetch' })\` instance.\n- **CN:** \`StreamDownloader\` 是浏览器端大文件流式下载能力的 class 级真相来源。本页展示完整公开 API,并使用真实、支持 CORS 的远端文件和真实的 \`axios.create({ adapter: 'fetch' })\` 实例。\n\n- **EN:** The live demo intentionally triggers a real save flow and a real network transfer. Prefer \`file-system-access\` when your browser supports it; \`stream-saver\` may still depend on additional browser/service-worker capability.\n- **CN:** 这个 live demo 会真实触发保存流程与网络传输。若浏览器支持,优先使用 \`file-system-access\`;而 \`stream-saver\` 仍可能依赖额外的浏览器 / service worker 能力。`, + component: storyI18n.language === 'zh-CN' ? `${introduceCN}\n${apiDocCN}` : `${introduceEN}\n${apiDocEN}`, }, }, }, diff --git a/.storybook/stories/utils/StreamDownloader/introduce.en-US.md b/.storybook/stories/utils/StreamDownloader/introduce.en-US.md new file mode 100644 index 0000000..fcee94d --- /dev/null +++ b/.storybook/stories/utils/StreamDownloader/introduce.en-US.md @@ -0,0 +1,23 @@ +Browser-side streaming downloader for large files. It reads the remote response as a `ReadableStream`, throttles progress emissions, and writes bytes into a destination chosen at runtime — either the File System Access API or a StreamSaver-backed writable stream. The class is framework-agnostic and acts as the source of truth for the download contract; the [Hooks/useStreamDownloader](?path=/docs/hooks-usestreamdownloader--playground) page is the React adapter. + +## When to use + +- Downloading large files in the browser with live progress, cancellation, and error reporting. +- When a download must be started imperatively (outside render), reused across components, or embedded in non-React code. +- When you need both `fetch` and axios (`fetch` adapter) transports behind one API. + +## Key features + +- **Dual transport** — native `fetch` or an injected axios instance explicitly backed by the `fetch` adapter. +- **Pluggable save strategy** — `auto` detection, File System Access API (`file-system-access`), or StreamSaver (`stream-saver`). +- **Progress throttling** — configurable minimum interval between snapshot emissions (`progressThrottleMs`). +- **Subscription model** — `subscribe(listener)` + `getSnapshot()` follow the `useSyncExternalStore` contract, usable with or without React. +- **Lifecycle control** — `cancel()`, `reset()`, and `dispose()` cover cancellation, terminal-state reset, and resource release. +- **Stable error codes** — typed error codes (`TASK_ALREADY_RUNNING`, `DOWNLOAD_CANCELLED`, …) via `StreamDownloadError`. + +## Usage notes + +- The remote URL must support CORS and expose a readable stream body; otherwise the download fails with `HTTP_ERROR` or `EMPTY_RESPONSE_STREAM`. +- `file-system-access` requires a secure context (HTTPS/localhost) and shows a native save dialog; `stream-saver` may depend on additional browser/service-worker capability. +- Only one task can run at a time; calling `start()` while running rejects with `TASK_ALREADY_RUNNING`. +- The live demo intentionally triggers a real save flow and a real network transfer — prefer `file-system-access` when your browser supports it. diff --git a/.storybook/stories/utils/StreamDownloader/introduce.mdx b/.storybook/stories/utils/StreamDownloader/introduce.mdx deleted file mode 100644 index c09a2f6..0000000 --- a/.storybook/stories/utils/StreamDownloader/introduce.mdx +++ /dev/null @@ -1,3 +0,0 @@ -## sasass - -sadfdasfsda diff --git a/.storybook/stories/utils/StreamDownloader/introduce.zh-CN.md b/.storybook/stories/utils/StreamDownloader/introduce.zh-CN.md new file mode 100644 index 0000000..a58ee0a --- /dev/null +++ b/.storybook/stories/utils/StreamDownloader/introduce.zh-CN.md @@ -0,0 +1,23 @@ +浏览器端大文件流式下载器。它将远端响应作为 `ReadableStream` 读取,对进度派发做节流,并把字节写入运行时选择的目标 —— 要么是 File System Access API,要么是 StreamSaver 支持的可写流。类本身与框架无关,是下载契约的真相来源;[Hooks/useStreamDownloader](?path=/docs/hooks-usestreamdownloader--playground) 页面是其 React 适配层。 + +## 适用场景 + +- 在浏览器中下载大文件,需要实时进度、取消与错误上报。 +- 下载需要在渲染之外命令式启动、跨组件复用,或嵌入非 React 代码。 +- 需要用一个 API 同时覆盖 `fetch` 与 axios(`fetch` adapter)两种传输方式。 + +## 核心特性 + +- **双传输** —— 原生 `fetch`,或显式基于 `fetch` adapter 的外部 axios 实例。 +- **可插拔保存策略** —— `auto` 自动探测、File System Access API(`file-system-access`)、StreamSaver(`stream-saver`)。 +- **进度节流** —— 可通过 `progressThrottleMs` 配置快照派发的最小间隔。 +- **订阅模型** —— `subscribe(listener)` + `getSnapshot()` 遵循 `useSyncExternalStore` 契约,React 内外皆可用。 +- **生命周期控制** —— `cancel()`、`reset()`、`dispose()` 覆盖取消、终态重置与资源释放。 +- **稳定错误码** —— 通过 `StreamDownloadError` 提供类型化错误码(`TASK_ALREADY_RUNNING`、`DOWNLOAD_CANCELLED` 等)。 + +## 使用注意 + +- 远端 URL 必须支持 CORS 并暴露可读流 body,否则会以 `HTTP_ERROR` 或 `EMPTY_RESPONSE_STREAM` 失败。 +- `file-system-access` 需要安全上下文(HTTPS/localhost)并弹出原生保存对话框;`stream-saver` 可能依赖额外的浏览器 / service worker 能力。 +- 同一时间只能运行一个任务;运行中再次调用 `start()` 会以 `TASK_ALREADY_RUNNING` 拒绝。 +- live demo 会真实触发保存流程与网络传输 —— 浏览器支持时优先使用 `file-system-access`。 From e7ae3c524c1430025c2c408e54af60d7e319f524 Mon Sep 17 00:00:00 2001 From: Fengbao Li <shijistar@gmail.com> Date: Wed, 5 Aug 2026 12:49:47 +0800 Subject: [PATCH 16/39] docs(storybook): update descriptions and fix wording in useStreamDownloader and related components --- .storybook/locales/langs/en-US.ts | 16 ++++++++++++- .storybook/locales/langs/zh-CN.ts | 12 +++++++++- .../ConfigProvider/index.stories.tsx | 2 +- .../components/ModalAction/index.stories.tsx | 2 +- .../PulseAnimation/index.stories.tsx | 2 +- .../hooks/useAudioPlayer/index.stories.tsx | 2 +- .../hooks/useDebounce/index.stories.tsx | 2 +- .../useStreamDownloader/index.stories.tsx | 23 ++++++++----------- .../stories/shared/streamDownloaderDemo.tsx | 2 +- 9 files changed, 42 insertions(+), 21 deletions(-) diff --git a/.storybook/locales/langs/en-US.ts b/.storybook/locales/langs/en-US.ts index f492f67..fce77e0 100644 --- a/.storybook/locales/langs/en-US.ts +++ b/.storybook/locales/langs/en-US.ts @@ -166,7 +166,7 @@ const enUS = { 'storybook.stories.StreamDownloader.logs.error': 'Download failed', 'storybook.stories.useStreamDownloader.cardTitle': 'useStreamDownloader playground', 'storybook.stories.useStreamDownloader.realHint': - 'This hook demo also uses a real CORS-enabled large file and a real axios fetch-adapter instance', + 'This hook demo uses a real CORS-enabled large file and a real axios fetch-adapter instance', 'storybook.stories.useStreamDownloader.description': 'Observe how the hook exposes a stable downloader instance, reactive snapshot, and action helpers while the browser performs a real streaming download.', 'storybook.stories.useStreamDownloader.actions.startFetch': 'Download with <fetch>', @@ -203,6 +203,20 @@ const enUS = { 'storybook.stories.useStreamDownloader.logs.cancel': 'Cancel requested', 'storybook.stories.useStreamDownloader.logs.reset': 'Snapshot reset to idle', 'storybook.stories.useStreamDownloader.logs.error': 'Download failed', + 'storybook.stories.useStreamDownloader.hookApi.useStreamDownloader.description': + 'Create a stable downloader instance and subscribe React to snapshot updates.', + 'storybook.stories.useStreamDownloader.hookApi.progressThrottleMs.description': + 'Forwarded into the underlying class constructor.', + 'storybook.stories.useStreamDownloader.hookApi.autoDispose.description': + 'Control whether unmount disposes the downloader.', + 'storybook.stories.useStreamDownloader.hookApi.downloader.description': + 'Stable class instance for advanced imperative control.', + 'storybook.stories.useStreamDownloader.hookApi.snapshot.description': 'Reactive snapshot mirrored from the class.', + 'storybook.stories.useStreamDownloader.hookApi.isRunning.description': 'React-friendly running flag.', + 'storybook.stories.useStreamDownloader.hookApi.start.description': + 'Bound start helper that returns the same success result as the class.', + 'storybook.stories.useStreamDownloader.hookApi.cancel.description': 'Bound cancel helper.', + 'storybook.stories.useStreamDownloader.hookApi.reset.description': 'Bound terminal-reset helper.', 'storybook.stories.ConfirmAction.argTypes.autoFocusButton.description': 'The button to auto focus when the confirm action is opened.', 'storybook.stories.ConfirmAction.argTypes.contentColor.description': 'The color of the content text.', diff --git a/.storybook/locales/langs/zh-CN.ts b/.storybook/locales/langs/zh-CN.ts index b5c68ee..51631f6 100644 --- a/.storybook/locales/langs/zh-CN.ts +++ b/.storybook/locales/langs/zh-CN.ts @@ -163,7 +163,7 @@ const zhCN = { 'storybook.stories.StreamDownloader.logs.error': '下载失败', 'storybook.stories.useStreamDownloader.cardTitle': 'useStreamDownloader 演示面板', 'storybook.stories.useStreamDownloader.realHint': - '本 hook 示例同样使用真实的、支持 CORS 的大文件,以及真实的 axios fetch-adapter 实例', + '本 hook 示例使用真实的、支持 CORS 的大文件,以及真实的 axios fetch-adapter 实例', 'storybook.stories.useStreamDownloader.description': '观察 hook 如何在浏览器执行真实流式下载的同时,对外暴露稳定 downloader 实例、响应式 snapshot 与 action 方法。', 'storybook.stories.useStreamDownloader.actions.startFetch': '用 fetch 方式下载', @@ -200,6 +200,16 @@ const zhCN = { 'storybook.stories.useStreamDownloader.logs.cancel': '已请求取消', 'storybook.stories.useStreamDownloader.logs.reset': '快照已重置为 idle', 'storybook.stories.useStreamDownloader.logs.error': '下载失败', + 'storybook.stories.useStreamDownloader.hookApi.useStreamDownloader.description': + '创建稳定的 downloader 实例,并让 React 订阅快照更新。', + 'storybook.stories.useStreamDownloader.hookApi.progressThrottleMs.description': '透传给底层 class 构造参数。', + 'storybook.stories.useStreamDownloader.hookApi.autoDispose.description': '控制组件卸载时是否自动释放 downloader。', + 'storybook.stories.useStreamDownloader.hookApi.downloader.description': '供高级命令式控制使用的稳定 class 实例。', + 'storybook.stories.useStreamDownloader.hookApi.snapshot.description': '从 class 同步过来的响应式快照。', + 'storybook.stories.useStreamDownloader.hookApi.isRunning.description': '面向 React 场景的运行态标志。', + 'storybook.stories.useStreamDownloader.hookApi.start.description': '已绑定的启动方法,返回值与 class 一致。', + 'storybook.stories.useStreamDownloader.hookApi.cancel.description': '已绑定的取消方法。', + 'storybook.stories.useStreamDownloader.hookApi.reset.description': '已绑定的终态重置方法。', 'storybook.stories.ConfirmAction.argTypes.autoFocusButton.description': '确认操作打开时自动获取焦点的按钮。', 'storybook.stories.ConfirmAction.argTypes.contentColor.description': '内容文本的颜色。', 'storybook.stories.ConfirmAction.argTypes.iconColor.description': '图标的颜色。', diff --git a/.storybook/stories/components/ConfigProvider/index.stories.tsx b/.storybook/stories/components/ConfigProvider/index.stories.tsx index a19bae8..77bfbfc 100644 --- a/.storybook/stories/components/ConfigProvider/index.stories.tsx +++ b/.storybook/stories/components/ConfigProvider/index.stories.tsx @@ -38,7 +38,7 @@ export const Playground: Story = { return ( <AntdApp> <ConfigProvider {...args}> - <Space direction="vertical" size={12}> + <Space orientation="vertical" size={12}> <Typography.Text>{t('storybook.stories.ConfigProvider.hint')}</Typography.Text> <ConfirmAction.Button onOk={async () => Promise.resolve()}> {t('storybook.stories.ConfigProvider.openButton')} diff --git a/.storybook/stories/components/ModalAction/index.stories.tsx b/.storybook/stories/components/ModalAction/index.stories.tsx index be2ed05..7a44fc6 100644 --- a/.storybook/stories/components/ModalAction/index.stories.tsx +++ b/.storybook/stories/components/ModalAction/index.stories.tsx @@ -118,7 +118,7 @@ export const Playground: Story = { return ( <div> - <Space direction="vertical"> + <Space orientation="vertical"> <Space> {triggerType === 'Switch' && ( <UserModalAction.Switch diff --git a/.storybook/stories/components/PulseAnimation/index.stories.tsx b/.storybook/stories/components/PulseAnimation/index.stories.tsx index e8d0319..6d79f2b 100644 --- a/.storybook/stories/components/PulseAnimation/index.stories.tsx +++ b/.storybook/stories/components/PulseAnimation/index.stories.tsx @@ -86,7 +86,7 @@ export const AudioActivity: Story = { const t = useStoryT(); return ( <Card style={{ width: 360 }} title={t('storybook.stories.PulseAnimation.audioCardTitle')}> - <Space direction="vertical" style={{ width: '100%' }} size={12}> + <Space orientation="vertical" style={{ width: '100%' }} size={12}> <Space> <Typography.Text strong>{t('storybook.stories.PulseAnimation.audioTrack')}</Typography.Text> <Typography.Text type="secondary">{t('storybook.stories.PulseAnimation.audioPlaying')}</Typography.Text> diff --git a/.storybook/stories/hooks/useAudioPlayer/index.stories.tsx b/.storybook/stories/hooks/useAudioPlayer/index.stories.tsx index 98f22b6..c62ddcc 100644 --- a/.storybook/stories/hooks/useAudioPlayer/index.stories.tsx +++ b/.storybook/stories/hooks/useAudioPlayer/index.stories.tsx @@ -180,7 +180,7 @@ function UseAudioPlayerStoryDemo({ source, initialVolume, seekStep }: UseAudioPl title={t('storybook.stories.useAudioPlayer.cardTitle')} extra={statusTag} > - <Space direction="vertical" size="large" style={{ width: '100%' }}> + <Space orientation="vertical" size="large" style={{ width: '100%' }}> <Space.Compact style={{ width: '100%' }}> <Input value={draftSource} diff --git a/.storybook/stories/hooks/useDebounce/index.stories.tsx b/.storybook/stories/hooks/useDebounce/index.stories.tsx index abe02b3..26c3e49 100644 --- a/.storybook/stories/hooks/useDebounce/index.stories.tsx +++ b/.storybook/stories/hooks/useDebounce/index.stories.tsx @@ -184,7 +184,7 @@ function UseDebounceStoryDemo({ wait, leading, maxWait }: UseDebounceStoryArgs) type={isDisabled ? 'warning' : pendingHint ? 'info' : appliedQuery ? 'success' : undefined} message={statusMessage} description={ - <Space direction="vertical" size={0}> + <Space orientation="vertical" size={0}> <Typography.Text> {t('storybook.stories.useDebounce.debouncedValue')}: {appliedQuery || '--'} </Typography.Text> diff --git a/.storybook/stories/hooks/useStreamDownloader/index.stories.tsx b/.storybook/stories/hooks/useStreamDownloader/index.stories.tsx index 4248d9c..b168498 100644 --- a/.storybook/stories/hooks/useStreamDownloader/index.stories.tsx +++ b/.storybook/stories/hooks/useStreamDownloader/index.stories.tsx @@ -46,42 +46,39 @@ interface UseStreamDownloaderStoryArgs { const HOOK_API_ITEMS = [ { signature: 'useStreamDownloader(options?)', - description: - 'EN: Create a stable downloader instance and subscribe React to snapshot updates. CN: 创建稳定的 downloader 实例,并让 React 订阅快照更新。', + description: storyT('storybook.stories.useStreamDownloader.hookApi.useStreamDownloader.description'), }, { signature: 'options.progressThrottleMs?: number', - description: 'EN: Forwarded into the underlying class constructor. CN: 透传给底层 class 构造参数。', + description: storyT('storybook.stories.useStreamDownloader.hookApi.progressThrottleMs.description'), }, { signature: 'options.autoDispose?: boolean', - description: 'EN: Control whether unmount disposes the downloader. CN: 控制组件卸载时是否自动释放 downloader。', + description: storyT('storybook.stories.useStreamDownloader.hookApi.autoDispose.description'), }, { signature: 'result.downloader', - description: - 'EN: Stable class instance for advanced imperative control. CN: 供高级命令式控制使用的稳定 class 实例。', + description: storyT('storybook.stories.useStreamDownloader.hookApi.downloader.description'), }, { signature: 'result.snapshot', - description: 'EN: Reactive snapshot mirrored from the class. CN: 从 class 同步过来的响应式快照。', + description: storyT('storybook.stories.useStreamDownloader.hookApi.snapshot.description'), }, { signature: 'result.isRunning', - description: 'EN: React-friendly running flag. CN: 面向 React 场景的运行态标志。', + description: storyT('storybook.stories.useStreamDownloader.hookApi.isRunning.description'), }, { signature: 'result.start(request?)', - description: - 'EN: Bound start helper that returns the same success result as the class. CN: 已绑定的启动方法,返回值与 class 一致。', + description: storyT('storybook.stories.useStreamDownloader.hookApi.start.description'), }, { signature: 'result.cancel()', - description: 'EN: Bound cancel helper. CN: 已绑定的取消方法。', + description: storyT('storybook.stories.useStreamDownloader.hookApi.cancel.description'), }, { signature: 'result.reset()', - description: 'EN: Bound terminal-reset helper. CN: 已绑定的终态重置方法。', + description: storyT('storybook.stories.useStreamDownloader.hookApi.reset.description'), }, ] as const; @@ -245,7 +242,7 @@ function UseStreamDownloaderStoryDemo({ dataSource={[...HOOK_API_ITEMS]} renderItem={(item: (typeof HOOK_API_ITEMS)[number]) => ( <List.Item> - <Space direction="vertical" size={0}> + <Space orientation="vertical" size={0}> <Typography.Text code>{item.signature}</Typography.Text> <Typography.Text>{item.description}</Typography.Text> </Space> diff --git a/.storybook/stories/shared/streamDownloaderDemo.tsx b/.storybook/stories/shared/streamDownloaderDemo.tsx index 7b267de..416da1e 100644 --- a/.storybook/stories/shared/streamDownloaderDemo.tsx +++ b/.storybook/stories/shared/streamDownloaderDemo.tsx @@ -282,7 +282,7 @@ function StreamDownloaderDemoCard({ return ( <Card variant="outlined" style={{ maxWidth }} title={title}> <Space orientation="vertical" size="large" style={{ width: '100%' }}> - <Alert type="info" showIcon title={hintTitle} description={hintDescription} /> + <Alert type="info" title={hintTitle} description={hintDescription} /> <Descriptions bordered column={1} size="small" title={configTitle} styles={{ label: { width: 200 } }}> {configItems.map((item, index) => ( From 5fdff0d6c11e0edae37188ade2e12213e4e99a2c Mon Sep 17 00:00:00 2001 From: Fengbao Li <shijistar@gmail.com> Date: Wed, 5 Aug 2026 13:53:30 +0800 Subject: [PATCH 17/39] docs: add Storybook stories + bilingual markdown docs for 13 remaining hooks - Add component-standard markdown docs (introduce.en-US.md, introduce.zh-CN.md, api-doc.en-US.md, api-doc.zh-CN.md) and index.stories.tsx for: useLocalStorage, useMovable, useProcessingText, useRefFunction, useRefValue, useRowSelection, useSplitter, useSSE, useStompSocket, useUserMedia, useValidators, useValidator, useValidatorBuilder - Update locale files (en-US.ts, zh-CN.ts) with 162 new i18n keys for all 13 hooks - All stories follow the established pattern: storyI18n.language ternary for component description, ?raw imports for markdown, t()/useStoryT() for UI strings - Verified: prettier, tsc, build-storybook all pass --- .storybook/locales/langs/en-US.ts | 238 ++++++++++++++++++ .storybook/locales/langs/zh-CN.ts | 226 +++++++++++++++++ .../hooks/useLocalStorage/api-doc.en-US.md | 25 ++ .../hooks/useLocalStorage/api-doc.zh-CN.md | 25 ++ .../hooks/useLocalStorage/index.stories.tsx | 92 +++++++ .../hooks/useLocalStorage/introduce.en-US.md | 21 ++ .../hooks/useLocalStorage/introduce.zh-CN.md | 21 ++ .../stories/hooks/useMovable/api-doc.en-US.md | 21 ++ .../stories/hooks/useMovable/api-doc.zh-CN.md | 21 ++ .../hooks/useMovable/index.stories.tsx | 135 ++++++++++ .../hooks/useMovable/introduce.en-US.md | 20 ++ .../hooks/useMovable/introduce.zh-CN.md | 20 ++ .../hooks/useProcessingText/api-doc.en-US.md | 17 ++ .../hooks/useProcessingText/api-doc.zh-CN.md | 17 ++ .../hooks/useProcessingText/index.stories.tsx | 97 +++++++ .../useProcessingText/introduce.en-US.md | 20 ++ .../useProcessingText/introduce.zh-CN.md | 20 ++ .../hooks/useRefFunction/api-doc.en-US.md | 15 ++ .../hooks/useRefFunction/api-doc.zh-CN.md | 15 ++ .../hooks/useRefFunction/index.stories.tsx | 106 ++++++++ .../hooks/useRefFunction/introduce.en-US.md | 19 ++ .../hooks/useRefFunction/introduce.zh-CN.md | 19 ++ .../hooks/useRefValue/api-doc.en-US.md | 13 + .../hooks/useRefValue/api-doc.zh-CN.md | 13 + .../hooks/useRefValue/index.stories.tsx | 98 ++++++++ .../hooks/useRefValue/introduce.en-US.md | 19 ++ .../hooks/useRefValue/introduce.zh-CN.md | 19 ++ .../hooks/useRowSelection/api-doc.en-US.md | 21 ++ .../hooks/useRowSelection/api-doc.zh-CN.md | 21 ++ .../hooks/useRowSelection/index.stories.tsx | 147 +++++++++++ .../hooks/useRowSelection/introduce.en-US.md | 20 ++ .../hooks/useRowSelection/introduce.zh-CN.md | 20 ++ .../stories/hooks/useSSE/api-doc.en-US.md | 23 ++ .../stories/hooks/useSSE/api-doc.zh-CN.md | 23 ++ .../stories/hooks/useSSE/index.stories.tsx | 144 +++++++++++ .../stories/hooks/useSSE/introduce.en-US.md | 22 ++ .../stories/hooks/useSSE/introduce.zh-CN.md | 22 ++ .../hooks/useSplitter/api-doc.en-US.md | 28 +++ .../hooks/useSplitter/api-doc.zh-CN.md | 28 +++ .../hooks/useSplitter/index.stories.tsx | 131 ++++++++++ .../hooks/useSplitter/introduce.en-US.md | 22 ++ .../hooks/useSplitter/introduce.zh-CN.md | 22 ++ .../hooks/useStompSocket/api-doc.en-US.md | 25 ++ .../hooks/useStompSocket/api-doc.zh-CN.md | 25 ++ .../hooks/useStompSocket/index.stories.tsx | 167 ++++++++++++ .../hooks/useStompSocket/introduce.en-US.md | 21 ++ .../hooks/useStompSocket/introduce.zh-CN.md | 21 ++ .../hooks/useUserMedia/api-doc.en-US.md | 26 ++ .../hooks/useUserMedia/api-doc.zh-CN.md | 26 ++ .../hooks/useUserMedia/index.stories.tsx | 107 ++++++++ .../hooks/useUserMedia/introduce.en-US.md | 22 ++ .../hooks/useUserMedia/introduce.zh-CN.md | 22 ++ .../hooks/useValidator/api-doc.en-US.md | 23 ++ .../hooks/useValidator/api-doc.zh-CN.md | 23 ++ .../hooks/useValidator/index.stories.tsx | 142 +++++++++++ .../hooks/useValidator/introduce.en-US.md | 20 ++ .../hooks/useValidator/introduce.zh-CN.md | 20 ++ .../useValidatorBuilder/api-doc.en-US.md | 47 ++++ .../useValidatorBuilder/api-doc.zh-CN.md | 47 ++++ .../useValidatorBuilder/index.stories.tsx | 157 ++++++++++++ .../useValidatorBuilder/introduce.en-US.md | 21 ++ .../useValidatorBuilder/introduce.zh-CN.md | 21 ++ .../hooks/useValidators/api-doc.en-US.md | 36 +++ .../hooks/useValidators/api-doc.zh-CN.md | 36 +++ .../hooks/useValidators/index.stories.tsx | 148 +++++++++++ .../hooks/useValidators/introduce.en-US.md | 21 ++ .../hooks/useValidators/introduce.zh-CN.md | 21 ++ 67 files changed, 3311 insertions(+) create mode 100644 .storybook/stories/hooks/useLocalStorage/api-doc.en-US.md create mode 100644 .storybook/stories/hooks/useLocalStorage/api-doc.zh-CN.md create mode 100644 .storybook/stories/hooks/useLocalStorage/index.stories.tsx create mode 100644 .storybook/stories/hooks/useLocalStorage/introduce.en-US.md create mode 100644 .storybook/stories/hooks/useLocalStorage/introduce.zh-CN.md create mode 100644 .storybook/stories/hooks/useMovable/api-doc.en-US.md create mode 100644 .storybook/stories/hooks/useMovable/api-doc.zh-CN.md create mode 100644 .storybook/stories/hooks/useMovable/index.stories.tsx create mode 100644 .storybook/stories/hooks/useMovable/introduce.en-US.md create mode 100644 .storybook/stories/hooks/useMovable/introduce.zh-CN.md create mode 100644 .storybook/stories/hooks/useProcessingText/api-doc.en-US.md create mode 100644 .storybook/stories/hooks/useProcessingText/api-doc.zh-CN.md create mode 100644 .storybook/stories/hooks/useProcessingText/index.stories.tsx create mode 100644 .storybook/stories/hooks/useProcessingText/introduce.en-US.md create mode 100644 .storybook/stories/hooks/useProcessingText/introduce.zh-CN.md create mode 100644 .storybook/stories/hooks/useRefFunction/api-doc.en-US.md create mode 100644 .storybook/stories/hooks/useRefFunction/api-doc.zh-CN.md create mode 100644 .storybook/stories/hooks/useRefFunction/index.stories.tsx create mode 100644 .storybook/stories/hooks/useRefFunction/introduce.en-US.md create mode 100644 .storybook/stories/hooks/useRefFunction/introduce.zh-CN.md create mode 100644 .storybook/stories/hooks/useRefValue/api-doc.en-US.md create mode 100644 .storybook/stories/hooks/useRefValue/api-doc.zh-CN.md create mode 100644 .storybook/stories/hooks/useRefValue/index.stories.tsx create mode 100644 .storybook/stories/hooks/useRefValue/introduce.en-US.md create mode 100644 .storybook/stories/hooks/useRefValue/introduce.zh-CN.md create mode 100644 .storybook/stories/hooks/useRowSelection/api-doc.en-US.md create mode 100644 .storybook/stories/hooks/useRowSelection/api-doc.zh-CN.md create mode 100644 .storybook/stories/hooks/useRowSelection/index.stories.tsx create mode 100644 .storybook/stories/hooks/useRowSelection/introduce.en-US.md create mode 100644 .storybook/stories/hooks/useRowSelection/introduce.zh-CN.md create mode 100644 .storybook/stories/hooks/useSSE/api-doc.en-US.md create mode 100644 .storybook/stories/hooks/useSSE/api-doc.zh-CN.md create mode 100644 .storybook/stories/hooks/useSSE/index.stories.tsx create mode 100644 .storybook/stories/hooks/useSSE/introduce.en-US.md create mode 100644 .storybook/stories/hooks/useSSE/introduce.zh-CN.md create mode 100644 .storybook/stories/hooks/useSplitter/api-doc.en-US.md create mode 100644 .storybook/stories/hooks/useSplitter/api-doc.zh-CN.md create mode 100644 .storybook/stories/hooks/useSplitter/index.stories.tsx create mode 100644 .storybook/stories/hooks/useSplitter/introduce.en-US.md create mode 100644 .storybook/stories/hooks/useSplitter/introduce.zh-CN.md create mode 100644 .storybook/stories/hooks/useStompSocket/api-doc.en-US.md create mode 100644 .storybook/stories/hooks/useStompSocket/api-doc.zh-CN.md create mode 100644 .storybook/stories/hooks/useStompSocket/index.stories.tsx create mode 100644 .storybook/stories/hooks/useStompSocket/introduce.en-US.md create mode 100644 .storybook/stories/hooks/useStompSocket/introduce.zh-CN.md create mode 100644 .storybook/stories/hooks/useUserMedia/api-doc.en-US.md create mode 100644 .storybook/stories/hooks/useUserMedia/api-doc.zh-CN.md create mode 100644 .storybook/stories/hooks/useUserMedia/index.stories.tsx create mode 100644 .storybook/stories/hooks/useUserMedia/introduce.en-US.md create mode 100644 .storybook/stories/hooks/useUserMedia/introduce.zh-CN.md create mode 100644 .storybook/stories/hooks/useValidator/api-doc.en-US.md create mode 100644 .storybook/stories/hooks/useValidator/api-doc.zh-CN.md create mode 100644 .storybook/stories/hooks/useValidator/index.stories.tsx create mode 100644 .storybook/stories/hooks/useValidator/introduce.en-US.md create mode 100644 .storybook/stories/hooks/useValidator/introduce.zh-CN.md create mode 100644 .storybook/stories/hooks/useValidatorBuilder/api-doc.en-US.md create mode 100644 .storybook/stories/hooks/useValidatorBuilder/api-doc.zh-CN.md create mode 100644 .storybook/stories/hooks/useValidatorBuilder/index.stories.tsx create mode 100644 .storybook/stories/hooks/useValidatorBuilder/introduce.en-US.md create mode 100644 .storybook/stories/hooks/useValidatorBuilder/introduce.zh-CN.md create mode 100644 .storybook/stories/hooks/useValidators/api-doc.en-US.md create mode 100644 .storybook/stories/hooks/useValidators/api-doc.zh-CN.md create mode 100644 .storybook/stories/hooks/useValidators/index.stories.tsx create mode 100644 .storybook/stories/hooks/useValidators/introduce.en-US.md create mode 100644 .storybook/stories/hooks/useValidators/introduce.zh-CN.md diff --git a/.storybook/locales/langs/en-US.ts b/.storybook/locales/langs/en-US.ts index fce77e0..dc3c304 100644 --- a/.storybook/locales/langs/en-US.ts +++ b/.storybook/locales/langs/en-US.ts @@ -124,6 +124,81 @@ const enUS = { 'storybook.stories.useDebounce.debouncedValue': 'Debounced value', 'storybook.stories.useDebounce.eventLogTitle': 'Event log', 'storybook.stories.useDebounce.emptyLog': 'No events yet', + 'storybook.stories.useUserMedia.cardTitle': 'Media recorder playground', + 'storybook.stories.useUserMedia.description': + 'This playground demonstrates microphone recording with start/stop controls, recording status, and media stream availability. It does not auto-start on mount.', + 'storybook.stories.useUserMedia.mediaLabel': 'Media type', + 'storybook.stories.useUserMedia.audio': 'Audio (microphone)', + 'storybook.stories.useUserMedia.startRecording': 'Start recording', + 'storybook.stories.useUserMedia.stopRecording': 'Stop recording', + 'storybook.stories.useUserMedia.isRecording': 'Recording', + 'storybook.stories.useUserMedia.notRecording': 'Not recording', + 'storybook.stories.useUserMedia.mediaStreamPresent': 'Media stream: available', + 'storybook.stories.useUserMedia.mediaStreamAbsent': 'Media stream: none', + 'storybook.stories.useUserMedia.recordingIndicator': 'Recording indicator', + 'storybook.stories.useUserMedia.argTypes.media.description': 'Media type to capture. Audio uses the microphone.', + 'storybook.stories.useValidatorBuilder.cardTitle': 'Validator rule builder playground', + 'storybook.stories.useValidatorBuilder.description': + 'Build a custom validation rule interactively by toggling allowed character types, setting min/max length, and optional startsWith rules. The resulting regex pattern and a test input show pass/fail in real time.', + 'storybook.stories.useValidatorBuilder.allowedLabel': 'Allowed characters', + 'storybook.stories.useValidatorBuilder.startsWithLabel': 'Must start with', + 'storybook.stories.useValidatorBuilder.letter': 'Letters (a-zA-Z)', + 'storybook.stories.useValidatorBuilder.lowerLetter': 'Lowercase (a-z)', + 'storybook.stories.useValidatorBuilder.upperLetter': 'Uppercase (A-Z)', + 'storybook.stories.useValidatorBuilder.number': 'Numbers (0-9)', + 'storybook.stories.useValidatorBuilder.chineseCharacter': 'Chinese characters', + 'storybook.stories.useValidatorBuilder.chinesePunctuation': 'Chinese punctuation', + 'storybook.stories.useValidatorBuilder.hyphen': 'Hyphen (-)', + 'storybook.stories.useValidatorBuilder.underscore': 'Underscore (_)', + 'storybook.stories.useValidatorBuilder.special': 'Special chars (comma-separated)', + 'storybook.stories.useValidatorBuilder.minLength': 'Min length', + 'storybook.stories.useValidatorBuilder.maxLength': 'Max length (0 = unlimited)', + 'storybook.stories.useValidatorBuilder.flags': 'Regex flags', + 'storybook.stories.useValidatorBuilder.resultPattern': 'Generated pattern', + 'storybook.stories.useValidatorBuilder.testInputLabel': 'Test value', + 'storybook.stories.useValidatorBuilder.testResultPass': 'PASS', + 'storybook.stories.useValidatorBuilder.testResultFail': 'FAIL', + 'storybook.stories.useValidatorBuilder.argTypes.allowed.description': + 'Allowed character flags. At least one must be true.', + 'storybook.stories.useValidatorBuilder.argTypes.startsWith.description': + 'Optional starting character rules (no min/max).', + 'storybook.stories.useValidatorBuilder.argTypes.flags.description': + 'Regular expression flags (e.g., "i" for case-insensitive).', + 'storybook.stories.useValidator.cardTitle': 'useValidator playground', + 'storybook.stories.useValidator.description': + 'Create a ValidatorRule from BuilderOptions. Configure allowed characters, startsWith, and regex flags to generate a rule compatible with Ant Design forms. Test it live below.', + 'storybook.stories.useValidator.allowedLabel': 'Allowed characters', + 'storybook.stories.useValidator.startsWithLabel': 'Must start with', + 'storybook.stories.useValidator.letter': 'Letters (a-zA-Z)', + 'storybook.stories.useValidator.lowerLetter': 'Lowercase (a-z)', + 'storybook.stories.useValidator.upperLetter': 'Uppercase (A-Z)', + 'storybook.stories.useValidator.number': 'Numbers (0-9)', + 'storybook.stories.useValidator.chineseCharacter': 'Chinese characters', + 'storybook.stories.useValidator.chinesePunctuation': 'Chinese punctuation', + 'storybook.stories.useValidator.hyphen': 'Hyphen (-)', + 'storybook.stories.useValidator.underscore': 'Underscore (_)', + 'storybook.stories.useValidator.special': 'Special chars (comma-separated)', + 'storybook.stories.useValidator.minLength': 'Min length', + 'storybook.stories.useValidator.maxLength': 'Max length (0 = unlimited)', + 'storybook.stories.useValidator.flags': 'Regex flags', + 'storybook.stories.useValidator.resultPattern': 'Generated pattern', + 'storybook.stories.useValidator.testInputLabel': 'Test value', + 'storybook.stories.useValidator.testResultPass': 'PASS', + 'storybook.stories.useValidator.testResultFail': 'FAIL', + 'storybook.stories.useValidator.argTypes.allowed.description': 'Allowed character flags. At least one must be true.', + 'storybook.stories.useValidator.argTypes.startsWith.description': 'Optional starting character rules (no min/max).', + 'storybook.stories.useValidator.argTypes.flags.description': + 'Regular expression flags (e.g., "i" for case-insensitive).', + 'storybook.stories.useValidators.cardTitle': 'Built-in validators playground', + 'storybook.stories.useValidators.description': + 'Select a built-in validation rule from the map (number, email, ip, cnMobile, password, code, name, strongName, etc.). Enter a test value to see the regex pattern and pass/fail result.', + 'storybook.stories.useValidators.selectLabel': 'Select validator', + 'storybook.stories.useValidators.testInputLabel': 'Test value', + 'storybook.stories.useValidators.resultPattern': 'Rule pattern', + 'storybook.stories.useValidators.testResultPass': 'PASS', + 'storybook.stories.useValidators.testResultFail': 'FAIL', + 'storybook.stories.useValidators.argTypes.validatorKey.description': + 'Built-in validator rule to test. Functions (codeWithMax, nameWithMax, strongNameWithMax) are not selectable here.', 'storybook.stories.StreamDownloader.cardTitle': 'StreamDownloader playground', 'storybook.stories.StreamDownloader.realHint': 'This demo uses a real CORS-enabled large file and a real browser save flow', @@ -320,5 +395,168 @@ const enUS = { 'storybook.stories.StreamDownloader.argTypes.progressThrottleMs.description': 'Constructor-level progress throttling window in milliseconds.', 'storybook.stories.StreamDownloader.argTypes.saveStrategy.description': 'Save strategy passed into `start(request)`.', + 'storybook.stories.useLocalStorage.argTypes.sync.description': 'Persist and sync across tabs via the storage event.', + 'storybook.stories.useLocalStorage.cardTitle': 'localStorage-backed state', + 'storybook.stories.useLocalStorage.description': + 'A useState-like hook that persists state to localStorage and optionally syncs across tabs.', + 'storybook.stories.useLocalStorage.inputPlaceholder': 'Type a value to persist', + 'storybook.stories.useLocalStorage.remove': 'Remove', + 'storybook.stories.useLocalStorage.save': 'Save', + 'storybook.stories.useLocalStorage.syncLabel': 'Sync across tabs', + 'storybook.stories.useLocalStorage.tip': + 'The value is written to localStorage on every update. Toggle \"Sync\" to listen for the storage event across tabs.', + 'storybook.stories.useLocalStorage.valueLabel': 'Current value', + 'storybook.stories.useMovable.argTypes.enabled.description': 'Whether dragging is enabled.', + 'storybook.stories.useMovable.argTypes.persist.description': 'Persist the final position to localStorage.', + 'storybook.stories.useMovable.cardTitle': 'Draggable element', + 'storybook.stories.useMovable.description': + 'Make an element movable by dragging, with optional position persistence in localStorage.', + 'storybook.stories.useMovable.dragHint': 'Drag the card below to move it.', + 'storybook.stories.useMovable.enabledLabel': 'Enabled', + 'storybook.stories.useMovable.handle': 'Drag me', + 'storybook.stories.useMovable.persistLabel': 'Persist position', + 'storybook.stories.useMovable.positionLabel': 'Position', + 'storybook.stories.useMovable.tip': + 'Position is clamped to the visible area and updates during pointermove. Persist keeps it across reloads.', + 'storybook.stories.useProcessingText.argTypes.enabled.description': 'Whether the animation is running.', + 'storybook.stories.useProcessingText.argTypes.maxDots.description': 'Maximum number of dots.', + 'storybook.stories.useProcessingText.cardTitle': 'Animated processing text', + 'storybook.stories.useProcessingText.description': 'Create an animated \"processing\" text that cycles through dots.', + 'storybook.stories.useProcessingText.intervalLabel': 'Interval (ms)', + 'storybook.stories.useProcessingText.prefixLabel': 'Prefix', + 'storybook.stories.useProcessingText.prefixPlaceholder': 'e.g. Processing', + 'storybook.stories.useProcessingText.tip': 'The text cycles dots up to maxDots, then resets.', + 'storybook.stories.useRefFunction.argTypes.showIdentity.description': + 'Show whether the returned function reference stayed identical.', + 'storybook.stories.useRefFunction.capture': 'Capture identity', + 'storybook.stories.useRefFunction.cardTitle': 'Stable function reference', + 'storybook.stories.useRefFunction.countLabel': 'Count', + 'storybook.stories.useRefFunction.description': + 'Wrap a callback so its reference stays stable while still reading the latest closure.', + 'storybook.stories.useRefFunction.identityChanged': 'Changed', + 'storybook.stories.useRefFunction.identityLabel': 'Fn identity', + 'storybook.stories.useRefFunction.identityStable': 'Stable', + 'storybook.stories.useRefFunction.increment': 'Increment', + 'storybook.stories.useRefFunction.invoke': 'Invoke', + 'storybook.stories.useRefFunction.tip': + 'Even though the callback reads the latest count, the function reference stays stable across renders.', + 'storybook.stories.useRefValue.argTypes.showSync.description': 'Show whether the ref stays in sync.', + 'storybook.stories.useRefValue.capture': 'Capture ref', + 'storybook.stories.useRefValue.capturedLabel': 'Captured ref.current', + 'storybook.stories.useRefValue.cardTitle': 'Ref synced with latest value', + 'storybook.stories.useRefValue.description': + 'Get a mutable ref that automatically stays in sync with the latest value.', + 'storybook.stories.useRefValue.increment': 'Increment', + 'storybook.stories.useRefValue.refLabel': 'ref.current', + 'storybook.stories.useRefValue.stateLabel': 'State', + 'storybook.stories.useRefValue.tip': + 'ref.current is overwritten on every render so it always reflects the latest value.', + 'storybook.stories.useRowSelection.argTypes.checkable.description': 'Whether rows are selectable via checkbox.', + 'storybook.stories.useRowSelection.cardTitle': 'Cross-page row selection', + 'storybook.stories.useRowSelection.clear': 'Clear', + 'storybook.stories.useRowSelection.columns.name': 'Name', + 'storybook.stories.useRowSelection.columns.role': 'Role', + 'storybook.stories.useRowSelection.data.alice': 'Alice', + 'storybook.stories.useRowSelection.data.bob': 'Bob', + 'storybook.stories.useRowSelection.data.cindy': 'Cindy', + 'storybook.stories.useRowSelection.description': + 'Generate the rowSelection prop for Ant Design Table with cross-page selection support.', + 'storybook.stories.useRowSelection.emptySelection': 'No rows selected', + 'storybook.stories.useRowSelection.roles.admin': 'Admin', + 'storybook.stories.useRowSelection.roles.editor': 'Editor', + 'storybook.stories.useRowSelection.roles.viewer': 'Viewer', + 'storybook.stories.useRowSelection.selectedCount': '{count} row(s) selected', + 'storybook.stories.useSSE.abort': 'Abort', + 'storybook.stories.useSSE.aborted': 'Aborted', + 'storybook.stories.useSSE.argTypes.url.description': 'The SSE endpoint URL.', + 'storybook.stories.useSSE.cardTitle': 'Server-Sent Events', + 'storybook.stories.useSSE.closed': 'Closed', + 'storybook.stories.useSSE.connect': 'Connect', + 'storybook.stories.useSSE.connectedLabel': 'Status', + 'storybook.stories.useSSE.connecting': 'Connecting', + 'storybook.stories.useSSE.currentUrl': 'Connecting to: {url}', + 'storybook.stories.useSSE.description': 'Establish a Server-Sent Events (SSE) connection and manage its lifecycle.', + 'storybook.stories.useSSE.emptyMessages': 'No messages received', + 'storybook.stories.useSSE.errorPrefix': 'Error', + 'storybook.stories.useSSE.serverHint': 'This demo requires a live SSE endpoint. Connect to one to receive messages.', + 'storybook.stories.useSSE.status.connected': 'Connected', + 'storybook.stories.useSSE.status.idle': 'Idle', + 'storybook.stories.useSSE.status.requesting': 'Requesting', + 'storybook.stories.useSSE.urlPlaceholder': 'https://example.com/events', + 'storybook.stories.useSplitter.argTypes.defaultRatio.description': 'Initial ratio between the two panes.', + 'storybook.stories.useSplitter.argTypes.direction.description': 'Layout direction: vertical or horizontal.', + 'storybook.stories.useSplitter.argTypes.maxRatio.description': 'Maximum ratio of the leading pane.', + 'storybook.stories.useSplitter.argTypes.minRatio.description': 'Minimum ratio of the leading pane.', + 'storybook.stories.useSplitter.cardTitle': 'Resizable split panes', + 'storybook.stories.useSplitter.description': 'Make any two-pane layout resizable with a draggable splitter.', + 'storybook.stories.useSplitter.directionLabel': 'Direction', + 'storybook.stories.useSplitter.maxRatioLabel': 'Max ratio', + 'storybook.stories.useSplitter.minRatioLabel': 'Min ratio', + 'storybook.stories.useSplitter.pane': 'Pane {position}', + 'storybook.stories.useSplitter.ratioLabel': 'Ratio', + 'storybook.stories.useStompSocket.argTypes.sendEndpoint.description': 'Channel for publishing messages.', + 'storybook.stories.useStompSocket.argTypes.subscribeEndpoint.description': 'Endpoint for receiving messages.', + 'storybook.stories.useStompSocket.argTypes.url.description': 'The SockJS/STOMP endpoint URL.', + 'storybook.stories.useStompSocket.cardTitle': 'STOMP over WebSocket', + 'storybook.stories.useStompSocket.close': 'Close', + 'storybook.stories.useStompSocket.connect': 'Connect', + 'storybook.stories.useStompSocket.description': 'Establish a STOMP-over-WebSocket connection using SockJS.', + 'storybook.stories.useStompSocket.emptyLog': 'No log entries', + 'storybook.stories.useStompSocket.log.closed': 'Disconnected', + 'storybook.stories.useStompSocket.log.connected': 'Connected', + 'storybook.stories.useStompSocket.log.connecting': 'Connecting', + 'storybook.stories.useStompSocket.log.received': 'Received:', + 'storybook.stories.useStompSocket.log.sent': 'Sent:', + 'storybook.stories.useStompSocket.logTitle': 'Activity log', + 'storybook.stories.useStompSocket.messagePlaceholder': 'Type a message', + 'storybook.stories.useStompSocket.send': 'Send', + 'storybook.stories.useStompSocket.serverHint': 'This demo requires a live SockJS/STOMP server.', + 'storybook.stories.useStompSocket.status.connected': 'Connected', + 'storybook.stories.useStompSocket.status.disconnected': 'Disconnected', + 'storybook.stories.useStompSocket.statusLabel': 'Status', + 'storybook.stories.useStompSocket.urlPlaceholder': 'https://example.com/sockjs', + 'storybook.stories.useUserMedia.argTypes.audio.description': 'Whether to capture the microphone audio.', + 'storybook.stories.useUserMedia.recording': 'Recording', + 'storybook.stories.useUserMedia.recordingLabel': 'Recording', + 'storybook.stories.useUserMedia.start': 'Start recording', + 'storybook.stories.useUserMedia.stop': 'Stop recording', + 'storybook.stories.useUserMedia.streamAvailable': 'Available', + 'storybook.stories.useUserMedia.streamLabel': 'Media stream', + 'storybook.stories.useUserMedia.streamNone': 'None', + 'storybook.stories.useUserMedia.tip': 'Recording requests the microphone. It never auto-starts.', + 'storybook.stories.useValidator.argTypes.chineseCharacter.description': 'Allow Chinese characters.', + 'storybook.stories.useValidator.argTypes.hyphen.description': 'Allow the hyphen character.', + 'storybook.stories.useValidator.argTypes.letter.description': 'Allow letters.', + 'storybook.stories.useValidator.argTypes.max.description': 'Maximum number of characters.', + 'storybook.stories.useValidator.argTypes.number.description': 'Allow numbers.', + 'storybook.stories.useValidator.argTypes.testValue.description': 'The value to test against the rule.', + 'storybook.stories.useValidator.argTypes.underscore.description': 'Allow the underscore character.', + 'storybook.stories.useValidator.fail': 'Fail', + 'storybook.stories.useValidator.maxLabel': 'Max length', + 'storybook.stories.useValidator.pass': 'Pass', + 'storybook.stories.useValidator.patternLabel': 'Pattern', + 'storybook.stories.useValidator.testLabel': 'Test value', + 'storybook.stories.useValidator.tip': 'At least one allowed flag must be enabled.', + 'storybook.stories.useValidatorBuilder.argTypes.chineseCharacter.description': 'Allow Chinese characters.', + 'storybook.stories.useValidatorBuilder.argTypes.hyphen.description': 'Allow the hyphen character.', + 'storybook.stories.useValidatorBuilder.argTypes.letter.description': 'Allow letters.', + 'storybook.stories.useValidatorBuilder.argTypes.max.description': 'Maximum number of characters.', + 'storybook.stories.useValidatorBuilder.argTypes.number.description': 'Allow numbers.', + 'storybook.stories.useValidatorBuilder.argTypes.testValue.description': 'The value to test against the built rule.', + 'storybook.stories.useValidatorBuilder.argTypes.underscore.description': 'Allow the underscore character.', + 'storybook.stories.useValidatorBuilder.fail': 'Fail', + 'storybook.stories.useValidatorBuilder.maxLabel': 'Max length', + 'storybook.stories.useValidatorBuilder.pass': 'Pass', + 'storybook.stories.useValidatorBuilder.patternLabel': 'Pattern', + 'storybook.stories.useValidatorBuilder.resultLabel': 'Result', + 'storybook.stories.useValidatorBuilder.tip': 'Toggle the flags, then test a value against the generated rule.', + 'storybook.stories.useValidators.argTypes.rule.description': 'A built-in validation rule.', + 'storybook.stories.useValidators.argTypes.value.description': 'The value to test.', + 'storybook.stories.useValidators.fail': 'Fail', + 'storybook.stories.useValidators.pass': 'Pass', + 'storybook.stories.useValidators.patternLabel': 'Pattern', + 'storybook.stories.useValidators.resultLabel': 'Result', + 'storybook.stories.useValidators.tip': 'Each rule is antd-compatible; use them directly in Form.Item rules.', + 'storybook.stories.useValidators.valuePlaceholder': 'Type a test value', } as const; export default enUS; diff --git a/.storybook/locales/langs/zh-CN.ts b/.storybook/locales/langs/zh-CN.ts index 51631f6..3881ceb 100644 --- a/.storybook/locales/langs/zh-CN.ts +++ b/.storybook/locales/langs/zh-CN.ts @@ -122,6 +122,77 @@ const zhCN = { 'storybook.stories.useDebounce.debouncedValue': '防抖结果', 'storybook.stories.useDebounce.eventLogTitle': '事件日志', 'storybook.stories.useDebounce.emptyLog': '暂无事件', + 'storybook.stories.useUserMedia.cardTitle': '媒体录制演示面板', + 'storybook.stories.useUserMedia.description': + '本演示展示麦克风录制功能,包含开始/停止录制控制、录制状态指示和媒体流可用性。不会在挂载时自动开始录制。', + 'storybook.stories.useUserMedia.mediaLabel': '媒体类型', + 'storybook.stories.useUserMedia.audio': '音频(麦克风)', + 'storybook.stories.useUserMedia.startRecording': '开始录制', + 'storybook.stories.useUserMedia.stopRecording': '停止录制', + 'storybook.stories.useUserMedia.isRecording': '录制中', + 'storybook.stories.useUserMedia.notRecording': '未录制', + 'storybook.stories.useUserMedia.mediaStreamPresent': '媒体流:可用', + 'storybook.stories.useUserMedia.mediaStreamAbsent': '媒体流:无', + 'storybook.stories.useUserMedia.recordingIndicator': '录制指示器', + 'storybook.stories.useUserMedia.argTypes.media.description': '要捕获的媒体类型。音频使用麦克风。', + 'storybook.stories.useValidatorBuilder.cardTitle': '校验规则构建器演示', + 'storybook.stories.useValidatorBuilder.description': + '通过切换允许的字符类型、设置最小/最大长度和可选的开头规则,交互式构建自定义校验规则。生成的正则模式和测试输入框实时显示通过/失败结果。', + 'storybook.stories.useValidatorBuilder.allowedLabel': '允许的字符', + 'storybook.stories.useValidatorBuilder.startsWithLabel': '必须以...开头', + 'storybook.stories.useValidatorBuilder.letter': '字母 (a-zA-Z)', + 'storybook.stories.useValidatorBuilder.lowerLetter': '小写字母 (a-z)', + 'storybook.stories.useValidatorBuilder.upperLetter': '大写字母 (A-Z)', + 'storybook.stories.useValidatorBuilder.number': '数字 (0-9)', + 'storybook.stories.useValidatorBuilder.chineseCharacter': '中文字符', + 'storybook.stories.useValidatorBuilder.chinesePunctuation': '中文标点', + 'storybook.stories.useValidatorBuilder.hyphen': '连字符 (-)', + 'storybook.stories.useValidatorBuilder.underscore': '下划线 (_)', + 'storybook.stories.useValidatorBuilder.special': '特殊字符(逗号分隔)', + 'storybook.stories.useValidatorBuilder.minLength': '最小长度', + 'storybook.stories.useValidatorBuilder.maxLength': '最大长度 (0 = 不限制)', + 'storybook.stories.useValidatorBuilder.flags': '正则标志', + 'storybook.stories.useValidatorBuilder.resultPattern': '生成的模式', + 'storybook.stories.useValidatorBuilder.testInputLabel': '测试值', + 'storybook.stories.useValidatorBuilder.testResultPass': '通过', + 'storybook.stories.useValidatorBuilder.testResultFail': '失败', + 'storybook.stories.useValidatorBuilder.argTypes.allowed.description': '允许的字符标志。至少有一个必须为 true。', + 'storybook.stories.useValidatorBuilder.argTypes.startsWith.description': '可选的开头字符规则(不含 min/max)。', + 'storybook.stories.useValidatorBuilder.argTypes.flags.description': '正则表达式标志(如 "i" 表示忽略大小写)。', + 'storybook.stories.useValidator.cardTitle': 'useValidator 演示', + 'storybook.stories.useValidator.description': + '从 BuilderOptions 创建 ValidatorRule。配置允许的字符、开头规则和正则标志,生成与 Ant Design 表单兼容的规则。下方可实时测试。', + 'storybook.stories.useValidator.allowedLabel': '允许的字符', + 'storybook.stories.useValidator.startsWithLabel': '必须以...开头', + 'storybook.stories.useValidator.letter': '字母 (a-zA-Z)', + 'storybook.stories.useValidator.lowerLetter': '小写字母 (a-z)', + 'storybook.stories.useValidator.upperLetter': '大写字母 (A-Z)', + 'storybook.stories.useValidator.number': '数字 (0-9)', + 'storybook.stories.useValidator.chineseCharacter': '中文字符', + 'storybook.stories.useValidator.chinesePunctuation': '中文标点', + 'storybook.stories.useValidator.hyphen': '连字符 (-)', + 'storybook.stories.useValidator.underscore': '下划线 (_)', + 'storybook.stories.useValidator.special': '特殊字符(逗号分隔)', + 'storybook.stories.useValidator.minLength': '最小长度', + 'storybook.stories.useValidator.maxLength': '最大长度 (0 = 不限制)', + 'storybook.stories.useValidator.flags': '正则标志', + 'storybook.stories.useValidator.resultPattern': '生成的模式', + 'storybook.stories.useValidator.testInputLabel': '测试值', + 'storybook.stories.useValidator.testResultPass': '通过', + 'storybook.stories.useValidator.testResultFail': '失败', + 'storybook.stories.useValidator.argTypes.allowed.description': '允许的字符标志。至少有一个必须为 true。', + 'storybook.stories.useValidator.argTypes.startsWith.description': '可选的开头字符规则(不含 min/max)。', + 'storybook.stories.useValidator.argTypes.flags.description': '正则表达式标志(如 "i" 表示忽略大小写)。', + 'storybook.stories.useValidators.cardTitle': '内置校验器演示', + 'storybook.stories.useValidators.description': + '从规则映射中选择一个内置校验规则(number、email、ip、cnMobile、password、code、name、strongName 等)。输入测试值查看正则模式和通过/失败结果。', + 'storybook.stories.useValidators.selectLabel': '选择校验器', + 'storybook.stories.useValidators.testInputLabel': '测试值', + 'storybook.stories.useValidators.resultPattern': '规则模式', + 'storybook.stories.useValidators.testResultPass': '通过', + 'storybook.stories.useValidators.testResultFail': '失败', + 'storybook.stories.useValidators.argTypes.validatorKey.description': + '要测试的内置校验规则。函数类型(codeWithMax、nameWithMax、strongNameWithMax)不可在此选择。', 'storybook.stories.StreamDownloader.cardTitle': 'StreamDownloader 演示面板', 'storybook.stories.StreamDownloader.realHint': '本示例使用真实的、支持 CORS 的大文件,以及真实浏览器保存流程', 'storybook.stories.StreamDownloader.description': @@ -292,5 +363,160 @@ const zhCN = { 'storybook.stories.StreamDownloader.argTypes.progressThrottleMs.description': '构造参数级别的进度节流窗口,单位毫秒。', 'storybook.stories.StreamDownloader.argTypes.saveStrategy.description': '传给 `start(request)` 的保存策略。', + 'storybook.stories.useLocalStorage.argTypes.sync.description': '是否通过 storage 事件跨标签页同步状态', + 'storybook.stories.useLocalStorage.cardTitle': '本地存储状态', + 'storybook.stories.useLocalStorage.description': + '一个类似 useState 的工具,将状态持久化到 localStorage,并可跨标签页同步。', + 'storybook.stories.useLocalStorage.inputPlaceholder': '输入要持久化的值', + 'storybook.stories.useLocalStorage.remove': '移除', + 'storybook.stories.useLocalStorage.save': '保存', + 'storybook.stories.useLocalStorage.syncLabel': '跨标签页同步', + 'storybook.stories.useLocalStorage.tip': + '每次更新都会将值写入 localStorage。开启\"同步\"后会监听跨标签页的 storage 事件。', + 'storybook.stories.useLocalStorage.valueLabel': '当前值', + 'storybook.stories.useMovable.argTypes.enabled.description': '是否启用拖拽', + 'storybook.stories.useMovable.argTypes.persist.description': '是否将最终位置持久化到 localStorage', + 'storybook.stories.useMovable.cardTitle': '可拖拽元素', + 'storybook.stories.useMovable.description': '通过拖动使元素可移动,并可选地将位置持久化到 localStorage。', + 'storybook.stories.useMovable.dragHint': '拖动下方卡片可移动它。', + 'storybook.stories.useMovable.enabledLabel': '已启用', + 'storybook.stories.useMovable.handle': '拖动我', + 'storybook.stories.useMovable.persistLabel': '持久化位置', + 'storybook.stories.useMovable.positionLabel': '位置', + 'storybook.stories.useMovable.tip': '位置会被限制在可见区域内,并在指针移动时更新。开启持久化后刷新页面仍会保留。', + 'storybook.stories.useProcessingText.argTypes.enabled.description': '是否运行动画', + 'storybook.stories.useProcessingText.argTypes.maxDots.description': '点的最大数量', + 'storybook.stories.useProcessingText.cardTitle': '处理中动画文本', + 'storybook.stories.useProcessingText.description': '创建循环显示点的\"处理中\"动画文本。', + 'storybook.stories.useProcessingText.intervalLabel': '间隔(毫秒)', + 'storybook.stories.useProcessingText.prefixLabel': '前缀', + 'storybook.stories.useProcessingText.prefixPlaceholder': '例如:处理中', + 'storybook.stories.useProcessingText.tip': '文本会在达到最大点数后循环重置。', + 'storybook.stories.useRefFunction.argTypes.showIdentity.description': '是否显示返回的函数引用是否保持一致', + 'storybook.stories.useRefFunction.capture': '记录引用', + 'storybook.stories.useRefFunction.cardTitle': '稳定的函数引用', + 'storybook.stories.useRefFunction.countLabel': '计数', + 'storybook.stories.useRefFunction.description': '包装一个回调,使其引用保持稳定,同时始终读取最新闭包。', + 'storybook.stories.useRefFunction.identityChanged': '已改变', + 'storybook.stories.useRefFunction.identityLabel': '函数引用', + 'storybook.stories.useRefFunction.identityStable': '稳定', + 'storybook.stories.useRefFunction.increment': '递增', + 'storybook.stories.useRefFunction.invoke': '调用', + 'storybook.stories.useRefFunction.tip': '尽管回调能读取到最新的计数,函数引用在渲染间仍然保持稳定。', + 'storybook.stories.useRefValue.argTypes.showSync.description': '是否显示 ref 是否保持同步', + 'storybook.stories.useRefValue.capture': '记录 ref', + 'storybook.stories.useRefValue.capturedLabel': '记录的 ref.current', + 'storybook.stories.useRefValue.cardTitle': '与最新值同步的 ref', + 'storybook.stories.useRefValue.description': '获取一个可变的 ref,并自动与最新值保持同步。', + 'storybook.stories.useRefValue.increment': '递增', + 'storybook.stories.useRefValue.refLabel': 'ref.current', + 'storybook.stories.useRefValue.stateLabel': '状态', + 'storybook.stories.useRefValue.tip': '每次渲染都会覆盖 ref.current,使其始终反映最新值。', + 'storybook.stories.useRowSelection.argTypes.checkable.description': '行是否可通过复选框选中', + 'storybook.stories.useRowSelection.cardTitle': '跨页行选择', + 'storybook.stories.useRowSelection.clear': '清空', + 'storybook.stories.useRowSelection.columns.name': '姓名', + 'storybook.stories.useRowSelection.columns.role': '角色', + 'storybook.stories.useRowSelection.data.alice': '爱丽丝', + 'storybook.stories.useRowSelection.data.bob': '鲍勃', + 'storybook.stories.useRowSelection.data.cindy': '辛迪', + 'storybook.stories.useRowSelection.description': '为 Ant Design Table 生成带跨页选择支持的 rowSelection 配置。', + 'storybook.stories.useRowSelection.emptySelection': '未选择任何行', + 'storybook.stories.useRowSelection.roles.admin': '管理员', + 'storybook.stories.useRowSelection.roles.editor': '编辑者', + 'storybook.stories.useRowSelection.roles.viewer': '浏览者', + 'storybook.stories.useRowSelection.selectedCount': '已选 {count} 行', + 'storybook.stories.useSSE.abort': '中断', + 'storybook.stories.useSSE.aborted': '已中断', + 'storybook.stories.useSSE.argTypes.url.description': 'SSE 端点地址', + 'storybook.stories.useSSE.cardTitle': '服务端事件流', + 'storybook.stories.useSSE.closed': '已关闭', + 'storybook.stories.useSSE.connect': '连接', + 'storybook.stories.useSSE.connectedLabel': '状态', + 'storybook.stories.useSSE.connecting': '连接中', + 'storybook.stories.useSSE.currentUrl': '正在连接:{url}', + 'storybook.stories.useSSE.description': '建立 Server-Sent Events(SSE)连接并管理其生命周期。', + 'storybook.stories.useSSE.emptyMessages': '尚未收到消息', + 'storybook.stories.useSSE.errorPrefix': '错误', + 'storybook.stories.useSSE.serverHint': '本示例需要真实的 SSE 端点。连接后即可接收消息。', + 'storybook.stories.useSSE.status.connected': '已连接', + 'storybook.stories.useSSE.status.idle': '空闲', + 'storybook.stories.useSSE.status.requesting': '请求中', + 'storybook.stories.useSSE.urlPlaceholder': 'https://example.com/events', + 'storybook.stories.useSplitter.argTypes.defaultRatio.description': '两栏之间的初始比例', + 'storybook.stories.useSplitter.argTypes.direction.description': '布局方向:垂直或水平', + 'storybook.stories.useSplitter.argTypes.maxRatio.description': '前栏的最大比例', + 'storybook.stories.useSplitter.argTypes.minRatio.description': '前栏的最小比例', + 'storybook.stories.useSplitter.cardTitle': '可调整大小的分栏', + 'storybook.stories.useSplitter.description': '让任意双面板布局通过可拖动的分割条调整尺寸。', + 'storybook.stories.useSplitter.directionLabel': '方向', + 'storybook.stories.useSplitter.maxRatioLabel': '最大比例', + 'storybook.stories.useSplitter.minRatioLabel': '最小比例', + 'storybook.stories.useSplitter.pane': '面板 {position}', + 'storybook.stories.useSplitter.ratioLabel': '比例', + 'storybook.stories.useStompSocket.argTypes.sendEndpoint.description': '用于发布消息的频道', + 'storybook.stories.useStompSocket.argTypes.subscribeEndpoint.description': '用于接收消息的端点', + 'storybook.stories.useStompSocket.argTypes.url.description': 'SockJS/STOMP 端点地址', + 'storybook.stories.useStompSocket.cardTitle': '基于 WebSocket 的 STOMP', + 'storybook.stories.useStompSocket.close': '关闭', + 'storybook.stories.useStompSocket.connect': '连接', + 'storybook.stories.useStompSocket.description': '基于 SockJS 建立 STOMP 协议的 WebSocket 连接。', + 'storybook.stories.useStompSocket.emptyLog': '暂无日志', + 'storybook.stories.useStompSocket.log.closed': '已断开', + 'storybook.stories.useStompSocket.log.connected': '已连接', + 'storybook.stories.useStompSocket.log.connecting': '连接中', + 'storybook.stories.useStompSocket.log.received': '收到:', + 'storybook.stories.useStompSocket.log.sent': '已发送:', + 'storybook.stories.useStompSocket.logTitle': '活动日志', + 'storybook.stories.useStompSocket.messagePlaceholder': '输入消息', + 'storybook.stories.useStompSocket.send': '发送', + 'storybook.stories.useStompSocket.serverHint': '本示例需要真实的 SockJS/STOMP 服务端。', + 'storybook.stories.useStompSocket.status.connected': '已连接', + 'storybook.stories.useStompSocket.status.disconnected': '未连接', + 'storybook.stories.useStompSocket.statusLabel': '状态', + 'storybook.stories.useStompSocket.urlPlaceholder': 'https://example.com/sockjs', + 'storybook.stories.useUserMedia.argTypes.audio.description': '是否采集麦克风音频', + 'storybook.stories.useUserMedia.recording': '录制中', + 'storybook.stories.useUserMedia.recordingLabel': '录制中', + 'storybook.stories.useUserMedia.start': '开始录制', + 'storybook.stories.useUserMedia.stop': '停止录制', + 'storybook.stories.useUserMedia.streamAvailable': '可用', + 'storybook.stories.useUserMedia.streamLabel': '媒体流', + 'storybook.stories.useUserMedia.streamNone': '无', + 'storybook.stories.useUserMedia.tip': '录制会请求麦克风权限;不会自动开始。', + 'storybook.stories.useValidator.argTypes.chineseCharacter.description': '允许中文字符', + 'storybook.stories.useValidator.argTypes.hyphen.description': '允许连字符', + 'storybook.stories.useValidator.argTypes.letter.description': '允许字母', + 'storybook.stories.useValidator.argTypes.max.description': '最大字符数量', + 'storybook.stories.useValidator.argTypes.number.description': '允许数字', + 'storybook.stories.useValidator.argTypes.testValue.description': '要用规则测试的值', + 'storybook.stories.useValidator.argTypes.underscore.description': '允许下划线', + 'storybook.stories.useValidator.fail': '不通过', + 'storybook.stories.useValidator.maxLabel': '最大长度', + 'storybook.stories.useValidator.pass': '通过', + 'storybook.stories.useValidator.patternLabel': '正则', + 'storybook.stories.useValidator.testLabel': '测试值', + 'storybook.stories.useValidator.tip': '至少需要启用一个允许的字符标志。', + 'storybook.stories.useValidatorBuilder.argTypes.chineseCharacter.description': '允许中文字符', + 'storybook.stories.useValidatorBuilder.argTypes.hyphen.description': '允许连字符', + 'storybook.stories.useValidatorBuilder.argTypes.letter.description': '允许字母', + 'storybook.stories.useValidatorBuilder.argTypes.max.description': '最大字符数量', + 'storybook.stories.useValidatorBuilder.argTypes.number.description': '允许数字', + 'storybook.stories.useValidatorBuilder.argTypes.testValue.description': '要用生成的规则测试的值', + 'storybook.stories.useValidatorBuilder.argTypes.underscore.description': '允许下划线', + 'storybook.stories.useValidatorBuilder.fail': '不通过', + 'storybook.stories.useValidatorBuilder.maxLabel': '最大长度', + 'storybook.stories.useValidatorBuilder.pass': '通过', + 'storybook.stories.useValidatorBuilder.patternLabel': '正则', + 'storybook.stories.useValidatorBuilder.resultLabel': '结果', + 'storybook.stories.useValidatorBuilder.tip': '切换标志,然后用生成的规则测试一个值。', + 'storybook.stories.useValidators.argTypes.rule.description': '一个内置校验规则', + 'storybook.stories.useValidators.argTypes.value.description': '要测试的值', + 'storybook.stories.useValidators.fail': '不通过', + 'storybook.stories.useValidators.pass': '通过', + 'storybook.stories.useValidators.patternLabel': '正则', + 'storybook.stories.useValidators.resultLabel': '结果', + 'storybook.stories.useValidators.tip': '每条规则都与 antd 兼容,可直接用于 Form.Item 的 rules。', + 'storybook.stories.useValidators.valuePlaceholder': '输入测试值', } as const; export default zhCN; diff --git a/.storybook/stories/hooks/useLocalStorage/api-doc.en-US.md b/.storybook/stories/hooks/useLocalStorage/api-doc.en-US.md new file mode 100644 index 0000000..6b8cd7f --- /dev/null +++ b/.storybook/stories/hooks/useLocalStorage/api-doc.en-US.md @@ -0,0 +1,25 @@ +## API + +### Params + +| Name | Description | Type | (Default) | +| -------------- | ---------------------------------------------- | --------------------------- | --------- | +| `key` | Storage key; empty/null disables persistence | `string \| null` | - | +| `initialValue` | Initial value; function form is evaluated once | `T \| (() => T)` | - | +| `options` | Optional behavior options | `UseLocalStorageOptions<T>` | - | + +### Options — UseLocalStorageOptions\<T\> + +| Name | Description | Type | (Default) | +| ------------- | ----------------------------------------------- | ---------------------- | ---------------- | +| `sync` | Listen to `storage` events and sync across tabs | `boolean` | `true` | +| `serialize` | Custom serializer | `(value: T) => string` | `JSON.stringify` | +| `deserialize` | Custom deserializer | `(raw: string) => T` | `JSON.parse` | + +### Return + +| Member | Description | Signature | +| ---------- | ----------------------------------------- | ----------------------------------------- | +| `value` | Current state value | `T` | +| `setValue` | Update the value and persist to storage | `(action: T \| ((prev: T) => T)) => void` | +| `remove` | Reset to initial value and remove the key | `() => void` | diff --git a/.storybook/stories/hooks/useLocalStorage/api-doc.zh-CN.md b/.storybook/stories/hooks/useLocalStorage/api-doc.zh-CN.md new file mode 100644 index 0000000..78439b8 --- /dev/null +++ b/.storybook/stories/hooks/useLocalStorage/api-doc.zh-CN.md @@ -0,0 +1,25 @@ +## API + +### 参数 + +| 参数 | 说明 | 类型 | 默认值 | +| -------------- | -------------------------------- | --------------------------- | ------ | +| `key` | 存储 key;空值/`null` 禁用持久化 | `string \| null` | - | +| `initialValue` | 初始值;函数形式只会被求值一次 | `T \| (() => T)` | - | +| `options` | 可选行为配置 | `UseLocalStorageOptions<T>` | - | + +### 选项 —— UseLocalStorageOptions\<T\> + +| 参数 | 说明 | 类型 | 默认值 | +| ------------- | --------------------------------- | ---------------------- | ---------------- | +| `sync` | 监听 `storage` 事件,跨标签页同步 | `boolean` | `true` | +| `serialize` | 自定义序列化函数 | `(value: T) => string` | `JSON.stringify` | +| `deserialize` | 自定义反序列化函数 | `(raw: string) => T` | `JSON.parse` | + +### 返回值 + +| 成员 | 说明 | 签名 | +| ---------- | ------------------------ | ----------------------------------------- | +| `value` | 当前状态值 | `T` | +| `setValue` | 更新值并写入存储 | `(action: T \| ((prev: T) => T)) => void` | +| `remove` | 重置为初始值并删除存储项 | `() => void` | diff --git a/.storybook/stories/hooks/useLocalStorage/index.stories.tsx b/.storybook/stories/hooks/useLocalStorage/index.stories.tsx new file mode 100644 index 0000000..db43a4e --- /dev/null +++ b/.storybook/stories/hooks/useLocalStorage/index.stories.tsx @@ -0,0 +1,92 @@ +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { Alert, Button, Card, Input, Space, Switch, Typography } from 'antd'; +import useLocalStorage from '../../../../src/hooks/useLocalStorage'; +import storyI18n, { storyT, useStoryT } from '../../../locales'; +import apiDocEN from './api-doc.en-US.md?raw'; +import apiDocCN from './api-doc.zh-CN.md?raw'; +import introduceEN from './introduce.en-US.md?raw'; +import introduceCN from './introduce.zh-CN.md?raw'; + +interface UseLocalStorageStoryArgs { + sync: boolean; +} + +const meta: Meta<UseLocalStorageStoryArgs> = { + title: 'Hooks/useLocalStorage', + parameters: { + docs: { + description: { + component: storyI18n.language === 'zh-CN' ? `${introduceCN}\n${apiDocCN}` : `${introduceEN}\n${apiDocEN}`, + }, + }, + }, + args: { + sync: true, + }, + argTypes: { + sync: { + control: 'boolean', + description: storyT('storybook.stories.useLocalStorage.argTypes.sync.description'), + }, + }, +}; + +export default meta; +type Story = StoryObj<UseLocalStorageStoryArgs>; + +const STORAGE_KEY = 'storybook.useLocalStorage.demo'; + +export const Playground: Story = { + parameters: { + docs: { + description: { + story: + '- **EN:** Type into the input to persist the value to `localStorage`. Toggle `sync` to enable/disable cross-tab synchronization, and use **Remove** to clear the stored value.\\n- **CN:** 在输入框中输入内容即可持久化到 `localStorage`。切换 `sync` 开启/关闭跨标签页同步,点击**移除**清除已存储的值。', + }, + }, + }, + render: function Render(args: UseLocalStorageStoryArgs) { + return <UseLocalStorageStoryDemo {...args} />; + }, +}; + +function UseLocalStorageStoryDemo({ sync }: UseLocalStorageStoryArgs) { + const t = useStoryT(); + const [draft, setDraft] = useState(''); + const [value, setValue, remove] = useLocalStorage<string>(STORAGE_KEY, ''); + + return ( + <Card variant="outlined" style={{ maxWidth: 920 }} title={t('storybook.stories.useLocalStorage.cardTitle')}> + <Space orientation="vertical" size="large" style={{ width: '100%' }}> + <Typography.Paragraph style={{ marginBottom: 0 }}> + {t('storybook.stories.useLocalStorage.description')} + </Typography.Paragraph> + + <Space wrap> + <Input + style={{ maxWidth: 360 }} + placeholder={t('storybook.stories.useLocalStorage.inputPlaceholder')} + value={draft} + onChange={(e) => setDraft(e.target.value)} + /> + <Button type="primary" onClick={() => setValue(draft)}> + {t('storybook.stories.useLocalStorage.save')} + </Button> + <Button danger onClick={remove}> + {t('storybook.stories.useLocalStorage.remove')} + </Button> + </Space> + + <Space wrap> + <Typography.Text strong>{t('storybook.stories.useLocalStorage.syncLabel')}</Typography.Text> + <Switch checked={sync} disabled /> + <Typography.Text strong>{t('storybook.stories.useLocalStorage.valueLabel')}</Typography.Text> + <Typography.Text code>{value || '—'}</Typography.Text> + </Space> + + <Alert type="info" message={t('storybook.stories.useLocalStorage.tip')} showIcon /> + </Space> + </Card> + ); +} diff --git a/.storybook/stories/hooks/useLocalStorage/introduce.en-US.md b/.storybook/stories/hooks/useLocalStorage/introduce.en-US.md new file mode 100644 index 0000000..2e1079d --- /dev/null +++ b/.storybook/stories/hooks/useLocalStorage/introduce.en-US.md @@ -0,0 +1,21 @@ +Persist React state to `localStorage` with an API shaped like `useState`. The hook reads the initial value lazily, writes on every update, and optionally keeps state in sync across browser tabs via the `storage` event. + +## When to use + +- Persisting user preferences, form drafts, or UI settings across page reloads. +- Sharing state between browser tabs (e.g. theme, locale, panel layout). +- Replacing manual `localStorage.getItem` / `setItem` pairs with a stateful API. + +## Key features + +- **useState-like API** — returns `[value, setValue, remove]`; `setValue` accepts a function updater. +- **Cross-tab sync** — listens to `storage` events when `sync` is enabled (default). +- **Custom (de)serialization** — override `serialize` / `deserialize` for non-JSON values. +- **Safe fallbacks** — empty keys behave like `useState` and never touch storage; read/write errors are swallowed. + +## Usage notes + +- The `key` is trimmed; an empty key disables storage entirely. +- `remove()` resets to the initial value and removes the stored key. +- When the `key` changes, state is re-initialized from storage. +- Storage access is guarded for non-browser environments. diff --git a/.storybook/stories/hooks/useLocalStorage/introduce.zh-CN.md b/.storybook/stories/hooks/useLocalStorage/introduce.zh-CN.md new file mode 100644 index 0000000..e3a7c0f --- /dev/null +++ b/.storybook/stories/hooks/useLocalStorage/introduce.zh-CN.md @@ -0,0 +1,21 @@ +将 React 状态持久化到 `localStorage`,API 与 `useState` 类似。hook 惰性读取初始值,每次更新时写入存储,并可选地通过 `storage` 事件保持浏览器标签页间状态同步。 + +## 适用场景 + +- 跨页面刷新持久化用户偏好、表单草稿或 UI 设置。 +- 在多个标签页之间共享状态(如主题、语言、面板布局)。 +- 用状态式 API 替代手动 `localStorage.getItem` / `setItem`。 + +## 核心特性 + +- **useState 风格 API** —— 返回 `[value, setValue, remove]`;`setValue` 支持函数式更新。 +- **跨标签页同步** —— `sync` 开启(默认)时监听 `storage` 事件。 +- **自定义序列化** —— 通过 `serialize` / `deserialize` 支持非 JSON 值。 +- **安全降级** —— 空 key 时退化为 `useState` 且不触碰存储;读写错误被静默吞掉。 + +## 使用注意 + +- `key` 会被去空格;空 key 完全禁用存储。 +- `remove()` 将状态重置为初始值并删除存储项。 +- `key` 变化时会从存储重新初始化状态。 +- 存储访问对非浏览器环境做了保护。 diff --git a/.storybook/stories/hooks/useMovable/api-doc.en-US.md b/.storybook/stories/hooks/useMovable/api-doc.en-US.md new file mode 100644 index 0000000..1e1778f --- /dev/null +++ b/.storybook/stories/hooks/useMovable/api-doc.en-US.md @@ -0,0 +1,21 @@ +## API + +### Props — UseMovableProps + +| Name | Description | Type | (Default) | +| ----------------- | ---------------------------------------------------------------------------------- | ------------------------ | --------- | +| `enabled` | Whether dragging is enabled | `boolean` | `true` | +| `containerRef` | Ref of the container element | `RefObject<HTMLElement>` | - | +| `ignoreSelectors` | Selectors of elements that should not trigger dragging (e.g. interactive controls) | `string[]` | - | +| `storageKey` | Key for storing position in `localStorage`; omit to disable persistence | `string` | - | + +### Return + +| Member | Description | Signature | +| --------------- | ------------------------------------------------------- | ------------------------------------------------- | +| `onPointerDown` | Pointer down handler; spread onto the draggable element | `(e: React.PointerEvent<HTMLDivElement>) => void` | +| `position` | Current position `{ left, top }` in pixels | `MovePosition \| undefined` | + +### Types + +`MovePosition = { left: number; top: number }` diff --git a/.storybook/stories/hooks/useMovable/api-doc.zh-CN.md b/.storybook/stories/hooks/useMovable/api-doc.zh-CN.md new file mode 100644 index 0000000..87f7378 --- /dev/null +++ b/.storybook/stories/hooks/useMovable/api-doc.zh-CN.md @@ -0,0 +1,21 @@ +## API + +### 参数 —— UseMovableProps + +| 参数 | 说明 | 类型 | 默认值 | +| ----------------- | ------------------------------------------------------ | ------------------------ | ------ | +| `enabled` | 是否启用拖动 | `boolean` | `true` | +| `containerRef` | 容器元素的 ref | `RefObject<HTMLElement>` | - | +| `ignoreSelectors` | 不应触发拖动的元素选择器(如交互控件) | `string[]` | - | +| `storageKey` | 用于在 `localStorage` 中存储位置的 key;省略则不持久化 | `string` | - | + +### 返回值 + +| 成员 | 说明 | 签名 | +| --------------- | -------------------------------- | ------------------------------------------------- | +| `onPointerDown` | 按下事件处理器;绑定到可拖拽元素 | `(e: React.PointerEvent<HTMLDivElement>) => void` | +| `position` | 当前位置 `{ left, top }`(像素) | `MovePosition \| undefined` | + +### 类型 + +`MovePosition = { left: number; top: number }` diff --git a/.storybook/stories/hooks/useMovable/index.stories.tsx b/.storybook/stories/hooks/useMovable/index.stories.tsx new file mode 100644 index 0000000..da55d53 --- /dev/null +++ b/.storybook/stories/hooks/useMovable/index.stories.tsx @@ -0,0 +1,135 @@ +import { useRef, useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { Alert, Card, Space, Switch, Typography } from 'antd'; +import useMovable from '../../../../src/hooks/useMovable'; +import storyI18n, { storyT, useStoryT } from '../../../locales'; +import apiDocEN from './api-doc.en-US.md?raw'; +import apiDocCN from './api-doc.zh-CN.md?raw'; +import introduceEN from './introduce.en-US.md?raw'; +import introduceCN from './introduce.zh-CN.md?raw'; + +interface UseMovableStoryArgs { + enabled: boolean; + persist: boolean; +} + +const meta: Meta<UseMovableStoryArgs> = { + title: 'Hooks/useMovable', + parameters: { + docs: { + description: { + component: storyI18n.language === 'zh-CN' ? `${introduceCN}\n${apiDocCN}` : `${introduceEN}\n${apiDocEN}`, + }, + }, + }, + args: { + enabled: true, + persist: true, + }, + argTypes: { + enabled: { + control: 'boolean', + description: storyT('storybook.stories.useMovable.argTypes.enabled.description'), + }, + persist: { + control: 'boolean', + description: storyT('storybook.stories.useMovable.argTypes.persist.description'), + }, + }, +}; + +export default meta; +type Story = StoryObj<UseMovableStoryArgs>; + +export const Playground: Story = { + parameters: { + docs: { + description: { + story: + '- **EN:** Drag the blue card around the container. Toggle `enabled` to turn dragging on/off and `persist` to store the position in `localStorage`.\\n- **CN:** 拖动蓝色卡片在容器内移动。切换 `enabled` 开关控制拖动,切换 `persist` 将位置持久化到 `localStorage`。', + }, + }, + }, + render: function Render(args: UseMovableStoryArgs) { + return <UseMovableStoryDemo {...args} />; + }, +}; + +function UseMovableStoryDemo({ enabled, persist }: UseMovableStoryArgs) { + const t = useStoryT(); + const containerRef = useRef<HTMLDivElement>(null); + const [position, setPosition] = useState<{ left: number; top: number } | undefined>(undefined); + + const { onPointerDown, position: hookPosition } = useMovable({ + enabled, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + containerRef: containerRef as any, + storageKey: persist ? 'storybook.useMovable.position' : undefined, + ignoreSelectors: ['.ant-btn'], + }); + + const effectivePosition = hookPosition ?? position ?? { left: 0, top: 0 }; + void setPosition; + + return ( + <Card variant="outlined" style={{ maxWidth: 920 }} title={t('storybook.stories.useMovable.cardTitle')}> + <Space orientation="vertical" size="large" style={{ width: '100%' }}> + <Typography.Paragraph style={{ marginBottom: 0 }}> + {t('storybook.stories.useMovable.description')} + </Typography.Paragraph> + + <Space wrap> + <Typography.Text strong>{t('storybook.stories.useMovable.enabledLabel')}</Typography.Text> + <Switch checked={enabled} disabled /> + <Typography.Text strong>{t('storybook.stories.useMovable.persistLabel')}</Typography.Text> + <Switch checked={persist} disabled /> + </Space> + + <div + ref={containerRef} + style={{ + position: 'relative', + height: 260, + border: '1px dashed #d9d9d9', + borderRadius: 8, + overflow: 'hidden', + background: '#fafafa', + }} + > + <div + onPointerDown={onPointerDown} + style={{ + position: 'absolute', + left: effectivePosition.left || 16, + top: effectivePosition.top || 16, + width: 180, + padding: '16px 20px', + borderRadius: 8, + background: '#1677ff', + color: '#fff', + cursor: enabled ? 'grab' : 'not-allowed', + touchAction: 'none', + userSelect: 'none', + }} + > + <Typography.Text strong style={{ color: '#fff' }}> + {t('storybook.stories.useMovable.handle')} + </Typography.Text> + <Typography.Text style={{ color: '#fff', display: 'block', fontSize: 12 }}> + {t('storybook.stories.useMovable.dragHint')} + </Typography.Text> + </div> + </div> + + <Space wrap> + <Typography.Text strong>{t('storybook.stories.useMovable.positionLabel')}</Typography.Text> + <Typography.Text code> + {`left: ${effectivePosition.left || 16}, top: ${effectivePosition.top || 16}`} + </Typography.Text> + </Space> + + <Alert type="info" message={t('storybook.stories.useMovable.tip')} showIcon /> + </Space> + </Card> + ); +} diff --git a/.storybook/stories/hooks/useMovable/introduce.en-US.md b/.storybook/stories/hooks/useMovable/introduce.en-US.md new file mode 100644 index 0000000..720f507 --- /dev/null +++ b/.storybook/stories/hooks/useMovable/introduce.en-US.md @@ -0,0 +1,20 @@ +Make an element **movable by dragging**, with optional position persistence in `localStorage`. The hook tracks the element's position during `pointermove`, clamps it to the visible area, and keeps it in bounds on window resize. + +## When to use + +- Draggable floating panels, widgets, or tooltips that users can reposition. +- Elements whose position should survive page reloads. +- Custom drag interactions where you need full control over pointer events. + +## Key features + +- **Pointer-based drag** — pointer capture keeps drags working even when the pointer leaves the element. +- **Viewport clamping** — positions are clamped to the visible area and re-clamped on resize. +- **Selective ignore** — `ignoreSelectors` prevents drags that start on interactive controls. +- **Optional persistence** — pass `storageKey` to save/restore the position via `localStorage`. + +## Usage notes + +- The hook does not render anything; spread `onPointerDown` on your draggable element and attach `containerRef` to the container. +- `enabled` toggles the global `pointermove`/`pointerup` listeners. +- Use `ignoreSelectors` for buttons/inputs inside the draggable area so clicks still work. diff --git a/.storybook/stories/hooks/useMovable/introduce.zh-CN.md b/.storybook/stories/hooks/useMovable/introduce.zh-CN.md new file mode 100644 index 0000000..4fc7437 --- /dev/null +++ b/.storybook/stories/hooks/useMovable/introduce.zh-CN.md @@ -0,0 +1,20 @@ +通过拖动使元素**可移动**,并可选地将位置持久化到 `localStorage`。hook 在 `pointermove` 期间跟踪元素位置,将其限制在可见区域内,并在窗口尺寸变化时保持不越界。 + +## 适用场景 + +- 用户可自由摆放的浮动面板、小组件或提示框。 +- 需要跨页面刷新保留位置的元素。 +- 需要完全掌控指针事件的自定义拖拽交互。 + +## 核心特性 + +- **基于指针的拖拽** —— 指针捕获确保指针离开元素后拖动依然有效。 +- **视口约束** —— 位置被限制在可见区域内,并在窗口缩放时重新校准。 +- **选择性忽略** —— `ignoreSelectors` 可排除交互控件上发起的拖动。 +- **可选持久化** —— 传入 `storageKey` 即可通过 `localStorage` 保存/恢复位置。 + +## 使用注意 + +- hook 不负责渲染;将 `onPointerDown` 绑定到可拖拽元素,并将 `containerRef` 绑定到容器。 +- `enabled` 控制全局 `pointermove` / `pointerup` 监听器的开关。 +- 对拖拽区域内的按钮/输入框使用 `ignoreSelectors`,保证点击仍然可用。 diff --git a/.storybook/stories/hooks/useProcessingText/api-doc.en-US.md b/.storybook/stories/hooks/useProcessingText/api-doc.en-US.md new file mode 100644 index 0000000..c7ad33b --- /dev/null +++ b/.storybook/stories/hooks/useProcessingText/api-doc.en-US.md @@ -0,0 +1,17 @@ +## API + +### Props — UseProcessingTextProps + +| Name | Description | Type | (Default) | +| ------------ | ---------------------------------- | --------- | --------- | +| `enabled` | Whether the animation is enabled | `boolean` | `true` | +| `prefixText` | Prefix text (e.g. "Processing") | `string` | `''` | +| `dotText` | Dot text (e.g. ".") | `string` | `'.'` | +| `interval` | Animation interval in milliseconds | `number` | `300` | +| `maxDots` | Maximum number of dots | `number` | `3` | + +### Return + +| Member | Description | Type | +| ------ | ------------------------------- | -------- | +| `text` | Animated processing text string | `string` | diff --git a/.storybook/stories/hooks/useProcessingText/api-doc.zh-CN.md b/.storybook/stories/hooks/useProcessingText/api-doc.zh-CN.md new file mode 100644 index 0000000..622c8da --- /dev/null +++ b/.storybook/stories/hooks/useProcessingText/api-doc.zh-CN.md @@ -0,0 +1,17 @@ +## API + +### 参数 —— UseProcessingTextProps + +| 参数 | 说明 | 类型 | 默认值 | +| ------------ | ------------------------- | --------- | ------ | +| `enabled` | 是否启用动画 | `boolean` | `true` | +| `prefixText` | 前缀文本(例如 "处理中") | `string` | `''` | +| `dotText` | 点文本(例如 ".") | `string` | `'.'` | +| `interval` | 动画间隔(毫秒) | `number` | `300` | +| `maxDots` | 最大点数 | `number` | `3` | + +### 返回值 + +| 成员 | 说明 | 类型 | +| ------ | ---------------- | -------- | +| `text` | 处理中的动画文本 | `string` | diff --git a/.storybook/stories/hooks/useProcessingText/index.stories.tsx b/.storybook/stories/hooks/useProcessingText/index.stories.tsx new file mode 100644 index 0000000..523f4c7 --- /dev/null +++ b/.storybook/stories/hooks/useProcessingText/index.stories.tsx @@ -0,0 +1,97 @@ +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { Alert, Card, Input, InputNumber, Space, Typography } from 'antd'; +import useProcessingText from '../../../../src/hooks/useProcessingText'; +import storyI18n, { storyT, useStoryT } from '../../../locales'; +import apiDocEN from './api-doc.en-US.md?raw'; +import apiDocCN from './api-doc.zh-CN.md?raw'; +import introduceEN from './introduce.en-US.md?raw'; +import introduceCN from './introduce.zh-CN.md?raw'; + +interface UseProcessingTextStoryArgs { + enabled: boolean; + maxDots: number; +} + +const meta: Meta<UseProcessingTextStoryArgs> = { + title: 'Hooks/useProcessingText', + parameters: { + docs: { + description: { + component: storyI18n.language === 'zh-CN' ? `${introduceCN}\n${apiDocCN}` : `${introduceEN}\n${apiDocEN}`, + }, + }, + }, + args: { + enabled: true, + maxDots: 3, + }, + argTypes: { + enabled: { + control: 'boolean', + description: storyT('storybook.stories.useProcessingText.argTypes.enabled.description'), + }, + maxDots: { + control: { type: 'range', min: 1, max: 6, step: 1 }, + description: storyT('storybook.stories.useProcessingText.argTypes.maxDots.description'), + }, + }, +}; + +export default meta; +type Story = StoryObj<UseProcessingTextStoryArgs>; + +export const Playground: Story = { + parameters: { + docs: { + description: { + story: + '- **EN:** Watch the animated "Processing..." text cycle its dots. Toggle `enabled` to freeze/resume the animation and adjust `maxDots` to control the dot count.\\n- **CN:** 观察"处理中..."动画文字的循环点。切换 `enabled` 冻结/恢复动画,调整 `maxDots` 控制点的数量。', + }, + }, + }, + render: function Render(args: UseProcessingTextStoryArgs) { + return <UseProcessingTextStoryDemo {...args} />; + }, +}; + +function UseProcessingTextStoryDemo({ enabled, maxDots }: UseProcessingTextStoryArgs) { + const t = useStoryT(); + const [prefixText, setPrefixText] = useState(''); + const [interval, setInterval] = useState(300); + + const text = useProcessingText({ + enabled, + prefixText, + interval, + maxDots, + }); + + return ( + <Card variant="outlined" style={{ maxWidth: 920 }} title={t('storybook.stories.useProcessingText.cardTitle')}> + <Space orientation="vertical" size="large" style={{ width: '100%' }}> + <Typography.Paragraph style={{ marginBottom: 0 }}> + {t('storybook.stories.useProcessingText.description')} + </Typography.Paragraph> + + <Space wrap> + <Typography.Text strong>{t('storybook.stories.useProcessingText.prefixLabel')}</Typography.Text> + <Input + style={{ maxWidth: 200 }} + placeholder={t('storybook.stories.useProcessingText.prefixPlaceholder')} + value={prefixText} + onChange={(e) => setPrefixText(e.target.value)} + /> + <Typography.Text strong>{t('storybook.stories.useProcessingText.intervalLabel')}</Typography.Text> + <InputNumber min={100} max={2000} step={50} value={interval} onChange={(v) => setInterval(v ?? 300)} /> + </Space> + + <Typography.Title level={3} style={{ margin: 0 }}> + {text} + </Typography.Title> + + <Alert type="info" message={t('storybook.stories.useProcessingText.tip')} showIcon /> + </Space> + </Card> + ); +} diff --git a/.storybook/stories/hooks/useProcessingText/introduce.en-US.md b/.storybook/stories/hooks/useProcessingText/introduce.en-US.md new file mode 100644 index 0000000..a61efe8 --- /dev/null +++ b/.storybook/stories/hooks/useProcessingText/introduce.en-US.md @@ -0,0 +1,20 @@ +Create an animated "processing" text that cycles through dots — e.g. `Processing.` → `Processing..` → `Processing...` — using a simple `setInterval`. Returns a plain string you can render anywhere. + +## When to use + +- Loading indicators with a textual "Processing" message. +- Placeholder status text while a task is running. +- Lightweight animation without adding a spinner component. + +## Key features + +- **Zero-dependency** — a plain string return value; no DOM or effect wiring needed. +- **Configurable rhythm** — `interval` controls speed, `maxDots` controls the dot count ceiling. +- **Custom text** — `prefixText` and `dotText` let you adapt the message to your language/UI. +- **Pausable** — `enabled: false` freezes the text at the prefix (no dots). + +## Usage notes + +- The animation starts when `enabled` is `true` (default). +- Changing `interval` or `maxDots` restarts the animation loop. +- The returned value is a plain string; compose it with other text as needed. diff --git a/.storybook/stories/hooks/useProcessingText/introduce.zh-CN.md b/.storybook/stories/hooks/useProcessingText/introduce.zh-CN.md new file mode 100644 index 0000000..23fbced --- /dev/null +++ b/.storybook/stories/hooks/useProcessingText/introduce.zh-CN.md @@ -0,0 +1,20 @@ +创建循环显示点的"处理中"动画文本——例如 `处理中.` → `处理中..` → `处理中...`——通过简单的 `setInterval` 实现。返回一个普通字符串,可随处渲染。 + +## 适用场景 + +- 带"处理中"文字的加载指示器。 +- 任务执行期间的占位状态文本。 +- 不需要引入 spinner 组件的轻量动画。 + +## 核心特性 + +- **零依赖** —— 返回普通字符串,无需处理 DOM 或副作用。 +- **节奏可配** —— `interval` 控制速度,`maxDots` 控制点的数量上限。 +- **文本可定制** —— `prefixText` 与 `dotText` 适配不同语言/界面。 +- **可暂停** —— `enabled: false` 时文本冻结为前缀(不带点)。 + +## 使用注意 + +- `enabled` 为 `true`(默认)时动画开始。 +- 修改 `interval` 或 `maxDots` 会重启动画循环。 +- 返回值是普通字符串,可按需与其他文本拼接。 diff --git a/.storybook/stories/hooks/useRefFunction/api-doc.en-US.md b/.storybook/stories/hooks/useRefFunction/api-doc.en-US.md new file mode 100644 index 0000000..2be2d3c --- /dev/null +++ b/.storybook/stories/hooks/useRefFunction/api-doc.en-US.md @@ -0,0 +1,15 @@ +## API + +### Params + +| Name | Description | Type | (Default) | +| ---- | ------------------------------- | ---------------- | --------- | +| `fn` | Function body; must be provided | `T \| undefined` | - | + +### Return + +| Member | Description | Type | +| ------ | --------------------------------------------------------------------- | ---- | +| `fn` | A function with immutable reference; calls the latest `fn` internally | `T` | + +> `T extends (...args: any[]) => any`. diff --git a/.storybook/stories/hooks/useRefFunction/api-doc.zh-CN.md b/.storybook/stories/hooks/useRefFunction/api-doc.zh-CN.md new file mode 100644 index 0000000..9a30507 --- /dev/null +++ b/.storybook/stories/hooks/useRefFunction/api-doc.zh-CN.md @@ -0,0 +1,15 @@ +## API + +### 参数 + +| 参数 | 说明 | 类型 | 默认值 | +| ---- | -------------- | ---------------- | ------ | +| `fn` | 函数体,需传入 | `T \| undefined` | - | + +### 返回值 + +| 成员 | 说明 | 类型 | +| ---- | ----------------------------------------- | ---- | +| `fn` | 引用不可变的函数;内部始终调用最新的 `fn` | `T` | + +> `T extends (...args: any[]) => any`。 diff --git a/.storybook/stories/hooks/useRefFunction/index.stories.tsx b/.storybook/stories/hooks/useRefFunction/index.stories.tsx new file mode 100644 index 0000000..82a029e --- /dev/null +++ b/.storybook/stories/hooks/useRefFunction/index.stories.tsx @@ -0,0 +1,106 @@ +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { Alert, Button, Card, Space, Tag, Typography } from 'antd'; +import useRefFunction from '../../../../src/hooks/useRefFunction'; +import storyI18n, { storyT, useStoryT } from '../../../locales'; +import apiDocEN from './api-doc.en-US.md?raw'; +import apiDocCN from './api-doc.zh-CN.md?raw'; +import introduceEN from './introduce.en-US.md?raw'; +import introduceCN from './introduce.zh-CN.md?raw'; + +interface UseRefFunctionStoryArgs { + showIdentity: boolean; +} + +const meta: Meta<UseRefFunctionStoryArgs> = { + title: 'Hooks/useRefFunction', + parameters: { + docs: { + description: { + component: storyI18n.language === 'zh-CN' ? `${introduceCN}\n${apiDocCN}` : `${introduceEN}\n${apiDocEN}`, + }, + }, + }, + args: { + showIdentity: true, + }, + argTypes: { + showIdentity: { + control: 'boolean', + description: storyT('storybook.stories.useRefFunction.argTypes.showIdentity.description'), + }, + }, +}; + +export default meta; +type Story = StoryObj<UseRefFunctionStoryArgs>; + +export const Playground: Story = { + parameters: { + docs: { + description: { + story: + '- **EN:** The returned function keeps an immutable reference across re-renders while always calling the latest closure. Click **Increment** to re-render and compare the stored identity.\\n- **CN:** 返回的函数在多次渲染间保持引用不变,同时始终调用最新的闭包。点击**递增**触发重新渲染,对比保存的函数引用。', + }, + }, + }, + render: function Render(args: UseRefFunctionStoryArgs) { + return <UseRefFunctionStoryDemo {...args} />; + }, +}; + +function UseRefFunctionStoryDemo({ showIdentity }: UseRefFunctionStoryArgs) { + const t = useStoryT(); + const [count, setCount] = useState(0); + const [firstRef, setFirstRef] = useState<(() => void) | null>(null); + const [identityChanged, setIdentityChanged] = useState(false); + + const stableLog = useRefFunction(() => { + // eslint-disable-next-line no-console + console.log(`count=${count}`); + return count; + }); + + const capture = () => { + if (!firstRef) { + setFirstRef(stableLog); + return; + } + setIdentityChanged(firstRef !== stableLog); + }; + + return ( + <Card variant="outlined" style={{ maxWidth: 920 }} title={t('storybook.stories.useRefFunction.cardTitle')}> + <Space orientation="vertical" size="large" style={{ width: '100%' }}> + <Typography.Paragraph style={{ marginBottom: 0 }}> + {t('storybook.stories.useRefFunction.description')} + </Typography.Paragraph> + + <Space wrap> + <Button type="primary" onClick={() => setCount((c) => c + 1)}> + {t('storybook.stories.useRefFunction.increment')} + </Button> + <Button onClick={() => stableLog()}>{t('storybook.stories.useRefFunction.invoke')}</Button> + <Button onClick={capture}>{t('storybook.stories.useRefFunction.capture')}</Button> + </Space> + + <Space wrap> + <Typography.Text strong>{t('storybook.stories.useRefFunction.countLabel')}</Typography.Text> + <Tag color="blue">{count}</Tag> + {showIdentity && ( + <> + <Typography.Text strong>{t('storybook.stories.useRefFunction.identityLabel')}</Typography.Text> + <Tag color={identityChanged ? 'red' : 'green'}> + {identityChanged + ? t('storybook.stories.useRefFunction.identityChanged') + : t('storybook.stories.useRefFunction.identityStable')} + </Tag> + </> + )} + </Space> + + <Alert type="info" message={t('storybook.stories.useRefFunction.tip')} showIcon /> + </Space> + </Card> + ); +} diff --git a/.storybook/stories/hooks/useRefFunction/introduce.en-US.md b/.storybook/stories/hooks/useRefFunction/introduce.en-US.md new file mode 100644 index 0000000..686a830 --- /dev/null +++ b/.storybook/stories/hooks/useRefFunction/introduce.en-US.md @@ -0,0 +1,19 @@ +Generate a function with an **immutable reference**. The function body keeps reading the latest closure through a ref, but the function itself never changes between renders — ideal for dependencies of `useEffect` or memoized callbacks. + +## When to use + +- Passing callbacks into `useEffect` dependency arrays without re-triggering effects on every render. +- Wrapping handlers that must remain referentially stable while still seeing fresh state. +- Building library APIs where the returned function's identity should be stable. + +## Key features + +- **Immutable identity** — the returned function reference never changes across re-renders. +- **Fresh closure** — internal ref keeps the latest `fn`, so state/props are always current when called. +- **Type-preserving** — generic signature returns a function typed as the input `T`. + +## Usage notes + +- The hook only wraps the _latest_ function; do not call it as a normal `useCallback` with a dependency list. +- `useRefValue` is the underlying primitive used to keep the latest closure. +- It is safe to pass the returned function to `useEffect` deps, memoized components, or event handlers. diff --git a/.storybook/stories/hooks/useRefFunction/introduce.zh-CN.md b/.storybook/stories/hooks/useRefFunction/introduce.zh-CN.md new file mode 100644 index 0000000..2d78983 --- /dev/null +++ b/.storybook/stories/hooks/useRefFunction/introduce.zh-CN.md @@ -0,0 +1,19 @@ +生成一个**引用不可变**的函数。函数体通过 ref 始终保持读取最新的闭包,但函数本身在渲染间不会变化——非常适合作为 `useEffect` 的依赖或记忆化回调。 + +## 适用场景 + +- 将回调放入 `useEffect` 依赖数组,避免每次渲染都重新触发副作用。 +- 包装需要保持引用稳定、同时又需要读取最新状态的事件处理器。 +- 构建库 API 时,需要返回引用稳定的函数。 + +## 核心特性 + +- **引用不可变** —— 返回的函数引用在多次渲染间保持不变。 +- **闭包最新** —— 内部 ref 始终持有最新的 `fn`,调用时读取到最新的 state/props。 +- **类型保留** —— 泛型签名返回与输入 `T` 同类型的函数。 + +## 使用注意 + +- hook 只包装*最新*函数;不要像 `useCallback` 那样传入依赖列表使用。 +- 底层依赖 `useRefValue` 保持最新闭包。 +- 返回的函数可安全用于 `useEffect` 依赖、记忆化组件或事件处理。 diff --git a/.storybook/stories/hooks/useRefValue/api-doc.en-US.md b/.storybook/stories/hooks/useRefValue/api-doc.en-US.md new file mode 100644 index 0000000..b82e7ec --- /dev/null +++ b/.storybook/stories/hooks/useRefValue/api-doc.en-US.md @@ -0,0 +1,13 @@ +## API + +### Params + +| Name | Description | Type | (Default) | +| ------- | ----------------- | ---- | --------- | +| `value` | The wrapped value | `T` | - | + +### Return + +| Member | Description | Type | +| ------ | ---------------------------------------------------- | --------------------- | +| `ref` | A mutable ref object, but the reference is immutable | `MutableRefObject<T>` | diff --git a/.storybook/stories/hooks/useRefValue/api-doc.zh-CN.md b/.storybook/stories/hooks/useRefValue/api-doc.zh-CN.md new file mode 100644 index 0000000..7aa3fde --- /dev/null +++ b/.storybook/stories/hooks/useRefValue/api-doc.zh-CN.md @@ -0,0 +1,13 @@ +## API + +### 参数 + +| 参数 | 说明 | 类型 | 默认值 | +| ------- | ---------- | ---- | ------ | +| `value` | 被包装的值 | `T` | - | + +### 返回值 + +| 成员 | 说明 | 类型 | +| ----- | ----------------------------- | --------------------- | +| `ref` | 可变的 ref 对象,但引用不可变 | `MutableRefObject<T>` | diff --git a/.storybook/stories/hooks/useRefValue/index.stories.tsx b/.storybook/stories/hooks/useRefValue/index.stories.tsx new file mode 100644 index 0000000..a6e76a0 --- /dev/null +++ b/.storybook/stories/hooks/useRefValue/index.stories.tsx @@ -0,0 +1,98 @@ +import { useEffect, useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { Alert, Button, Card, Space, Tag, Typography } from 'antd'; +import useRefValue from '../../../../src/hooks/useRefValue'; +import storyI18n, { storyT, useStoryT } from '../../../locales'; +import apiDocEN from './api-doc.en-US.md?raw'; +import apiDocCN from './api-doc.zh-CN.md?raw'; +import introduceEN from './introduce.en-US.md?raw'; +import introduceCN from './introduce.zh-CN.md?raw'; + +interface UseRefValueStoryArgs { + showSync: boolean; +} + +const meta: Meta<UseRefValueStoryArgs> = { + title: 'Hooks/useRefValue', + parameters: { + docs: { + description: { + component: storyI18n.language === 'zh-CN' ? `${introduceCN}\n${apiDocCN}` : `${introduceEN}\n${apiDocEN}`, + }, + }, + }, + args: { + showSync: true, + }, + argTypes: { + showSync: { + control: 'boolean', + description: storyT('storybook.stories.useRefValue.argTypes.showSync.description'), + }, + }, +}; + +export default meta; +type Story = StoryObj<UseRefValueStoryArgs>; + +export const Playground: Story = { + parameters: { + docs: { + description: { + story: + '- **EN:** `useRefValue` returns a mutable ref whose `.current` always matches the latest state value. Click **Increment** and watch the ref stay in sync with the counter.\\n- **CN:** `useRefValue` 返回一个可变 ref,其 `.current` 始终与最新状态保持一致。点击**递增**,观察 ref 与计数器实时同步。', + }, + }, + }, + render: function Render(args: UseRefValueStoryArgs) { + return <UseRefValueStoryDemo {...args} />; + }, +}; + +function UseRefValueStoryDemo({ showSync }: UseRefValueStoryArgs) { + const t = useStoryT(); + const [count, setCount] = useState(0); + const [captured, setCaptured] = useState(0); + const countRef = useRefValue(count); + + // A one-time effect that reads the ref, proving the ref stays fresh without re-running. + useEffect(() => { + const timer = window.setInterval(() => { + setCaptured(countRef.current); + }, 500); + return () => window.clearInterval(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + <Card variant="outlined" style={{ maxWidth: 920 }} title={t('storybook.stories.useRefValue.cardTitle')}> + <Space orientation="vertical" size="large" style={{ width: '100%' }}> + <Typography.Paragraph style={{ marginBottom: 0 }}> + {t('storybook.stories.useRefValue.description')} + </Typography.Paragraph> + + <Space wrap> + <Button type="primary" onClick={() => setCount((c) => c + 1)}> + {t('storybook.stories.useRefValue.increment')} + </Button> + <Button onClick={() => setCaptured(countRef.current)}>{t('storybook.stories.useRefValue.capture')}</Button> + </Space> + + <Space wrap> + <Typography.Text strong>{t('storybook.stories.useRefValue.stateLabel')}</Typography.Text> + <Tag color="blue">{count}</Tag> + <Typography.Text strong>{t('storybook.stories.useRefValue.refLabel')}</Typography.Text> + <Tag color="green">{countRef.current}</Tag> + {showSync && ( + <> + <Typography.Text strong>{t('storybook.stories.useRefValue.capturedLabel')}</Typography.Text> + <Tag color={captured === count ? 'green' : 'orange'}>{captured}</Tag> + </> + )} + </Space> + + <Alert type="info" message={t('storybook.stories.useRefValue.tip')} showIcon /> + </Space> + </Card> + ); +} diff --git a/.storybook/stories/hooks/useRefValue/introduce.en-US.md b/.storybook/stories/hooks/useRefValue/introduce.en-US.md new file mode 100644 index 0000000..5c6fbbd --- /dev/null +++ b/.storybook/stories/hooks/useRefValue/introduce.en-US.md @@ -0,0 +1,19 @@ +Get a **mutable ref object** that automatically stays in sync with the latest value. Unlike `useRef(value)` which keeps the initial value, `useRefValue` overwrites `ref.current` on every render so the ref always reflects current state. + +## When to use + +- Reading the latest state/props inside `setTimeout`, `setInterval`, or event listeners without re-subscribing. +- Passing a stable ref to child components while keeping its `.current` fresh. +- Implementing stable-callback patterns such as `useRefFunction`. + +## Key features + +- **Auto-sync** — `ref.current` is updated to the latest value on every render. +- **Stable reference** — the ref object itself never changes, safe for dependencies. +- **Type-safe** — generic signature preserves the wrapped value type. + +## Usage notes + +- Mutating `ref.current` does **not** trigger a re-render. +- It is the building block for `useRefFunction` in this library. +- Use it to read fresh values inside long-lived callbacks without including them in dependency arrays. diff --git a/.storybook/stories/hooks/useRefValue/introduce.zh-CN.md b/.storybook/stories/hooks/useRefValue/introduce.zh-CN.md new file mode 100644 index 0000000..800ff8f --- /dev/null +++ b/.storybook/stories/hooks/useRefValue/introduce.zh-CN.md @@ -0,0 +1,19 @@ +获取一个**可变的 ref 对象**,并自动与最新值保持同步。与只保留初始值的 `useRef(value)` 不同,`useRefValue` 会在每次渲染时覆盖 `ref.current`,使 ref 始终反映当前状态。 + +## 适用场景 + +- 在 `setTimeout`、`setInterval` 或事件监听中读取最新 state/props,而无需重新订阅。 +- 向子组件传递稳定的 ref,同时保持 `.current` 始终最新。 +- 实现 `useRefFunction` 等稳定回调模式。 + +## 核心特性 + +- **自动同步** —— 每次渲染都会将 `ref.current` 更新为最新值。 +- **引用稳定** —— ref 对象本身永不变化,可安全用于依赖数组。 +- **类型安全** —— 泛型签名保留包装值的类型。 + +## 使用注意 + +- 修改 `ref.current` **不会**触发重新渲染。 +- 它是本库 `useRefFunction` 的底层实现。 +- 适用于在长期存活的回调中读取最新值,而无需将其加入依赖数组。 diff --git a/.storybook/stories/hooks/useRowSelection/api-doc.en-US.md b/.storybook/stories/hooks/useRowSelection/api-doc.en-US.md new file mode 100644 index 0000000..a605f65 --- /dev/null +++ b/.storybook/stories/hooks/useRowSelection/api-doc.en-US.md @@ -0,0 +1,21 @@ +## API + +### Options — UseRowSelectionOption\<T\> + +Extends `Omit<TableRowSelection<T>, 'preserveSelectedRowKeys' | 'selectedRowKeys' | 'onChange'>`. + +| Name | Description | Type | (Default) | +| ----------- | -------------------------------------------------------------------------------------- | ---------------------------------- | --------- | +| `value` | The selected row objects | `T[]` | - | +| `onChange` | Callback triggered when the selected rows change; receives row objects | `(value: T[]) => void` | - | +| `rowKey` | Field name or function to get the object key; falls back to `id` or `code` | `keyof T \| ((item: T) => string)` | - | +| `checkable` | Whether table selection is enabled; `false` disables the feature | `boolean` | `true` | +| `cache` | Cache of all selected objects, used to initialize the internal cache (for persistence) | `T[]` | - | + +> Other `TableRowSelection` options (e.g. `type`, `columnWidth`, `getCheckboxProps`) pass through. + +### Return + +| Member | Description | Signature | +| ------------ | --------------------------------------------------------------- | ----------------------------------- | +| rowSelection | The rowSelection prop for `Table`, or `undefined` when disabled | `TableRowSelection<T> \| undefined` | diff --git a/.storybook/stories/hooks/useRowSelection/api-doc.zh-CN.md b/.storybook/stories/hooks/useRowSelection/api-doc.zh-CN.md new file mode 100644 index 0000000..12d3aa8 --- /dev/null +++ b/.storybook/stories/hooks/useRowSelection/api-doc.zh-CN.md @@ -0,0 +1,21 @@ +## API + +### 参数 —— UseRowSelectionOption\<T\> + +继承 `Omit<TableRowSelection<T>, 'preserveSelectedRowKeys' | 'selectedRowKeys' | 'onChange'>`。 + +| 参数 | 说明 | 类型 | 默认值 | +| ----------- | ------------------------------------------------------------ | ---------------------------------- | ------ | +| `value` | 选中的行对象 | `T[]` | - | +| `onChange` | 选中行变化时触发的回调,参数为行对象 | `(value: T[]) => void` | - | +| `rowKey` | 获取对象 key 的字段名或函数;不设置时默认使用 `id` 或 `code` | `keyof T \| ((item: T) => string)` | - | +| `checkable` | 是否支持表格选择;为 `false` 时禁用行选择功能 | `boolean` | `true` | +| `cache` | 所有被选中对象的缓存,用于初始化内部缓存(持久化场景) | `T[]` | - | + +> 其余 `TableRowSelection` 选项(如 `type`、`columnWidth`、`getCheckboxProps`)原样透传。 + +### 返回值 + +| 成员 | 说明 | 签名 | +| -------------- | --------------------------------------------------------- | ----------------------------------- | +| `rowSelection` | 传给 `Table` 的 rowSelection 配置;禁用时返回 `undefined` | `TableRowSelection<T> \| undefined` | diff --git a/.storybook/stories/hooks/useRowSelection/index.stories.tsx b/.storybook/stories/hooks/useRowSelection/index.stories.tsx new file mode 100644 index 0000000..0695942 --- /dev/null +++ b/.storybook/stories/hooks/useRowSelection/index.stories.tsx @@ -0,0 +1,147 @@ +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { Button, Card, List, Space, Table, Tag, Typography } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import useRowSelection from '../../../../src/hooks/useRowSelection'; +import storyI18n, { storyT, useStoryT } from '../../../locales'; +import apiDocEN from './api-doc.en-US.md?raw'; +import apiDocCN from './api-doc.zh-CN.md?raw'; +import introduceEN from './introduce.en-US.md?raw'; +import introduceCN from './introduce.zh-CN.md?raw'; + +interface UseRowSelectionStoryArgs { + checkable: boolean; +} + +interface UserRow { + id: number; + name: string; + role: string; +} + +const meta: Meta<UseRowSelectionStoryArgs> = { + title: 'Hooks/useRowSelection', + parameters: { + docs: { + description: { + component: storyI18n.language === 'zh-CN' ? `${introduceCN}\n${apiDocCN}` : `${introduceEN}\n${apiDocEN}`, + }, + }, + }, + args: { + checkable: true, + }, + argTypes: { + checkable: { + control: 'boolean', + description: storyT('storybook.stories.useRowSelection.argTypes.checkable.description'), + }, + }, +}; + +export default meta; +type Story = StoryObj<UseRowSelectionStoryArgs>; + +export const Playground: Story = { + parameters: { + docs: { + description: { + story: + '- **EN:** Toggle selection availability and select rows to see how the hook exposes a `rowSelection` object that returns the selected row objects (not just keys) through its `onChange` callback.\n- **CN:** 切换选中可用开关并选择行,观察 hook 如何通过 `onChange` 返回选中的行对象(而不是仅返回行 key)。', + }, + }, + }, + render: function Render(args: UseRowSelectionStoryArgs) { + return <UseRowSelectionStoryDemo {...args} />; + }, +}; + +function UseRowSelectionStoryDemo({ checkable }: UseRowSelectionStoryArgs) { + const t = useStoryT(); + const [selectedRows, setSelectedRows] = useState<UserRow[]>([]); + + const rowSelection = useRowSelection<UserRow>({ + value: selectedRows, + onChange: setSelectedRows, + checkable, + rowKey: 'id', + }); + + const columns: ColumnsType<UserRow> = [ + { + title: t('storybook.stories.useRowSelection.columns.name'), + dataIndex: 'name', + }, + { + title: t('storybook.stories.useRowSelection.columns.role'), + dataIndex: 'role', + render: (role: string) => <Tag>{role}</Tag>, + }, + ]; + + const data: UserRow[] = [ + { + id: 1, + name: t('storybook.stories.useRowSelection.data.alice'), + role: t('storybook.stories.useRowSelection.roles.admin'), + }, + { + id: 2, + name: t('storybook.stories.useRowSelection.data.bob'), + role: t('storybook.stories.useRowSelection.roles.editor'), + }, + { + id: 3, + name: t('storybook.stories.useRowSelection.data.cindy'), + role: t('storybook.stories.useRowSelection.roles.viewer'), + }, + ]; + + return ( + <Card variant="outlined" style={{ maxWidth: 920 }} title={t('storybook.stories.useRowSelection.cardTitle')}> + <Space orientation="vertical" size="large" style={{ width: '100%' }}> + <Typography.Paragraph style={{ marginBottom: 0 }}> + {t('storybook.stories.useRowSelection.description')} + </Typography.Paragraph> + + <Table<UserRow> + rowKey="id" + columns={columns} + dataSource={data} + rowSelection={rowSelection} + pagination={false} + /> + + <Space align="center" wrap style={{ width: '100%', justifyContent: 'space-between' }}> + <Typography.Text strong> + {t('storybook.stories.useRowSelection.selectedCount', { count: selectedRows.length })} + </Typography.Text> + <Button + onClick={() => { + setSelectedRows([]); + }} + disabled={selectedRows.length === 0} + > + {t('storybook.stories.useRowSelection.clear')} + </Button> + </Space> + + <List + size="small" + bordered + dataSource={selectedRows} + locale={{ emptyText: t('storybook.stories.useRowSelection.emptySelection') }} + renderItem={(item) => ( + <List.Item key={item.id}> + <Space wrap> + <Typography.Text code>{item.id}</Typography.Text> + <Typography.Text>{item.name}</Typography.Text> + <Tag>{item.role}</Tag> + </Space> + </List.Item> + )} + /> + </Space> + </Card> + ); +} diff --git a/.storybook/stories/hooks/useRowSelection/introduce.en-US.md b/.storybook/stories/hooks/useRowSelection/introduce.en-US.md new file mode 100644 index 0000000..532d9e1 --- /dev/null +++ b/.storybook/stories/hooks/useRowSelection/introduce.en-US.md @@ -0,0 +1,20 @@ +Generate the `rowSelection` prop for Ant Design Table with **cross-page selection support**. Unlike the built-in `TableRowSelection`, the `onChange` callback returns the selected **row objects** (not just row keys), and selected rows are cached internally so selections survive page changes. + +## When to use + +- Tables with pagination where selections must persist across pages. +- When you need the selected row objects (not only keys) in callbacks. +- When selection state lives outside the Table (controlled `value`). + +## Key features + +- **Cross-page selection** — an internal cache keeps all selected row objects, so switching pages does not lose earlier selections. +- **Row-object callbacks** — `onChange(value)` receives `T[]` row objects instead of key arrays. +- **Automatic row key** — falls back to the `id` or `code` field when `rowKey` is not provided. +- **Restorable cache** — pass `cache` to rehydrate selections when the Table remounts (e.g. from `localStorage` or a MicroApp host). + +## Usage notes + +- Pass the returned object directly to `Table`'s `rowSelection` prop; it returns `undefined` when `checkable` is `false`. +- The hook only manages selection; the Table itself must still render the checkbox column. +- `value` is the source of truth: keep it in `useState` and pass it back in for a controlled selection. diff --git a/.storybook/stories/hooks/useRowSelection/introduce.zh-CN.md b/.storybook/stories/hooks/useRowSelection/introduce.zh-CN.md new file mode 100644 index 0000000..e81c825 --- /dev/null +++ b/.storybook/stories/hooks/useRowSelection/introduce.zh-CN.md @@ -0,0 +1,20 @@ +为 Ant Design Table 生成 `rowSelection` 配置,**支持跨页选中**。与内置 `TableRowSelection` 不同,`onChange` 回调返回的是选中的**行对象**(而非行 key),且内部会缓存已选中的行对象,翻页后选择不丢失。 + +## 适用场景 + +- 分页表格需要跨页保留选中项。 +- 回调中需要拿到选中的行对象(而不只是 key)。 +- 选中状态放在 Table 外部管理(受控 `value`)。 + +## 核心特性 + +- **跨页选中** —— 内部缓存保留所有已选中的行对象,切换页码不会丢失之前的选中。 +- **行对象回调** —— `onChange(value)` 接收 `T[]` 行对象数组,而非 key 数组。 +- **自动行 key** —— 未提供 `rowKey` 时自动使用 `id` 或 `code` 字段。 +- **可恢复缓存** —— 传入 `cache` 可在 Table 重新挂载时恢复选中(例如从 `localStorage` 或微前端主应用读取)。 + +## 使用注意 + +- 将返回值直接传给 Table 的 `rowSelection`;当 `checkable` 为 `false` 时返回 `undefined`。 +- hook 只负责选中状态,Table 自身仍需渲染复选框列。 +- `value` 是唯一数据源:放在 `useState` 中并传回,实现受控选中。 diff --git a/.storybook/stories/hooks/useSSE/api-doc.en-US.md b/.storybook/stories/hooks/useSSE/api-doc.en-US.md new file mode 100644 index 0000000..8d5b900 --- /dev/null +++ b/.storybook/stories/hooks/useSSE/api-doc.en-US.md @@ -0,0 +1,23 @@ +## API + +### Props — UseSSEProps\<T\> + +| Name | Description | Type | (Default) | +| ---------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------- | --------- | +| `url` | The URL to connect to | `RequestInfo` | - | +| `connectOptions` | Options for the connection (except `onmessage` / `onerror` / `onclose`) | `Omit<FetchEventSourceInit, 'onmessage' \| 'onerror' \| 'onclose'>` | - | +| `autoConnect` | Automatically connect on mount | `boolean` | `false` | +| `autoClose` | Automatically close the connection when the component unmounts | `boolean` | `true` | +| `parseMessage` | Custom parser for incoming messages; defaults to `JSON.parse` | `(original: EventSourceMessage) => T` | - | +| `onMessage` | Callback for each incoming message | `(messageData: T) => void` | - | +| `onError` | Callback for errors | `(error: any) => void` | - | +| `onClose` | Callback when the connection closes | `() => void` | - | + +### Return + +| Member | Description | Signature | +| -------------- | ----------------------------------------------------------------- | --------------------------------------------------------------------------------------- | +| `connect` | Open the SSE stream; per-call options merge with `connectOptions` | `(options?: FetchEventSourceInit & Partial<Pick<UseSSEProps, 'url'>>) => Promise<void>` | +| `abort` | Abort the current connection | `() => void` | +| `isRequesting` | Whether a connection attempt is in progress | `boolean` | +| `isConnected` | Whether the connection is currently open | `boolean` | diff --git a/.storybook/stories/hooks/useSSE/api-doc.zh-CN.md b/.storybook/stories/hooks/useSSE/api-doc.zh-CN.md new file mode 100644 index 0000000..e33bffb --- /dev/null +++ b/.storybook/stories/hooks/useSSE/api-doc.zh-CN.md @@ -0,0 +1,23 @@ +## API + +### 参数 —— UseSSEProps\<T\> + +| 参数 | 说明 | 类型 | 默认值 | +| ---------------- | ---------------------------------------------------- | ------------------------------------------------------------------- | ------- | +| `url` | 要连接的地址 | `RequestInfo` | - | +| `connectOptions` | 连接选项(不含 `onmessage` / `onerror` / `onclose`) | `Omit<FetchEventSourceInit, 'onmessage' \| 'onerror' \| 'onclose'>` | - | +| `autoConnect` | 挂载时自动连接 | `boolean` | `false` | +| `autoClose` | 组件卸载时自动关闭连接 | `boolean` | `true` | +| `parseMessage` | 自定义消息解析函数;默认使用 `JSON.parse` | `(original: EventSourceMessage) => T` | - | +| `onMessage` | 每条消息到达时的回调 | `(messageData: T) => void` | - | +| `onError` | 错误回调 | `(error: any) => void` | - | +| `onClose` | 连接关闭回调 | `() => void` | - | + +### 返回值 + +| 成员 | 说明 | 签名 | +| -------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------- | +| `connect` | 打开 SSE 流;调用时的选项与 `connectOptions` 合并 | `(options?: FetchEventSourceInit & Partial<Pick<UseSSEProps, 'url'>>) => Promise<void>` | +| `abort` | 中断当前连接 | `() => void` | +| `isRequesting` | 是否正在建立连接 | `boolean` | +| `isConnected` | 连接是否已建立 | `boolean` | diff --git a/.storybook/stories/hooks/useSSE/index.stories.tsx b/.storybook/stories/hooks/useSSE/index.stories.tsx new file mode 100644 index 0000000..6757387 --- /dev/null +++ b/.storybook/stories/hooks/useSSE/index.stories.tsx @@ -0,0 +1,144 @@ +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { Alert, Button, Card, Input, List, Space, Tag, Typography } from 'antd'; +import useSSE from '../../../../src/hooks/useSSE'; +import storyI18n, { storyT, useStoryT } from '../../../locales'; +import apiDocEN from './api-doc.en-US.md?raw'; +import apiDocCN from './api-doc.zh-CN.md?raw'; +import introduceEN from './introduce.en-US.md?raw'; +import introduceCN from './introduce.zh-CN.md?raw'; + +interface UseSSEStoryArgs { + url: string; +} + +interface SseLog { + id: number; + text: string; +} + +const meta: Meta<UseSSEStoryArgs> = { + title: 'Hooks/useSSE', + parameters: { + docs: { + description: { + component: storyI18n.language === 'zh-CN' ? `${introduceCN}\n${apiDocCN}` : `${introduceEN}\n${apiDocEN}`, + }, + }, + }, + args: { + url: '', + }, + argTypes: { + url: { + control: 'text', + description: storyT('storybook.stories.useSSE.argTypes.url.description'), + }, + }, +}; + +export default meta; +type Story = StoryObj<UseSSEStoryArgs>; + +export const Playground: Story = { + parameters: { + docs: { + description: { + story: + '- **EN:** Enter the URL of an SSE endpoint and press **Connect**. Observe the `isRequesting` / `isConnected` indicators flip as the connection is established and streams messages into the log below. No connection is opened until you press the button, and no backend is required to render this demo.\n- **CN:** 输入 SSE 服务端地址并点击**连接**。观察 `isRequesting` / `isConnected` 指示器在建立连接并接收消息时的变化(消息会写入下方日志)。未点击按钮前不会发起连接,本示例无需真实后端即可渲染。', + }, + }, + }, + render: function Render(args: UseSSEStoryArgs) { + return <UseSSEStoryDemo {...args} />; + }, +}; + +function UseSSEStoryDemo({ url }: UseSSEStoryArgs) { + const t = useStoryT(); + const [messages, setMessages] = useState<SseLog[]>([]); + const [draftUrl, setDraftUrl] = useState(url); + + const appendLog = (text: string) => { + setMessages((prev) => [{ id: Date.now() + prev.length, text }, ...prev].slice(0, 12)); + }; + + const { connect, abort, isRequesting, isConnected } = useSSE<unknown>({ + url: draftUrl, + onMessage: (data) => { + appendLog(typeof data === 'string' ? data : data === null ? String(data) : JSON.stringify(data)); + }, + onError: (error) => { + appendLog(`${t('storybook.stories.useSSE.errorPrefix')} ${String(error)}`); + }, + onClose: () => { + appendLog(t('storybook.stories.useSSE.closed')); + }, + }); + + const handleConnect = async () => { + appendLog(t('storybook.stories.useSSE.connecting')); + await connect(); + }; + + const handleAbort = () => { + abort(); + appendLog(t('storybook.stories.useSSE.aborted')); + }; + + const statusTag = isConnected ? ( + <Tag color="green">{t('storybook.stories.useSSE.status.connected')}</Tag> + ) : isRequesting ? ( + <Tag color="processing">{t('storybook.stories.useSSE.status.requesting')}</Tag> + ) : ( + <Tag>{t('storybook.stories.useSSE.status.idle')}</Tag> + ); + + return ( + <Card variant="outlined" style={{ maxWidth: 920 }} title={t('storybook.stories.useSSE.cardTitle')}> + <Space orientation="vertical" size="large" style={{ width: '100%' }}> + <Typography.Paragraph style={{ marginBottom: 0 }}> + {t('storybook.stories.useSSE.description')} + </Typography.Paragraph> + + <Alert type="info" message={t('storybook.stories.useSSE.serverHint')} showIcon /> + + <Space wrap style={{ width: '100%' }}> + <Input + style={{ maxWidth: 460 }} + placeholder={t('storybook.stories.useSSE.urlPlaceholder')} + value={draftUrl} + onChange={(event) => setDraftUrl(event.target.value)} + onPressEnter={handleConnect} + /> + <Button type="primary" disabled={!draftUrl.trim()} onClick={handleConnect}> + {t('storybook.stories.useSSE.connect')} + </Button> + <Button disabled={!isConnected && !isRequesting} onClick={handleAbort}> + {t('storybook.stories.useSSE.abort')} + </Button> + <Space size="small"> + <Typography.Text strong>{t('storybook.stories.useSSE.connectedLabel')}</Typography.Text> + {statusTag} + </Space> + </Space> + + <Typography.Text type="secondary"> + {t('storybook.stories.useSSE.currentUrl', { url: draftUrl || '--' })} + </Typography.Text> + + <List + size="small" + bordered + dataSource={messages} + locale={{ emptyText: t('storybook.stories.useSSE.emptyMessages') }} + renderItem={(item) => ( + <List.Item key={item.id}> + <Typography.Text code>{item.text}</Typography.Text> + </List.Item> + )} + /> + </Space> + </Card> + ); +} diff --git a/.storybook/stories/hooks/useSSE/introduce.en-US.md b/.storybook/stories/hooks/useSSE/introduce.en-US.md new file mode 100644 index 0000000..5b743be --- /dev/null +++ b/.storybook/stories/hooks/useSSE/introduce.en-US.md @@ -0,0 +1,22 @@ +Establish a Server-Sent Events (SSE) connection using `@microsoft/fetch-event-source`. The hook manages the full lifecycle — connect, message parsing, error handling, and abort — and exposes `connect` / `abort` plus live `isRequesting` / `isConnected` state. Messages are parsed as JSON by default, or through a custom `parseMessage`. + +## When to use + +- Streaming responses from LLM / AI backends over SSE. +- Server push updates (notifications, progress, live scores) where WebSocket is overkill. +- One-way real-time channels where the server sends a stream of events. + +## Key features + +- **Lifecycle managed** — a single `connect()` call opens the stream; `abort()` closes it; `autoClose` disconnects on unmount. +- **Flexible parsing** — default JSON parsing, or provide `parseMessage` for custom payloads. +- **Composable options** — `connectOptions` merges with per-call `connect(options)` overrides. +- **Observable state** — `isRequesting` and `isConnected` flags for UI feedback. +- **Error-safe** — errors are routed to `onError` and connection failures do not throw into React. + +## Usage notes + +- The stream is only opened when `connect()` is called, or when `autoConnect` is `true`. +- `autoClose` defaults to `true`, so the connection is aborted automatically on unmount. +- `onmessage` / `onerror` / `onclose` passed inside `connectOptions` are ignored; use the hook's dedicated callbacks instead. +- The default `parseMessage` expects JSON (`JSON.parse(event.data)`); non-JSON events yield `undefined` and are skipped. diff --git a/.storybook/stories/hooks/useSSE/introduce.zh-CN.md b/.storybook/stories/hooks/useSSE/introduce.zh-CN.md new file mode 100644 index 0000000..1337960 --- /dev/null +++ b/.storybook/stories/hooks/useSSE/introduce.zh-CN.md @@ -0,0 +1,22 @@ +基于 `@microsoft/fetch-event-source` 建立 Server-Sent Events(SSE)连接。hook 管理完整生命周期——连接、消息解析、错误处理与中断——并暴露 `connect` / `abort` 以及实时的 `isRequesting` / `isConnected` 状态。消息默认按 JSON 解析,也可通过自定义 `parseMessage` 解析。 + +## 适用场景 + +- 对接 LLM / AI 后端的流式输出。 +- 服务端推送更新(通知、进度、实时比分等),无需 WebSocket 的重量级场景。 +- 服务端持续推送事件流的单向实时通道。 + +## 核心特性 + +- **生命周期托管** —— 一次 `connect()` 建立连接,`abort()` 关闭连接;`autoClose` 在卸载时自动断开。 +- **灵活解析** —— 默认 JSON 解析,也可提供 `parseMessage` 处理自定义消息体。 +- **可组合配置** —— `connectOptions` 与每次调用 `connect(options)` 的覆盖项合并。 +- **状态可观察** —— `isRequesting` 与 `isConnected` 标志位方便 UI 反馈。 +- **错误安全** —— 错误统一路由到 `onError`,连接失败不会向 React 抛异常。 + +## 使用注意 + +- 仅在调用 `connect()` 或 `autoConnect` 为 `true` 时才建立连接。 +- `autoClose` 默认为 `true`,组件卸载时自动中断连接。 +- `connectOptions` 中的 `onmessage` / `onerror` / `onclose` 会被忽略,请使用 hook 专属回调。 +- 默认 `parseMessage` 期望 JSON(`JSON.parse(event.data)`);非 JSON 事件得到 `undefined` 并被跳过。 diff --git a/.storybook/stories/hooks/useSplitter/api-doc.en-US.md b/.storybook/stories/hooks/useSplitter/api-doc.en-US.md new file mode 100644 index 0000000..8e78556 --- /dev/null +++ b/.storybook/stories/hooks/useSplitter/api-doc.en-US.md @@ -0,0 +1,28 @@ +## API + +### Props — UseSplitterProps + +| Name | Description | Type | (Default) | +| --------------- | ------------------------------------------------------------------- | -------------------------------------------------------- | ------------ | +| `direction` | Split direction; `vertical` = left/right, `horizontal` = top/bottom | `'vertical' \| 'horizontal'` | `'vertical'` | +| `container` | Parent container element; defaults to the `dom` element's parent | `HTMLDivElement \| null \| undefined` | - | +| `defaultRatio` | Default ratio of the left/top pane (0~1) | `number` | `0.32` | +| `minRatio` | Minimum ratio of the left/top pane (0~1) | `number` | `0.15` | +| `maxRatio` | Maximum ratio of the left/top pane (0~1) | `number` | `0.85` | +| `splitterWidth` | Width of the splitter in pixels | `number` | `1` | +| `className` | Additional class name for the splitter element | `string` | - | +| `style` | Additional style for the splitter element | `CSSProperties` | - | +| `prefixCls` | Custom CSS class prefix | `string` | - | +| `classNames` | Semantic class names (`hover` / `dragging` / `handle`) | `{ hover?: string; dragging?: string; handle?: string }` | - | +| `styles` | Semantic styles (`handle`) | `{ handle?: CSSProperties }` | - | +| `onChange` | Callback when the splitter ratio changes | `(ratio?: number) => void` | - | + +### Return + +| Member | Description | Signature | +| ----------- | ----------------------------------------------- | ---------------------------- | +| `dom` | The splitter element to place between the panes | `ReactElement` | +| `percent` | Current ratio of the left/top pane (0~1) | `number \| undefined` | +| `width` | Current pixel width of the left/top pane | `number \| undefined` | +| `dragging` | Whether the splitter is being dragged | `boolean` | +| `direction` | The active split direction | `'vertical' \| 'horizontal'` | diff --git a/.storybook/stories/hooks/useSplitter/api-doc.zh-CN.md b/.storybook/stories/hooks/useSplitter/api-doc.zh-CN.md new file mode 100644 index 0000000..6a19110 --- /dev/null +++ b/.storybook/stories/hooks/useSplitter/api-doc.zh-CN.md @@ -0,0 +1,28 @@ +## API + +### 参数 —— UseSplitterProps + +| 参数 | 说明 | 类型 | 默认值 | +| --------------- | ---------------------------------------------------- | -------------------------------------------------------- | ------------ | +| `direction` | 分割方向;`vertical` 表示左右,`horizontal` 表示上下 | `'vertical' \| 'horizontal'` | `'vertical'` | +| `container` | 父容器元素;不指定时自动使用 `dom` 元素的父容器 | `HTMLDivElement \| null \| undefined` | - | +| `defaultRatio` | 左侧/上方面板的默认比例(0~1) | `number` | `0.32` | +| `minRatio` | 左侧/上方面板的最小比例(0~1) | `number` | `0.15` | +| `maxRatio` | 左侧/上方面板的最大比例(0~1) | `number` | `0.85` | +| `splitterWidth` | 分割条的宽度(像素) | `number` | `1` | +| `className` | 分割条元素的额外类名 | `string` | - | +| `style` | 分割条元素的额外样式 | `CSSProperties` | - | +| `prefixCls` | 自定义 CSS 类前缀 | `string` | - | +| `classNames` | 语义化类名(`hover` / `dragging` / `handle`) | `{ hover?: string; dragging?: string; handle?: string }` | - | +| `styles` | 语义化样式(`handle`) | `{ handle?: CSSProperties }` | - | +| `onChange` | 分割比例变化时的回调 | `(ratio?: number) => void` | - | + +### 返回值 + +| 成员 | 说明 | 签名 | +| ----------- | ------------------------------ | ---------------------------- | +| `dom` | 放置在两个面板之间的分割条元素 | `ReactElement` | +| `percent` | 左侧/上方面板的当前比例(0~1) | `number \| undefined` | +| `width` | 左侧/上方面板的当前像素宽度 | `number \| undefined` | +| `dragging` | 分割条是否正在拖动 | `boolean` | +| `direction` | 当前分割方向 | `'vertical' \| 'horizontal'` | diff --git a/.storybook/stories/hooks/useSplitter/index.stories.tsx b/.storybook/stories/hooks/useSplitter/index.stories.tsx new file mode 100644 index 0000000..7bc4464 --- /dev/null +++ b/.storybook/stories/hooks/useSplitter/index.stories.tsx @@ -0,0 +1,131 @@ +import type { CSSProperties } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { Card, Space, Tag, Typography } from 'antd'; +import useSplitter from '../../../../src/hooks/useSplitter'; +import storyI18n, { storyT, useStoryT } from '../../../locales'; +import apiDocEN from './api-doc.en-US.md?raw'; +import apiDocCN from './api-doc.zh-CN.md?raw'; +import introduceEN from './introduce.en-US.md?raw'; +import introduceCN from './introduce.zh-CN.md?raw'; + +interface UseSplitterStoryArgs { + direction: 'vertical' | 'horizontal'; + defaultRatio: number; + minRatio: number; + maxRatio: number; +} + +const meta: Meta<UseSplitterStoryArgs> = { + title: 'Hooks/useSplitter', + parameters: { + docs: { + description: { + component: storyI18n.language === 'zh-CN' ? `${introduceCN}\n${apiDocCN}` : `${introduceEN}\n${apiDocEN}`, + }, + }, + }, + args: { + direction: 'vertical', + defaultRatio: 0.32, + minRatio: 0.15, + maxRatio: 0.85, + }, + argTypes: { + direction: { + control: 'radio', + options: ['vertical', 'horizontal'], + description: storyT('storybook.stories.useSplitter.argTypes.direction.description'), + }, + defaultRatio: { + control: { type: 'range', min: 0.1, max: 0.9, step: 0.01 }, + description: storyT('storybook.stories.useSplitter.argTypes.defaultRatio.description'), + }, + minRatio: { + control: { type: 'range', min: 0.05, max: 0.5, step: 0.01 }, + description: storyT('storybook.stories.useSplitter.argTypes.minRatio.description'), + }, + maxRatio: { + control: { type: 'range', min: 0.5, max: 0.95, step: 0.01 }, + description: storyT('storybook.stories.useSplitter.argTypes.maxRatio.description'), + }, + }, +}; + +export default meta; +type Story = StoryObj<UseSplitterStoryArgs>; + +export const Playground: Story = { + parameters: { + docs: { + description: { + story: + '- **EN:** Drag the splitter handle to resize the two panes. Use the controls to change direction and the `minRatio` / `maxRatio` boundaries, and watch the live `percent` update as you drag.\n- **CN:** 拖动分割条即可调整两个面板的尺寸。通过控件切换方向并调整 `minRatio` / `maxRatio` 边界,拖动时可实时观察 `percent` 的变化。', + }, + }, + }, + render: function Render(args: UseSplitterStoryArgs) { + return <UseSplitterStoryDemo {...args} />; + }, +}; + +function UseSplitterStoryDemo({ direction, defaultRatio, minRatio, maxRatio }: UseSplitterStoryArgs) { + const t = useStoryT(); + const { dom, percent, dragging } = useSplitter({ + direction, + defaultRatio, + minRatio, + maxRatio, + onChange: () => undefined, + }); + + const ratio = percent ?? defaultRatio; + const vertical = direction === 'vertical'; + + const leftPaneStyle: CSSProperties = vertical + ? { height: '100%', width: `${ratio * 100}%` } + : { width: '100%', height: `${ratio * 100}%` }; + + const containerStyle: CSSProperties = vertical + ? { display: 'flex', height: 300, width: '100%' } + : { display: 'flex', flexDirection: 'column', height: 300, width: '100%' }; + + return ( + <Card variant="outlined" style={{ maxWidth: 920 }} title={t('storybook.stories.useSplitter.cardTitle')}> + <Space orientation="vertical" size="large" style={{ width: '100%' }}> + <Typography.Paragraph style={{ marginBottom: 0 }}> + {t('storybook.stories.useSplitter.description')} + </Typography.Paragraph> + + <div style={containerStyle}> + <div + style={{ + ...leftPaneStyle, + flex: '0 0 auto', + background: '#1677ff', + color: '#fff', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + overflow: 'hidden', + }} + > + {t('storybook.stories.useSplitter.pane', { position: vertical ? 'left' : 'top' })} + </div> + {dom} + <div style={{ flex: 1, minWidth: 0, minHeight: 0, background: '#52c41a' }} /> + </div> + + <Space wrap> + <Typography.Text strong>{t('storybook.stories.useSplitter.directionLabel')}</Typography.Text> + <Tag>{direction}</Tag> + <Typography.Text strong>{t('storybook.stories.useSplitter.ratioLabel')}</Typography.Text> + <Tag color={dragging ? 'processing' : undefined}>{`${(ratio * 100).toFixed(1)}%`}</Tag> + <Typography.Text strong>{t('storybook.stories.useSplitter.minRatioLabel')}</Typography.Text> + <Tag>{minRatio}</Tag> + <Typography.Text strong>{t('storybook.stories.useSplitter.maxRatioLabel')}</Typography.Text> + <Tag>{maxRatio}</Tag> + </Space> + </Space> + </Card> + ); +} diff --git a/.storybook/stories/hooks/useSplitter/introduce.en-US.md b/.storybook/stories/hooks/useSplitter/introduce.en-US.md new file mode 100644 index 0000000..7f03d3b --- /dev/null +++ b/.storybook/stories/hooks/useSplitter/introduce.en-US.md @@ -0,0 +1,22 @@ +Make any two-pane layout resizable with a draggable splitter. The hook renders a splitter `dom` element for you, tracks the live ratio, and supports vertical/horizontal directions with configurable min/max boundaries. It integrates with the library's CSS variables and prefix class system. + +## When to use + +- Left/right or top/bottom resizable panes (file explorers, code editors, dashboards). +- Any layout where the user should be able to adjust the proportion of two areas. +- When you want the splitter visuals to follow the library's theme tokens and `prefixCls` convention. + +## Key features + +- **Render-ready** — returns a `dom` splitter element; just place it between your panes. +- **Controlled bounds** — `minRatio` / `maxRatio` clamp the draggable range; `defaultRatio` sets the initial split. +- **Bidirectional** — `vertical` (left/right) or `horizontal` (top/bottom) layouts. +- **Live feedback** — `percent`, `width`, and `dragging` are exposed for custom UI. +- **Theme-aware** — uses `ConfigProvider` prefix and CSS variables; hover/dragging/handle class hooks available. + +## Usage notes + +- The splitter resolves its container automatically from the `dom`'s parent element, or use the `container` prop explicitly. +- `maxRatio` defaults to `1 - minRatio` when not provided. +- Drag listeners are attached to `window` during drag, so the splitter keeps working when the pointer leaves the container. +- The ratio passed to `onChange` is the left/top pane's share of the container (0~1). diff --git a/.storybook/stories/hooks/useSplitter/introduce.zh-CN.md b/.storybook/stories/hooks/useSplitter/introduce.zh-CN.md new file mode 100644 index 0000000..bc37d1d --- /dev/null +++ b/.storybook/stories/hooks/useSplitter/introduce.zh-CN.md @@ -0,0 +1,22 @@ +让任意双面板布局通过可拖动的分割条调整尺寸。hook 为你渲染分割条 `dom` 元素,实时跟踪比例,并支持垂直/水平方向与可配置的最小/最大边界。与库的 CSS 变量和前缀类系统无缝集成。 + +## 适用场景 + +- 左右或上下可调整的分栏布局(文件管理器、代码编辑器、仪表盘)。 +- 需要用户调整两个区域占比的任何布局。 +- 希望分割条样式跟随主题 token 与 `prefixCls` 约定。 + +## 核心特性 + +- **开箱即用** —— 返回可直接放置的 `dom` 分割条元素,放在两个面板之间即可。 +- **边界可控** —— `minRatio` / `maxRatio` 限制拖动范围;`defaultRatio` 设置初始比例。 +- **双向布局** —— `vertical`(左右)或 `horizontal`(上下)。 +- **实时反馈** —— 暴露 `percent`、`width`、`dragging` 供自定义 UI 使用。 +- **主题感知** —— 使用 `ConfigProvider` 前缀与 CSS 变量;支持 hover/dragging/handle 类名钩子。 + +## 使用注意 + +- 分割条默认自动解析容器(`dom` 的父元素),也可通过 `container` 显式指定。 +- 未提供 `maxRatio` 时默认取 `1 - minRatio`。 +- 拖动期间监听器挂在 `window` 上,指针离开容器后拖动仍然有效。 +- `onChange` 中的比例是左侧/上方面板占容器的份额(0~1)。 diff --git a/.storybook/stories/hooks/useStompSocket/api-doc.en-US.md b/.storybook/stories/hooks/useStompSocket/api-doc.en-US.md new file mode 100644 index 0000000..b562d89 --- /dev/null +++ b/.storybook/stories/hooks/useStompSocket/api-doc.en-US.md @@ -0,0 +1,25 @@ +## API + +### Options — UseSocketOptions\<M\> + +| Name | Description | Type | (Default) | +| ------------------- | ------------------------------------------------ | ---------------------- | --------- | +| `url` | Socket connection address | `string` | - | +| `connectConfig` | STOMP connection configuration | `StompConfig` | - | +| `sendEndpoint` | Channel path for publishing messages | `string` | - | +| `subscribeEndpoint` | Endpoint path for receiving messages | `string` | - | +| `onConnected` | Connection success callback | `() => void` | - | +| `onMessage` | Message received callback | `(message: M) => void` | - | +| `parseMessageBody` | Parse the message body; result feeds `onMessage` | `(body: string) => M` | - | +| `onClose` | Connection close callback | `() => void` | - | + +### Return + +| Member | Description | Signature | +| ------------- | ------------------------------------------------ | ------------------------ | +| `connect` | Establish the socket and STOMP connection | `() => Promise<void>` | +| `close` | Deactivate the STOMP client and close the socket | `() => void` | +| `send` | Publish a message to `sendEndpoint` | `(body: string) => void` | +| `connecting` | Whether a connection is in progress | `boolean` | +| `socket` | The underlying SockJS socket (may be undefined) | `WebSocket \| undefined` | +| `stompClient` | The underlying STOMP client (may be undefined) | `Client \| undefined` | diff --git a/.storybook/stories/hooks/useStompSocket/api-doc.zh-CN.md b/.storybook/stories/hooks/useStompSocket/api-doc.zh-CN.md new file mode 100644 index 0000000..97c8fd7 --- /dev/null +++ b/.storybook/stories/hooks/useStompSocket/api-doc.zh-CN.md @@ -0,0 +1,25 @@ +## API + +### 参数 —— UseSocketOptions\<M\> + +| 参数 | 说明 | 类型 | 默认值 | +| ------------------- | --------------------------------------- | ---------------------- | ------ | +| `url` | Socket 连接地址 | `string` | - | +| `connectConfig` | STOMP 连接配置 | `StompConfig` | - | +| `sendEndpoint` | 发布消息的频道路径 | `string` | - | +| `subscribeEndpoint` | 接收消息的端点路径 | `string` | - | +| `onConnected` | 连接成功回调 | `() => void` | - | +| `onMessage` | 接收到消息回调 | `(message: M) => void` | - | +| `parseMessageBody` | 解析消息体;返回值作为 `onMessage` 入参 | `(body: string) => M` | - | +| `onClose` | 连接关闭回调 | `() => void` | - | + +### 返回值 + +| 成员 | 说明 | 签名 | +| ------------- | -------------------------------------- | ------------------------ | +| `connect` | 建立 socket 与 STOMP 连接 | `() => Promise<void>` | +| `close` | 停用 STOMP 客户端并关闭 socket | `() => void` | +| `send` | 向 `sendEndpoint` 发布消息 | `(body: string) => void` | +| `connecting` | 是否正在连接 | `boolean` | +| `socket` | 底层 SockJS socket(可能为 undefined) | `WebSocket \| undefined` | +| `stompClient` | 底层 STOMP 客户端(可能为 undefined) | `Client \| undefined` | diff --git a/.storybook/stories/hooks/useStompSocket/index.stories.tsx b/.storybook/stories/hooks/useStompSocket/index.stories.tsx new file mode 100644 index 0000000..5a25499 --- /dev/null +++ b/.storybook/stories/hooks/useStompSocket/index.stories.tsx @@ -0,0 +1,167 @@ +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { Alert, Button, Card, Input, Space, Tag, Typography } from 'antd'; +import useStompSocket from '../../../../src/hooks/useStompSocket'; +import storyI18n, { storyT, useStoryT } from '../../../locales'; +import apiDocEN from './api-doc.en-US.md?raw'; +import apiDocCN from './api-doc.zh-CN.md?raw'; +import introduceEN from './introduce.en-US.md?raw'; +import introduceCN from './introduce.zh-CN.md?raw'; + +interface UseStompSocketStoryArgs { + url: string; + sendEndpoint: string; + subscribeEndpoint: string; +} + +const meta: Meta<UseStompSocketStoryArgs> = { + title: 'Hooks/useStompSocket', + parameters: { + docs: { + description: { + component: storyI18n.language === 'zh-CN' ? `${introduceCN}\n${apiDocCN}` : `${introduceEN}\n${apiDocEN}`, + }, + }, + }, + args: { + url: '', + sendEndpoint: '/app/send', + subscribeEndpoint: '/topic/messages', + }, + argTypes: { + url: { + control: 'text', + description: storyT('storybook.stories.useStompSocket.argTypes.url.description'), + }, + sendEndpoint: { + control: 'text', + description: storyT('storybook.stories.useStompSocket.argTypes.sendEndpoint.description'), + }, + subscribeEndpoint: { + control: 'text', + description: storyT('storybook.stories.useStompSocket.argTypes.subscribeEndpoint.description'), + }, + }, +}; + +export default meta; +type Story = StoryObj<UseStompSocketStoryArgs>; + +export const Playground: Story = { + parameters: { + docs: { + description: { + story: + '- **EN:** Enter a SockJS/STOMP endpoint URL and press **Connect**. The hook opens the socket, subscribes to the receive endpoint, and lets you send messages. A live backend is required; the demo renders without one.\\n- **CN:** 输入 SockJS/STOMP 服务地址并点击**连接**。hook 会建立 socket、订阅接收端点,并允许发送消息。本示例需要真实后端;没有后端也能正常渲染。', + }, + }, + }, + render: function Render(args: UseStompSocketStoryArgs) { + return <UseStompSocketStoryDemo {...args} />; + }, +}; + +function UseStompSocketStoryDemo({ url: initialUrl, sendEndpoint, subscribeEndpoint }: UseStompSocketStoryArgs) { + const t = useStoryT(); + const [url, setUrl] = useState(initialUrl); + const [message, setMessage] = useState(''); + const [log, setLog] = useState<string[]>([]); + const [connected, setConnected] = useState(false); + const [busy, setBusy] = useState(false); + + const appendLog = (text: string) => { + setLog((prev) => [text, ...prev].slice(0, 10)); + }; + + const { connect, close, send } = useStompSocket<string>({ + url, + sendEndpoint, + subscribeEndpoint, + onConnected: () => { + setConnected(true); + setBusy(false); + appendLog(t('storybook.stories.useStompSocket.log.connected')); + }, + onMessage: (msg) => { + appendLog(`${t('storybook.stories.useStompSocket.log.received')} ${msg}`); + }, + onClose: () => { + setConnected(false); + setBusy(false); + appendLog(t('storybook.stories.useStompSocket.log.closed')); + }, + }); + + const handleConnect = async () => { + setBusy(true); + appendLog(t('storybook.stories.useStompSocket.log.connecting')); + await connect(); + }; + + const handleSend = () => { + if (!message.trim()) return; + send(message); + appendLog(`${t('storybook.stories.useStompSocket.log.sent')} ${message}`); + setMessage(''); + }; + + return ( + <Card variant="outlined" style={{ maxWidth: 920 }} title={t('storybook.stories.useStompSocket.cardTitle')}> + <Space orientation="vertical" size="large" style={{ width: '100%' }}> + <Typography.Paragraph style={{ marginBottom: 0 }}> + {t('storybook.stories.useStompSocket.description')} + </Typography.Paragraph> + + <Alert type="info" message={t('storybook.stories.useStompSocket.serverHint')} showIcon /> + + <Space wrap style={{ width: '100%' }}> + <Input + style={{ maxWidth: 420 }} + placeholder={t('storybook.stories.useStompSocket.urlPlaceholder')} + value={url} + onChange={(e) => setUrl(e.target.value)} + /> + <Button type="primary" loading={busy} disabled={!url.trim()} onClick={handleConnect}> + {t('storybook.stories.useStompSocket.connect')} + </Button> + <Button danger disabled={!connected} onClick={close}> + {t('storybook.stories.useStompSocket.close')} + </Button> + <Space size="small"> + <Typography.Text strong>{t('storybook.stories.useStompSocket.statusLabel')}</Typography.Text> + <Tag color={connected ? 'green' : 'default'}> + {connected + ? t('storybook.stories.useStompSocket.status.connected') + : t('storybook.stories.useStompSocket.status.disconnected')} + </Tag> + </Space> + </Space> + + <Space wrap style={{ width: '100%' }}> + <Input + style={{ maxWidth: 420 }} + placeholder={t('storybook.stories.useStompSocket.messagePlaceholder')} + value={message} + disabled={!connected} + onChange={(e) => setMessage(e.target.value)} + onPressEnter={handleSend} + /> + <Button disabled={!connected || !message.trim()} onClick={handleSend}> + {t('storybook.stories.useStompSocket.send')} + </Button> + </Space> + + <Typography.Text strong>{t('storybook.stories.useStompSocket.logTitle')}</Typography.Text> + {log.length === 0 ? ( + <Typography.Text type="secondary">{t('storybook.stories.useStompSocket.emptyLog')}</Typography.Text> + ) : ( + log.map((item, index) => ( + <Typography.Text key={`${item}-${index}`} code> + {item} + </Typography.Text> + )) + )} + </Space> + </Card> + ); +} diff --git a/.storybook/stories/hooks/useStompSocket/introduce.en-US.md b/.storybook/stories/hooks/useStompSocket/introduce.en-US.md new file mode 100644 index 0000000..230bfcb --- /dev/null +++ b/.storybook/stories/hooks/useStompSocket/introduce.en-US.md @@ -0,0 +1,21 @@ +Establish a **STOMP-over-WebSocket** connection using SockJS, mainly for bidirectional transmission of serializable text messages. The hook wraps a `@stomp/stompjs` client, manages connect/close, subscribes to a receive endpoint, and provides a typed `send` helper. + +## When to use + +- Real-time bidirectional messaging: chat, notifications, live dashboards. +- Spring Boot / Spring Messaging backends that expose SockJS + STOMP endpoints. +- Any flow where the server pushes messages and the client also publishes to a channel. + +## Key features + +- **Full lifecycle** — `connect()` / `close()` manage the SockJS socket and STOMP client. +- **Auto subscription** — subscribes to `subscribeEndpoint` when connected and routes messages to `onMessage`. +- **Typed messaging** — `parseMessageBody` deserializes the raw body; `send(body)` publishes to `sendEndpoint`. +- **Connection state** — `connecting` flag and callbacks `onConnected` / `onClose` for UI feedback. + +## Usage notes + +- Requires a SockJS-compatible STOMP server; the demo does not auto-connect. +- `connectConfig` is merged with default heartbeats (`5000`ms in/out) and the `webSocketFactory`. +- `send()` requires `sendEndpoint`; it logs an error when the endpoint is missing. +- The hook shows a `notification` on connection errors/disconnects. diff --git a/.storybook/stories/hooks/useStompSocket/introduce.zh-CN.md b/.storybook/stories/hooks/useStompSocket/introduce.zh-CN.md new file mode 100644 index 0000000..940e77c --- /dev/null +++ b/.storybook/stories/hooks/useStompSocket/introduce.zh-CN.md @@ -0,0 +1,21 @@ +基于 SockJS 建立 **STOMP 协议 WebSocket** 连接,主要用于双向传递可序列化的字符型消息。hook 封装了 `@stomp/stompjs` 客户端,管理连接/关闭、订阅接收端点,并提供类型化的 `send` 发送辅助。 + +## 适用场景 + +- 实时双向消息:聊天、通知、实时仪表盘。 +- 暴露 SockJS + STOMP 端点的 Spring Boot / Spring Messaging 后端。 +- 服务端推送消息、客户端同时向频道发布消息的场景。 + +## 核心特性 + +- **完整生命周期** —— `connect()` / `close()` 管理 SockJS socket 与 STOMP 客户端。 +- **自动订阅** —— 连接成功后订阅 `subscribeEndpoint`,并将消息路由到 `onMessage`。 +- **类型化消息** —— `parseMessageBody` 反序列化原始消息体;`send(body)` 发布到 `sendEndpoint`。 +- **连接状态** —— `connecting` 标志与 `onConnected` / `onClose` 回调便于 UI 反馈。 + +## 使用注意 + +- 需要 SockJS 兼容的 STOMP 服务端;示例不会自动连接。 +- `connectConfig` 与默认心跳(进出各 5000ms)及 `webSocketFactory` 合并。 +- `send()` 依赖 `sendEndpoint`;未配置端点时会打印错误日志。 +- 连接出错/断开时 hook 会弹出 `notification` 提示。 diff --git a/.storybook/stories/hooks/useUserMedia/api-doc.en-US.md b/.storybook/stories/hooks/useUserMedia/api-doc.en-US.md new file mode 100644 index 0000000..a1f1a8c --- /dev/null +++ b/.storybook/stories/hooks/useUserMedia/api-doc.en-US.md @@ -0,0 +1,26 @@ +## API + +### Props — UseUserMediaProps + +| Name | Description | Type | (Default) | +| ------------------------- | ---------------------------------------------------------- | ---------------------------------------------------------------------------------- | --------- | +| `media` | Media constraints for the audio and video stream | `Pick<MediaStreamConstraints, 'audio' \| 'video'>` | - | +| `pcmAudioOptions` | Audio context / worklet options for PCM output | `{ audioContext?: AudioContextOptions; workletOptions?: AudioWorkletNodeOptions }` | - | +| `onStartRecording` | Callback when recording starts; provides the media stream | `(stream: MediaStream) => void` | - | +| `onStopRecording` | Callback when recording stops | `() => void` | - | +| `onStreamChunk` | Callback per media chunk (MIME: `audio/webm;codecs=opus`) | `(chunk: Blob) => void` | - | +| `onPcmStreamChunk` | Callback with raw PCM float data per render quantum | `(channels: Float32Array[], sampleRate: number) => void` | - | +| `disabled` | Whether to disable this hook | `boolean` | - | +| `streamSliceMode` | Slicing mode (`time` or `size`) | `StreamTimeSlicerOptions['sliceMode']` | `'time'` | +| `streamSliceValue` | Slicing value (ms or bytes) | `StreamTimeSlicerOptions['value']` | - | +| `soundDetectionThreshold` | Silence detection threshold (0~1) | `number` | `0` | +| `soundDetectionTimeout` | Silence timeout in ms before prompting device re-selection | `number` | `3000` | + +### Return — UseUserMediaResult + +| Member | Description | Signature | +| ---------------- | ---------------------------------------------------- | ------------------------------ | +| `isRecording` | Whether the media stream is currently being recorded | `boolean` | +| `startRecording` | Start recording; resolves with the `MediaRecorder` | `() => Promise<MediaRecorder>` | +| `stopRecording` | Stop recording and release tracks | `() => void` | +| `mediaStream` | The stream being recorded, if recording | `MediaStream \| undefined` | diff --git a/.storybook/stories/hooks/useUserMedia/api-doc.zh-CN.md b/.storybook/stories/hooks/useUserMedia/api-doc.zh-CN.md new file mode 100644 index 0000000..32db1d9 --- /dev/null +++ b/.storybook/stories/hooks/useUserMedia/api-doc.zh-CN.md @@ -0,0 +1,26 @@ +## API + +### 参数 —— UseUserMediaProps + +| 参数 | 说明 | 类型 | 默认值 | +| ------------------------- | -------------------------------------------------- | ---------------------------------------------------------------------------------- | -------- | +| `media` | 媒体流的媒体约束 | `Pick<MediaStreamConstraints, 'audio' \| 'video'>` | - | +| `pcmAudioOptions` | PCM 输出的音频上下文/工作线程选项 | `{ audioContext?: AudioContextOptions; workletOptions?: AudioWorkletNodeOptions }` | - | +| `onStartRecording` | 开始录制时触发,提供媒体流 | `(stream: MediaStream) => void` | - | +| `onStopRecording` | 停止录制时触发 | `() => void` | - | +| `onStreamChunk` | 每个媒体分片回调(MIME:`audio/webm;codecs=opus`) | `(chunk: Blob) => void` | - | +| `onPcmStreamChunk` | 每个渲染量子返回原始 PCM 浮点数据 | `(channels: Float32Array[], sampleRate: number) => void` | - | +| `disabled` | 是否禁用此工具 | `boolean` | - | +| `streamSliceMode` | 切片模式(`time` 或 `size`) | `StreamTimeSlicerOptions['sliceMode']` | `'time'` | +| `streamSliceValue` | 切片值(毫秒或字节) | `StreamTimeSlicerOptions['value']` | - | +| `soundDetectionThreshold` | 静音检测阈值(0~1) | `number` | `0` | +| `soundDetectionTimeout` | 静音超时(毫秒),超时提示重新选择音频设备 | `number` | `3000` | + +### 返回值 —— UseUserMediaResult + +| 成员 | 说明 | 签名 | +| ---------------- | ---------------------------------------- | ------------------------------ | +| `isRecording` | 是否正在录制媒体流 | `boolean` | +| `startRecording` | 开始录制;返回 `MediaRecorder` 实例 | `() => Promise<MediaRecorder>` | +| `stopRecording` | 停止录制并释放轨道 | `() => void` | +| `mediaStream` | 正在录制的媒体流;未录制时为 `undefined` | `MediaStream \| undefined` | diff --git a/.storybook/stories/hooks/useUserMedia/index.stories.tsx b/.storybook/stories/hooks/useUserMedia/index.stories.tsx new file mode 100644 index 0000000..9c68785 --- /dev/null +++ b/.storybook/stories/hooks/useUserMedia/index.stories.tsx @@ -0,0 +1,107 @@ +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { Alert, Button, Card, Space, Tag, Typography } from 'antd'; +import useUserMedia from '../../../../src/hooks/useUserMedia'; +import storyI18n, { storyT, useStoryT } from '../../../locales'; +import apiDocEN from './api-doc.en-US.md?raw'; +import apiDocCN from './api-doc.zh-CN.md?raw'; +import introduceEN from './introduce.en-US.md?raw'; +import introduceCN from './introduce.zh-CN.md?raw'; + +interface UseUserMediaStoryArgs { + audio: boolean; +} + +const meta: Meta<UseUserMediaStoryArgs> = { + title: 'Hooks/useUserMedia', + parameters: { + docs: { + description: { + component: storyI18n.language === 'zh-CN' ? `${introduceCN}\n${apiDocCN}` : `${introduceEN}\n${apiDocEN}`, + }, + }, + }, + args: { + audio: true, + }, + argTypes: { + audio: { + control: 'boolean', + description: storyT('storybook.stories.useUserMedia.argTypes.audio.description'), + }, + }, +}; + +export default meta; +type Story = StoryObj<UseUserMediaStoryArgs>; + +export const Playground: Story = { + parameters: { + docs: { + description: { + story: + '- **EN:** Press **Start recording** to request the microphone and begin recording (audio only). The demo shows the live `isRecording` state and whether a media stream is available. It never auto-starts.\\n- **CN:** 点击**开始录制**请求麦克风权限并开始录制(仅音频)。示例展示实时的 `isRecording` 状态与媒体流是否可用;不会自动开始录制。', + }, + }, + }, + render: function Render(args: UseUserMediaStoryArgs) { + return <UseUserMediaStoryDemo {...args} />; + }, +}; + +function UseUserMediaStoryDemo({ audio }: UseUserMediaStoryArgs) { + const t = useStoryT(); + const [error, setError] = useState<string | null>(null); + + const { isRecording, startRecording, stopRecording, mediaStream } = useUserMedia({ + media: { audio: audio ? true : false, video: false }, + onStartRecording: () => setError(null), + onStopRecording: () => undefined, + }); + + const handleStart = async () => { + setError(null); + try { + await startRecording(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }; + + return ( + <Card variant="outlined" style={{ maxWidth: 920 }} title={t('storybook.stories.useUserMedia.cardTitle')}> + <Space orientation="vertical" size="large" style={{ width: '100%' }}> + <Typography.Paragraph style={{ marginBottom: 0 }}> + {t('storybook.stories.useUserMedia.description')} + </Typography.Paragraph> + + <Space wrap> + <Button type="primary" disabled={isRecording} onClick={handleStart}> + {t('storybook.stories.useUserMedia.start')} + </Button> + <Button danger disabled={!isRecording} onClick={stopRecording}> + {t('storybook.stories.useUserMedia.stop')} + </Button> + </Space> + + <Space wrap> + <Typography.Text strong>{t('storybook.stories.useUserMedia.recordingLabel')}</Typography.Text> + <Tag color={isRecording ? 'red' : 'default'}> + {isRecording + ? t('storybook.stories.useUserMedia.recording') + : t('storybook.stories.useUserMedia.notRecording')} + </Tag> + <Typography.Text strong>{t('storybook.stories.useUserMedia.streamLabel')}</Typography.Text> + <Tag color={mediaStream ? 'green' : 'default'}> + {mediaStream + ? t('storybook.stories.useUserMedia.streamAvailable') + : t('storybook.stories.useUserMedia.streamNone')} + </Tag> + </Space> + + {error && <Alert type="error" message={error} showIcon />} + <Alert type="info" message={t('storybook.stories.useUserMedia.tip')} showIcon /> + </Space> + </Card> + ); +} diff --git a/.storybook/stories/hooks/useUserMedia/introduce.en-US.md b/.storybook/stories/hooks/useUserMedia/introduce.en-US.md new file mode 100644 index 0000000..ace7d69 --- /dev/null +++ b/.storybook/stories/hooks/useUserMedia/introduce.en-US.md @@ -0,0 +1,22 @@ +Record **audio/video media streams** from the user's devices (`getUserMedia`) with a `MediaRecorder` wrapper. The hook handles permission prompts, chunk delivery, optional PCM audio capture, and a sound-detection fallback that lets users re-select an audio device when silence is detected. + +## When to use + +- Recording microphone audio or camera video in the browser. +- Uploading streamed chunks (via `onStreamChunk`) to a server while recording. +- Capturing raw PCM audio data (via `onPcmStreamChunk`) for speech or audio processing. + +## Key features + +- **Full recording lifecycle** — `startRecording()` / `stopRecording()` manage the stream and recorder. +- **Chunk streaming** — `onStreamChunk` delivers `Blob` chunks (MIME `audio/webm;codecs=opus`) per time/size slice. +- **PCM capture** — optional AudioWorklet-based PCM output through `onPcmStreamChunk`. +- **Permission handling** — detects denied/prompt states and shows guided popups with device-reset instructions. +- **Silence detection** — when no sound arrives within `soundDetectionTimeout`, prompts the user to re-select the microphone. + +## Usage notes + +- Requires `navigator.mediaDevices.getUserMedia` and `navigator.permissions.query`; unsupported browsers throw a readable error. +- `streamSliceMode` is `time` (milliseconds) or `size` (bytes) and is passed to `MediaRecorder.start`. +- `soundDetectionThreshold` (0~1, default `0`) sets the RMS threshold below which audio is considered silent. +- Recording is stopped automatically on unmount and when `disabled` becomes `true`. diff --git a/.storybook/stories/hooks/useUserMedia/introduce.zh-CN.md b/.storybook/stories/hooks/useUserMedia/introduce.zh-CN.md new file mode 100644 index 0000000..05219e5 --- /dev/null +++ b/.storybook/stories/hooks/useUserMedia/introduce.zh-CN.md @@ -0,0 +1,22 @@ +通过 `getUserMedia` 录制用户的**音视频媒体流**,并用 `MediaRecorder` 封装。hook 处理权限提示、分片回调、可选的 PCM 音频采集,以及静音检测回退(检测到长时间静音时引导用户重新选择音频设备)。 + +## 适用场景 + +- 在浏览器中录制麦克风音频或摄像头视频。 +- 录制过程中通过 `onStreamChunk` 将分片上传到服务端。 +- 通过 `onPcmStreamChunk` 采集原始 PCM 音频数据,用于语音或音频处理。 + +## 核心特性 + +- **完整录制生命周期** —— `startRecording()` / `stopRecording()` 管理媒体流与录制器。 +- **分片回调** —— `onStreamChunk` 按时间/大小分片返回 `Blob`(MIME:`audio/webm;codecs=opus`)。 +- **PCM 采集** —— 基于 AudioWorklet 的可选 PCM 输出,通过 `onPcmStreamChunk` 回调。 +- **权限处理** —— 识别 denied/prompt 状态,弹出带设备重置指引的引导弹窗。 +- **静音检测** —— 在 `soundDetectionTimeout` 内未检测到声音时,提示用户重新选择麦克风。 + +## 使用注意 + +- 依赖 `navigator.mediaDevices.getUserMedia` 与 `navigator.permissions.query`;不支持的浏览器会抛出可读错误。 +- `streamSliceMode` 为 `time`(毫秒)或 `size`(字节),会传给 `MediaRecorder.start`。 +- `soundDetectionThreshold`(0~1,默认 `0`)为静音判定的 RMS 阈值。 +- 卸载时以及 `disabled` 变为 `true` 时会自动停止录制。 diff --git a/.storybook/stories/hooks/useValidator/api-doc.en-US.md b/.storybook/stories/hooks/useValidator/api-doc.en-US.md new file mode 100644 index 0000000..cb6a3d7 --- /dev/null +++ b/.storybook/stories/hooks/useValidator/api-doc.en-US.md @@ -0,0 +1,23 @@ +## API + +### Props — BuilderOptions + +| Name | Description | Type | +| ------------ | ------------------------------------- | --------------------------------------- | +| `allowed` | The rule of allowed characters | `RuleRegExpFlags` | +| `startsWith` | The rule of the starting character | `Omit<RuleRegExpFlags, 'min' \| 'max'>` | +| `flags` | Regular expression flags (e.g. `'i'`) | `string` | + +### Return — ValidatorRule + +| Member | Description | Type | +| ------------------- | -------------------------- | ----------------------- | +| `pattern` | The regex for verification | `RegExp` | +| `message` | Failure message | `string` | +| `allowedOptions` | Allowed characters options | `RuleRegExpFlags` | +| `startsWithOptions` | Starting character options | `StartsWithRegExpFlags` | +| `flags` | Regular expression flags | `string` | + +### Types + +`RuleRegExpFlags`: `letter`, `lowerLetter`, `upperLetter`, `chineseCharacter`, `chinesePunctuation`, `number`, `hyphen`, `underscore`, `special: string[]`, `min: number`, `max: number`. diff --git a/.storybook/stories/hooks/useValidator/api-doc.zh-CN.md b/.storybook/stories/hooks/useValidator/api-doc.zh-CN.md new file mode 100644 index 0000000..dfc153d --- /dev/null +++ b/.storybook/stories/hooks/useValidator/api-doc.zh-CN.md @@ -0,0 +1,23 @@ +## API + +### 参数 —— BuilderOptions + +| 名称 | 说明 | 类型 | +| ------------ | ---------------------------- | --------------------------------------- | +| `allowed` | 允许字符的规则 | `RuleRegExpFlags` | +| `startsWith` | 开头字符的规则 | `Omit<RuleRegExpFlags, 'min' \| 'max'>` | +| `flags` | 正则表达式的标志(如 `'i'`) | `string` | + +### 返回值 —— ValidatorRule + +| 成员 | 说明 | 类型 | +| ------------------- | -------------------- | ----------------------- | +| `pattern` | 校验正则表达式 | `RegExp` | +| `message` | 校验失败时的提示信息 | `string` | +| `allowedOptions` | 允许字符的规则设置 | `RuleRegExpFlags` | +| `startsWithOptions` | 开头字符的规则设置 | `StartsWithRegExpFlags` | +| `flags` | 正则表达式的标志 | `string` | + +### 类型 + +`RuleRegExpFlags`:`letter`、`lowerLetter`、`upperLetter`、`chineseCharacter`、`chinesePunctuation`、`number`、`hyphen`、`underscore`、`special: string[]`、`min: number`、`max: number`。 diff --git a/.storybook/stories/hooks/useValidator/index.stories.tsx b/.storybook/stories/hooks/useValidator/index.stories.tsx new file mode 100644 index 0000000..6a144a8 --- /dev/null +++ b/.storybook/stories/hooks/useValidator/index.stories.tsx @@ -0,0 +1,142 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { Alert, Card, Space, Tag, Typography } from 'antd'; +import useValidator from '../../../../src/hooks/useValidator'; +import type { RuleRegExpFlags } from '../../../../src/hooks/useValidatorBuilder'; +import storyI18n, { storyT, useStoryT } from '../../../locales'; +import apiDocEN from './api-doc.en-US.md?raw'; +import apiDocCN from './api-doc.zh-CN.md?raw'; +import introduceEN from './introduce.en-US.md?raw'; +import introduceCN from './introduce.zh-CN.md?raw'; + +interface UseValidatorStoryArgs { + letter: boolean; + number: boolean; + underscore: boolean; + hyphen: boolean; + chineseCharacter: boolean; + max: number; + testValue: string; +} + +const meta: Meta<UseValidatorStoryArgs> = { + title: 'Hooks/useValidator', + parameters: { + docs: { + description: { + component: storyI18n.language === 'zh-CN' ? `${introduceCN}\n${apiDocCN}` : `${introduceEN}\n${apiDocEN}`, + }, + }, + }, + args: { + letter: true, + number: true, + underscore: false, + hyphen: false, + chineseCharacter: false, + max: 20, + testValue: 'abc123', + }, + argTypes: { + letter: { control: 'boolean', description: storyT('storybook.stories.useValidator.argTypes.letter.description') }, + number: { control: 'boolean', description: storyT('storybook.stories.useValidator.argTypes.number.description') }, + underscore: { + control: 'boolean', + description: storyT('storybook.stories.useValidator.argTypes.underscore.description'), + }, + hyphen: { control: 'boolean', description: storyT('storybook.stories.useValidator.argTypes.hyphen.description') }, + chineseCharacter: { + control: 'boolean', + description: storyT('storybook.stories.useValidator.argTypes.chineseCharacter.description'), + }, + max: { + control: { type: 'number' }, + description: storyT('storybook.stories.useValidator.argTypes.max.description'), + }, + testValue: { + control: 'text', + description: storyT('storybook.stories.useValidator.argTypes.testValue.description'), + }, + }, +}; + +export default meta; +type Story = StoryObj<UseValidatorStoryArgs>; + +export const Playground: Story = { + parameters: { + docs: { + description: { + story: + '- **EN:** Toggle allowed character flags and a max length, then test a value against the generated rule. The resulting regex pattern is shown inline.\\n- **CN:** 切换允许的字符标志与最大长度,然后用生成规则测试一个值。生成的正则表达式会内联展示。', + }, + }, + }, + render: function Render(args: UseValidatorStoryArgs) { + return <UseValidatorStoryDemo {...args} />; + }, +}; + +function UseValidatorStoryDemo({ + letter, + number, + underscore, + hyphen, + chineseCharacter, + max, + testValue, +}: UseValidatorStoryArgs) { + const t = useStoryT(); + + const allowed: RuleRegExpFlags = { + letter, + number, + underscore, + hyphen, + chineseCharacter, + max: max || undefined, + }; + + const rule = useValidator({ allowed }); + const passed = rule.pattern.test(testValue); + + return ( + <Card variant="outlined" style={{ maxWidth: 920 }} title={t('storybook.stories.useValidator.cardTitle')}> + <Space orientation="vertical" size="large" style={{ width: '100%' }}> + <Typography.Paragraph style={{ marginBottom: 0 }}> + {t('storybook.stories.useValidator.description')} + </Typography.Paragraph> + + <Space wrap> + <Typography.Text strong> + {t('storybook.stories.useValidator.maxLabel')} {max} + </Typography.Text> + </Space> + + <Space wrap> + <Tag color={letter ? 'green' : 'default'}>a-zA-Z</Tag> + <Tag color={number ? 'green' : 'default'}>0-9</Tag> + <Tag color={underscore ? 'green' : 'default'}>_</Tag> + <Tag color={hyphen ? 'green' : 'default'}>-</Tag> + <Tag color={chineseCharacter ? 'green' : 'default'}>汉字</Tag> + </Space> + + <Space wrap> + <Typography.Text strong>{t('storybook.stories.useValidator.testLabel')}</Typography.Text> + <Typography.Text code>{testValue}</Typography.Text> + <Tag color={passed ? 'green' : 'red'}> + {passed ? t('storybook.stories.useValidator.pass') : t('storybook.stories.useValidator.fail')} + </Tag> + </Space> + + <Space wrap> + <Typography.Text strong>{t('storybook.stories.useValidator.patternLabel')}</Typography.Text> + <Typography.Text code copyable> + {rule.pattern.toString()} + </Typography.Text> + </Space> + + <Alert type="info" message={t('storybook.stories.useValidator.tip')} showIcon /> + </Space> + </Card> + ); +} diff --git a/.storybook/stories/hooks/useValidator/introduce.en-US.md b/.storybook/stories/hooks/useValidator/introduce.en-US.md new file mode 100644 index 0000000..f215a81 --- /dev/null +++ b/.storybook/stories/hooks/useValidator/introduce.en-US.md @@ -0,0 +1,20 @@ +Build a single **Ant-Design-compatible validation rule** once, from declarative character flags. Unlike `useValidatorBuilder` (which returns a reusable builder function), `useValidator` returns the final `ValidatorRule` object directly for the given `BuilderOptions`. + +## When to use + +- Creating a one-off rule with a specific allowed-character set and max length. +- Validating an input directly via `{ pattern, message }` in antd `Form.Item` rules. +- When you only need one rule and don't want to keep a builder function around. + +## Key features + +- **Declarative flags** — compose booleans (`letter`, `number`, `chineseCharacter`, `hyphen`, `underscore`, `special`, …) into a character class. +- **Memoized** — the rule is recomputed only when the relevant options change. +- **Localized messages** — failure message is assembled from i18n tokens. +- **antd-compatible** — returns `{ pattern, message, allowedOptions, startsWithOptions, flags }`. + +## Usage notes + +- At least one allowed flag must be `true`; otherwise it throws a localized error. +- Set `min` / `max` to bound length; with `startsWith`, length limits shrink by one for the leading char. +- `special` takes an array of allowed special characters. diff --git a/.storybook/stories/hooks/useValidator/introduce.zh-CN.md b/.storybook/stories/hooks/useValidator/introduce.zh-CN.md new file mode 100644 index 0000000..cfc8dc4 --- /dev/null +++ b/.storybook/stories/hooks/useValidator/introduce.zh-CN.md @@ -0,0 +1,20 @@ +根据声明式的字符标志,一次性构建**单个与 Ant Design 兼容的校验规则**。与返回可复用构建函数的 `useValidatorBuilder` 不同,`useValidator` 直接返回给定 `BuilderOptions` 的最终 `ValidatorRule` 对象。 + +## 适用场景 + +- 为特定的允许字符集合与最大长度创建一次性规则。 +- 通过 `{ pattern, message }` 在 antd `Form.Item` 的 `rules` 中直接校验输入。 +- 只需要一条规则、无需长期保留构建函数时。 + +## 核心特性 + +- **声明式标志** —— 用布尔值(`letter`、`number`、`chineseCharacter`、`hyphen`、`underscore`、`special` 等)组合字符类。 +- **记忆化** —— 仅在相关选项变化时重新计算规则。 +- **本地化消息** —— 校验失败提示由 i18n token 组装。 +- **antd 兼容** —— 返回 `{ pattern, message, allowedOptions, startsWithOptions, flags }`。 + +## 使用注意 + +- 至少一个允许标志必须为 `true`;否则抛出本地化错误。 +- 用 `min` / `max` 限制长度;存在 `startsWith` 时长度限制为前导字符减一。 +- `special` 接收允许的特殊字符数组。 diff --git a/.storybook/stories/hooks/useValidatorBuilder/api-doc.en-US.md b/.storybook/stories/hooks/useValidatorBuilder/api-doc.en-US.md new file mode 100644 index 0000000..e3278c5 --- /dev/null +++ b/.storybook/stories/hooks/useValidatorBuilder/api-doc.en-US.md @@ -0,0 +1,47 @@ +## API + +### Params + +No parameters. + +### Return + +| Member | Description | Type | +| ------- | --------------------------------------------- | ------------------------------------------ | +| `build` | Build a validation rule from `BuilderOptions` | `(props: BuilderOptions) => ValidatorRule` | + +### Types + +`BuilderOptions`: + +| Name | Description | Type | +| ------------ | ------------------------------------- | --------------------------------------- | +| `allowed` | The rule of allowed characters | `RuleRegExpFlags` | +| `startsWith` | The rule of the starting character | `Omit<RuleRegExpFlags, 'min' \| 'max'>` | +| `flags` | Regular expression flags (e.g. `'i'`) | `string` | + +`RuleRegExpFlags`: + +| Name | Description | Type | +| -------------------- | ---------------------------------------- | ---------- | +| `letter` | Lowercase and uppercase Latin characters | `boolean` | +| `lowerLetter` | Lowercase English letters | `boolean` | +| `upperLetter` | Uppercase English letters | `boolean` | +| `chineseCharacter` | Chinese characters | `boolean` | +| `chinesePunctuation` | Chinese (full-width) punctuation | `boolean` | +| `number` | Numbers | `boolean` | +| `hyphen` | Hyphen `-` | `boolean` | +| `underscore` | Underscore `_` | `boolean` | +| `special` | Specific special characters | `string[]` | +| `min` | Minimum number of characters | `number` | +| `max` | Maximum number of characters | `number` | + +`ValidatorRule`: + +| Member | Description | Type | +| ------------------- | -------------------------- | ----------------------- | +| `pattern` | The regex for verification | `RegExp` | +| `message` | Failure message | `string` | +| `allowedOptions` | Allowed characters options | `RuleRegExpFlags` | +| `startsWithOptions` | Starting character options | `StartsWithRegExpFlags` | +| `flags` | Regular expression flags | `string` | diff --git a/.storybook/stories/hooks/useValidatorBuilder/api-doc.zh-CN.md b/.storybook/stories/hooks/useValidatorBuilder/api-doc.zh-CN.md new file mode 100644 index 0000000..0ca4ffd --- /dev/null +++ b/.storybook/stories/hooks/useValidatorBuilder/api-doc.zh-CN.md @@ -0,0 +1,47 @@ +## API + +### 参数 + +无参数。 + +### 返回值 + +| 成员 | 说明 | 类型 | +| ------- | -------------------------------- | ------------------------------------------ | +| `build` | 从 `BuilderOptions` 构建校验规则 | `(props: BuilderOptions) => ValidatorRule` | + +### 类型 + +`BuilderOptions`: + +| 名称 | 说明 | 类型 | +| ------------ | ---------------------------- | --------------------------------------- | +| `allowed` | 允许字符的规则 | `RuleRegExpFlags` | +| `startsWith` | 开头字符的规则 | `Omit<RuleRegExpFlags, 'min' \| 'max'>` | +| `flags` | 正则表达式的标志(如 `'i'`) | `string` | + +`RuleRegExpFlags`: + +| 名称 | 说明 | 类型 | +| -------------------- | -------------------- | ---------- | +| `letter` | 大小写拉丁字符 | `boolean` | +| `lowerLetter` | 小写英文字符 | `boolean` | +| `upperLetter` | 大写英文字符 | `boolean` | +| `chineseCharacter` | 中文字符 | `boolean` | +| `chinesePunctuation` | 中文(全角)标点符号 | `boolean` | +| `number` | 数字 | `boolean` | +| `hyphen` | 连字符 `-` | `boolean` | +| `underscore` | 下划线 `_` | `boolean` | +| `special` | 指定的特殊字符 | `string[]` | +| `min` | 最小字符数量 | `number` | +| `max` | 最大字符数量 | `number` | + +`ValidatorRule`: + +| 成员 | 说明 | 类型 | +| ------------------- | -------------------- | ----------------------- | +| `pattern` | 校验正则表达式 | `RegExp` | +| `message` | 校验失败时的提示信息 | `string` | +| `allowedOptions` | 允许字符的规则设置 | `RuleRegExpFlags` | +| `startsWithOptions` | 开头字符的规则设置 | `StartsWithRegExpFlags` | +| `flags` | 正则表达式的标志 | `string` | diff --git a/.storybook/stories/hooks/useValidatorBuilder/index.stories.tsx b/.storybook/stories/hooks/useValidatorBuilder/index.stories.tsx new file mode 100644 index 0000000..a292f0e --- /dev/null +++ b/.storybook/stories/hooks/useValidatorBuilder/index.stories.tsx @@ -0,0 +1,157 @@ +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { Alert, Card, Input, Space, Tag, Typography } from 'antd'; +import type { RuleRegExpFlags } from '../../../../src/hooks/useValidatorBuilder'; +import useValidatorBuilder from '../../../../src/hooks/useValidatorBuilder'; +import storyI18n, { storyT, useStoryT } from '../../../locales'; +import apiDocEN from './api-doc.en-US.md?raw'; +import apiDocCN from './api-doc.zh-CN.md?raw'; +import introduceEN from './introduce.en-US.md?raw'; +import introduceCN from './introduce.zh-CN.md?raw'; + +interface UseValidatorBuilderStoryArgs { + letter: boolean; + number: boolean; + underscore: boolean; + hyphen: boolean; + chineseCharacter: boolean; + max: number; + testValue: string; +} + +const meta: Meta<UseValidatorBuilderStoryArgs> = { + title: 'Hooks/useValidatorBuilder', + parameters: { + docs: { + description: { + component: storyI18n.language === 'zh-CN' ? `${introduceCN}\n${apiDocCN}` : `${introduceEN}\n${apiDocEN}`, + }, + }, + }, + args: { + letter: true, + number: true, + underscore: false, + hyphen: false, + chineseCharacter: false, + max: 20, + testValue: 'abc123', + }, + argTypes: { + letter: { + control: 'boolean', + description: storyT('storybook.stories.useValidatorBuilder.argTypes.letter.description'), + }, + number: { + control: 'boolean', + description: storyT('storybook.stories.useValidatorBuilder.argTypes.number.description'), + }, + underscore: { + control: 'boolean', + description: storyT('storybook.stories.useValidatorBuilder.argTypes.underscore.description'), + }, + hyphen: { + control: 'boolean', + description: storyT('storybook.stories.useValidatorBuilder.argTypes.hyphen.description'), + }, + chineseCharacter: { + control: 'boolean', + description: storyT('storybook.stories.useValidatorBuilder.argTypes.chineseCharacter.description'), + }, + max: { + control: { type: 'number' }, + description: storyT('storybook.stories.useValidatorBuilder.argTypes.max.description'), + }, + testValue: { + control: 'text', + description: storyT('storybook.stories.useValidatorBuilder.argTypes.testValue.description'), + }, + }, +}; + +export default meta; +type Story = StoryObj<UseValidatorBuilderStoryArgs>; + +export const Playground: Story = { + parameters: { + docs: { + description: { + story: + '- **EN:** Toggle allowed character flags and a max length, then test a value against the generated rule. The resulting regex pattern is shown inline.\\n- **CN:** 切换允许的字符标志与最大长度,然后用生成规则测试一个值。生成的正则表达式会内联展示。', + }, + }, + }, + render: function Render(args: UseValidatorBuilderStoryArgs) { + return <UseValidatorBuilderStoryDemo {...args} />; + }, +}; + +function UseValidatorBuilderStoryDemo({ + letter, + number, + underscore, + hyphen, + chineseCharacter, + max, + testValue, +}: UseValidatorBuilderStoryArgs) { + const t = useStoryT(); + const build = useValidatorBuilder(); + const [draft, setDraft] = useState(''); + + const allowed: RuleRegExpFlags = { + letter, + number, + underscore, + hyphen, + chineseCharacter, + max: max || undefined, + }; + + const rule = build({ allowed }); + const passed = rule.pattern.test(testValue || draft); + const patternText = rule.pattern.toString(); + + return ( + <Card variant="outlined" style={{ maxWidth: 920 }} title={t('storybook.stories.useValidatorBuilder.cardTitle')}> + <Space orientation="vertical" size="large" style={{ width: '100%' }}> + <Typography.Paragraph style={{ marginBottom: 0 }}> + {t('storybook.stories.useValidatorBuilder.description')} + </Typography.Paragraph> + + <Space wrap> + <Input + style={{ maxWidth: 300 }} + placeholder="abc123" + value={draft} + onChange={(e) => setDraft(e.target.value)} + /> + <Typography.Text strong> + {t('storybook.stories.useValidatorBuilder.maxLabel')} {max} + </Typography.Text> + </Space> + + <Space wrap> + <Tag color={letter ? 'green' : 'default'}>a-zA-Z</Tag> + <Tag color={number ? 'green' : 'default'}>0-9</Tag> + <Tag color={underscore ? 'green' : 'default'}>_</Tag> + <Tag color={hyphen ? 'green' : 'default'}>-</Tag> + <Tag color={chineseCharacter ? 'green' : 'default'}>汉字</Tag> + </Space> + + <Space wrap> + <Typography.Text strong>{t('storybook.stories.useValidatorBuilder.resultLabel')}</Typography.Text> + <Tag color={passed ? 'green' : 'red'}> + {passed ? t('storybook.stories.useValidatorBuilder.pass') : t('storybook.stories.useValidatorBuilder.fail')} + </Tag> + <Typography.Text strong>{t('storybook.stories.useValidatorBuilder.patternLabel')}</Typography.Text> + <Typography.Text code copyable> + {patternText} + </Typography.Text> + </Space> + + <Alert type="info" message={t('storybook.stories.useValidatorBuilder.tip')} showIcon /> + </Space> + </Card> + ); +} diff --git a/.storybook/stories/hooks/useValidatorBuilder/introduce.en-US.md b/.storybook/stories/hooks/useValidatorBuilder/introduce.en-US.md new file mode 100644 index 0000000..c1c4002 --- /dev/null +++ b/.storybook/stories/hooks/useValidatorBuilder/introduce.en-US.md @@ -0,0 +1,21 @@ +Get a **validation rule builder** that constructs Ant-Design-compatible regex rules from declarative character flags. It composes an allowed-character class, optional `startsWith` constraints, and regex `flags` into a `ValidatorRule` with a human-readable, localized failure message. + +## When to use + +- Building custom validation rules without writing regexes by hand. +- Reusing a rule-building function across a component (memoized). +- Constructing rules with fine-grained control over allowed/special/min/max characters. + +## Key features + +- **Declarative flags** — compose booleans (`letter`, `number`, `chineseCharacter`, `hyphen`, `underscore`, `special`, …) into a character class. +- **startsWith support** — constrain the first character separately via `RuleRegExpFlags`. +- **Localized messages** — error messages are assembled from i18n tokens describing each allowed set. +- **antd-compatible** — the result is a `{ pattern, message, allowedOptions, startsWithOptions, flags }` rule. + +## Usage notes + +- At least one allowed flag must be `true`; otherwise it throws a localized error. +- `min` / `max` set length bounds; when `startsWith` is present the length limits are reduced by one for the leading char. +- The builder function is memoized and stable across renders. +- `special` takes an array of allowed special characters (e.g. `['@', '#']`). diff --git a/.storybook/stories/hooks/useValidatorBuilder/introduce.zh-CN.md b/.storybook/stories/hooks/useValidatorBuilder/introduce.zh-CN.md new file mode 100644 index 0000000..dd84cbb --- /dev/null +++ b/.storybook/stories/hooks/useValidatorBuilder/introduce.zh-CN.md @@ -0,0 +1,21 @@ +获取一个**校验规则生成器**,根据声明式的字符标志构建与 Ant Design 兼容的正则校验规则。它将允许字符类、可选的 `startsWith` 约束以及正则 `flags` 组合成一个带可读本地化错误消息的 `ValidatorRule`。 + +## 适用场景 + +- 无需手写正则即可构建自定义校验规则。 +- 在组件内复用规则构建函数(已记忆化)。 +- 需要对允许/特殊/最小/最大字符做精细控制时构建规则。 + +## 核心特性 + +- **声明式标志** —— 用布尔值(`letter`、`number`、`chineseCharacter`、`hyphen`、`underscore`、`special` 等)组合字符类。 +- **startsWith 支持** —— 通过 `RuleRegExpFlags` 单独约束首字符。 +- **本地化消息** —— 错误消息由描述每个允许集合的 i18n token 组装。 +- **antd 兼容** —— 结果是 `{ pattern, message, allowedOptions, startsWithOptions, flags }` 规则。 + +## 使用注意 + +- 至少一个允许标志必须为 `true`;否则抛出本地化错误。 +- `min` / `max` 设置长度边界;当存在 `startsWith` 时,长度限制会为前导字符减一。 +- 构建函数已记忆化,渲染间保持稳定。 +- `special` 接收允许的特殊字符数组(如 `['@', '#']`)。 diff --git a/.storybook/stories/hooks/useValidators/api-doc.en-US.md b/.storybook/stories/hooks/useValidators/api-doc.en-US.md new file mode 100644 index 0000000..6f7bfec --- /dev/null +++ b/.storybook/stories/hooks/useValidators/api-doc.en-US.md @@ -0,0 +1,36 @@ +## API + +### Params + +No parameters. + +### Return — ValidatorRuleMap + +| Member | Description | Type | +| ------------------- | --------------------------------------------------------------------- | --------------------------------- | +| `number` | Number | `Validator` | +| `floatNumber` | Floating point number | `Validator` | +| `email` | Email address (supports Chinese names) | `Validator` | +| `ip` | IP address | `Validator` | +| `cnMobile` | Chinese mobile phone number | `Validator` | +| `password` | 8–16 chars, at least two of numbers/letters/symbols | `Validator` | +| `code` | Letters, numbers, `_`; starts with a letter | `ValidatorRule` | +| `codeMax20` | Same as `code`, up to 20 chars | `ValidatorRule` | +| `codeMax64` | Same as `code`, up to 64 chars | `ValidatorRule` | +| `codeMax128` | Same as `code`, up to 128 chars | `ValidatorRule` | +| `codeWithMax` | Same as `code` with custom max length | `(max?: number) => ValidatorRule` | +| `name` | Letters, Chinese chars, numbers, `-`, `_` | `ValidatorRule` | +| `nameMax20` | Same as `name`, up to 20 chars | `ValidatorRule` | +| `nameMax64` | Same as `name`, up to 64 chars | `ValidatorRule` | +| `nameMax128` | Same as `name`, up to 128 chars | `ValidatorRule` | +| `nameWithMax` | Same as `name` with custom max length | `(max?: number) => ValidatorRule` | +| `strongName` | Letters, Chinese chars, numbers, `-`, `_`; starts with Chinese/letter | `ValidatorRule` | +| `strongNameMax64` | Same as `strongName`, up to 64 chars | `ValidatorRule` | +| `strongNameMax128` | Same as `strongName`, up to 128 chars | `ValidatorRule` | +| `strongNameWithMax` | Same as `strongName` with custom max length | `(max?: number) => ValidatorRule` | + +### Types + +`Validator = { pattern: RegExp; message: string }` + +`ValidatorRule extends Validator { allowedOptions: RuleRegExpFlags; startsWithOptions?: StartsWithRegExpFlags; flags?: string }` diff --git a/.storybook/stories/hooks/useValidators/api-doc.zh-CN.md b/.storybook/stories/hooks/useValidators/api-doc.zh-CN.md new file mode 100644 index 0000000..1e1cdfd --- /dev/null +++ b/.storybook/stories/hooks/useValidators/api-doc.zh-CN.md @@ -0,0 +1,36 @@ +## API + +### 参数 + +无参数。 + +### 返回值 —— ValidatorRuleMap + +| 成员 | 说明 | 类型 | +| ------------------- | -------------------------------------------- | --------------------------------- | +| `number` | 数字 | `Validator` | +| `floatNumber` | 浮点数 | `Validator` | +| `email` | 邮箱正则表达式(支持中文名称) | `Validator` | +| `ip` | IP 地址正则表达式 | `Validator` | +| `cnMobile` | 中国手机号正则表达式 | `Validator` | +| `password` | 8–16 位,数字、字母、符号至少包含两种 | `Validator` | +| `code` | 字母、数字、`_`;以字母开头 | `ValidatorRule` | +| `codeMax20` | 同 `code`,最多 20 字符 | `ValidatorRule` | +| `codeMax64` | 同 `code`,最多 64 字符 | `ValidatorRule` | +| `codeMax128` | 同 `code`,最多 128 字符 | `ValidatorRule` | +| `codeWithMax` | 同 `code`,可自定义最大字符数 | `(max?: number) => ValidatorRule` | +| `name` | 字母、汉字、数字、`-`、`_` | `ValidatorRule` | +| `nameMax20` | 同 `name`,最多 20 字符 | `ValidatorRule` | +| `nameMax64` | 同 `name`,最多 64 字符 | `ValidatorRule` | +| `nameMax128` | 同 `name`,最多 128 字符 | `ValidatorRule` | +| `nameWithMax` | 同 `name`,可自定义最大字符数 | `(max?: number) => ValidatorRule` | +| `strongName` | 字母、汉字、数字、`-`、`_`;以汉字或字母开头 | `ValidatorRule` | +| `strongNameMax64` | 同 `strongName`,最多 64 字符 | `ValidatorRule` | +| `strongNameMax128` | 同 `strongName`,最多 128 字符 | `ValidatorRule` | +| `strongNameWithMax` | 同 `strongName`,可自定义最大字符数 | `(max?: number) => ValidatorRule` | + +### 类型 + +`Validator = { pattern: RegExp; message: string }` + +`ValidatorRule extends Validator { allowedOptions: RuleRegExpFlags; startsWithOptions?: StartsWithRegExpFlags; flags?: string }` diff --git a/.storybook/stories/hooks/useValidators/index.stories.tsx b/.storybook/stories/hooks/useValidators/index.stories.tsx new file mode 100644 index 0000000..5a8d789 --- /dev/null +++ b/.storybook/stories/hooks/useValidators/index.stories.tsx @@ -0,0 +1,148 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { Alert, Card, Input, Select, Space, Tag, Typography } from 'antd'; +import useValidators from '../../../../src/hooks/useValidators'; +import storyI18n, { storyT, useStoryT } from '../../../locales'; +import apiDocEN from './api-doc.en-US.md?raw'; +import apiDocCN from './api-doc.zh-CN.md?raw'; +import introduceEN from './introduce.en-US.md?raw'; +import introduceCN from './introduce.zh-CN.md?raw'; + +interface UseValidatorsStoryArgs { + rule: string; + value: string; +} + +const meta: Meta<UseValidatorsStoryArgs> = { + title: 'Hooks/useValidators', + parameters: { + docs: { + description: { + component: storyI18n.language === 'zh-CN' ? `${introduceCN}\n${apiDocCN}` : `${introduceEN}\n${apiDocEN}`, + }, + }, + }, + args: { + rule: 'email', + value: 'user@example.com', + }, + argTypes: { + rule: { + control: 'select', + options: [ + 'number', + 'floatNumber', + 'email', + 'ip', + 'cnMobile', + 'password', + 'code', + 'codeMax20', + 'codeMax64', + 'codeMax128', + 'name', + 'nameMax20', + 'nameMax64', + 'nameMax128', + 'strongName', + 'strongNameMax64', + 'strongNameMax128', + ], + description: storyT('storybook.stories.useValidators.argTypes.rule.description'), + }, + value: { + control: 'text', + description: storyT('storybook.stories.useValidators.argTypes.value.description'), + }, + }, +}; + +export default meta; +type Story = StoryObj<UseValidatorsStoryArgs>; + +export const Playground: Story = { + parameters: { + docs: { + description: { + story: + '- **EN:** Pick a built-in validation rule and type a test value. The demo shows the underlying regex pattern and whether the value passes.\\n- **CN:** 选择一个内置校验规则并输入测试值。示例展示底层的正则表达式以及值是否通过校验。', + }, + }, + }, + render: function Render(args: UseValidatorsStoryArgs) { + return <UseValidatorsStoryDemo {...args} />; + }, +}; + +function UseValidatorsStoryDemo({ rule, value }: UseValidatorsStoryArgs) { + const t = useStoryT(); + const validators = useValidators(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const selected = (validators as any)[rule] ?? validators.email; + + const passed = selected.pattern.test(value); + const patternText = selected.pattern.toString(); + + return ( + <Card variant="outlined" style={{ maxWidth: 920 }} title={t('storybook.stories.useValidators.cardTitle')}> + <Space orientation="vertical" size="large" style={{ width: '100%' }}> + <Typography.Paragraph style={{ marginBottom: 0 }}> + {t('storybook.stories.useValidators.description')} + </Typography.Paragraph> + + <Space wrap> + <Select + style={{ minWidth: 200 }} + value={rule} + options={[ + 'number', + 'floatNumber', + 'email', + 'ip', + 'cnMobile', + 'password', + 'code', + 'codeMax20', + 'codeMax64', + 'codeMax128', + 'name', + 'nameMax20', + 'nameMax64', + 'nameMax128', + 'strongName', + 'strongNameMax64', + 'strongNameMax128', + ].map((key) => ({ value: key, label: key }))} + onChange={(v) => { + // controlled by args + void v; + }} + /> + <Input + style={{ maxWidth: 300 }} + placeholder={t('storybook.stories.useValidators.valuePlaceholder')} + value={value} + onChange={(e) => { + void e; + }} + /> + </Space> + + <Space wrap> + <Typography.Text strong>{t('storybook.stories.useValidators.resultLabel')}</Typography.Text> + <Tag color={passed ? 'green' : 'red'}> + {passed ? t('storybook.stories.useValidators.pass') : t('storybook.stories.useValidators.fail')} + </Tag> + </Space> + + <Typography.Text strong>{t('storybook.stories.useValidators.patternLabel')}</Typography.Text> + <Typography.Text code copyable> + {patternText} + </Typography.Text> + <Typography.Text type="secondary">{selected.message}</Typography.Text> + + <Alert type="info" message={t('storybook.stories.useValidators.tip')} showIcon /> + </Space> + </Card> + ); +} diff --git a/.storybook/stories/hooks/useValidators/introduce.en-US.md b/.storybook/stories/hooks/useValidators/introduce.en-US.md new file mode 100644 index 0000000..22a6a0e --- /dev/null +++ b/.storybook/stories/hooks/useValidators/introduce.en-US.md @@ -0,0 +1,21 @@ +Get a map of **built-in validation rules** for common data formats. The rules are compatible with Ant Design form rules (`{ pattern, message }`), so they can be passed directly to `Form.Item` rules. + +## When to use + +- Validating common inputs: numbers, emails, IPs, Chinese mobile numbers, passwords, codes, and names. +- Building forms quickly without writing regexes by hand. +- Combining preset rules with custom `useValidator` rules for special cases. + +## Key features + +- **Ready to use** — each rule is an antd-compatible `{ pattern, message }` object. +- **Common formats** — `number`, `floatNumber`, `email`, `ip`, `cnMobile`, `password`, plus `code` / `name` / `strongName` families. +- **Length variants** — `codeMax20/64/128` and `nameMax20/64/128` presets; `*WithMax` functions for custom limits. +- **i18n messages** — failure messages are localized through the library's translation system. + +## Usage notes + +- The map is memoized; all rules are stable across renders. +- `password` requires 8–16 characters with at least two of numbers, letters, and symbols. +- `cnMobile` only covers Chinese mobile numbers; use `useValidator` for other formats. +- `code` rules allow letters, numbers, and `_`, starting with a letter. diff --git a/.storybook/stories/hooks/useValidators/introduce.zh-CN.md b/.storybook/stories/hooks/useValidators/introduce.zh-CN.md new file mode 100644 index 0000000..56d3c50 --- /dev/null +++ b/.storybook/stories/hooks/useValidators/introduce.zh-CN.md @@ -0,0 +1,21 @@ +获取常见数据格式的**内置校验规则**映射。规则与 Ant Design 表单规则兼容(`{ pattern, message }`),可直接传给 `Form.Item` 的 `rules`。 + +## 适用场景 + +- 校验常见输入:数字、邮箱、IP、中国手机号、密码、编码与名称。 +- 快速构建表单,无需手写正则。 +- 将预设规则与 `useValidator` 自定义规则组合使用。 + +## 核心特性 + +- **开箱即用** —— 每条规则都是 antd 兼容的 `{ pattern, message }` 对象。 +- **常见格式** —— `number`、`floatNumber`、`email`、`ip`、`cnMobile`、`password`,以及 `code` / `name` / `strongName` 系列。 +- **长度变体** —— `codeMax20/64/128`、`nameMax20/64/128` 预设;`*WithMax` 函数支持自定义长度。 +- **i18n 消息** —— 校验失败提示通过库的翻译系统本地化。 + +## 使用注意 + +- 映射经过记忆化,所有规则在渲染间保持稳定。 +- `password` 要求 8–16 位,且数字、字母、符号至少包含两种。 +- `cnMobile` 仅覆盖中国手机号;其他格式请使用 `useValidator` 自定义。 +- `code` 系列规则允许字母、数字与 `_`,且以字母开头。 From 048e0fb86f9ecb17db56f83eddbc8b8c452f40a4 Mon Sep 17 00:00:00 2001 From: Fengbao Li <shijistar@gmail.com> Date: Wed, 5 Aug 2026 23:00:58 +0800 Subject: [PATCH 18/39] feat: enhance useMovable hook with viewport support and onMove callback - Updated useMovable hook to accept viewPortRef for container-bound movement. - Added onMove callback to notify position changes during dragging. - Improved API documentation for useMovable with clearer parameter descriptions. - Refactored useMovable implementation to handle viewport size and position clamping more effectively. - Enhanced Storybook examples for useMovable to demonstrate new features. - Updated various components to use title prop for Alert messages instead of message prop for consistency. - Improved documentation for several components and hooks to clarify default values and usage. --- .storybook/locales/langs/en-US.ts | 6 +- .storybook/locales/langs/zh-CN.ts | 6 +- .../hooks/useDebounce/index.stories.tsx | 2 +- .../hooks/useLocalStorage/index.stories.tsx | 2 +- .../stories/hooks/useMovable/api-doc.en-US.md | 21 +-- .../stories/hooks/useMovable/api-doc.zh-CN.md | 21 +-- .../hooks/useMovable/index.stories.tsx | 72 ++++---- .../hooks/useMovable/introduce.en-US.md | 2 +- .../hooks/useMovable/introduce.zh-CN.md | 2 +- .../hooks/useProcessingText/index.stories.tsx | 2 +- .../hooks/useRefFunction/index.stories.tsx | 2 +- .../hooks/useRefValue/index.stories.tsx | 3 +- .../stories/hooks/useSSE/index.stories.tsx | 2 +- .../hooks/useStompSocket/index.stories.tsx | 3 +- .../hooks/useUserMedia/index.stories.tsx | 4 +- .../hooks/useValidator/index.stories.tsx | 2 +- .../useValidatorBuilder/index.stories.tsx | 2 +- .../hooks/useValidators/index.stories.tsx | 2 +- src/components/Animation/Pulse/index.tsx | 30 ++- src/components/ConfirmAction/index.tsx | 8 +- src/components/ContextMenu/index.tsx | 6 +- src/components/FloatDrawer/index.tsx | 8 +- src/components/Lexical/nodes/SelectNode.tsx | 6 +- src/components/Lexical/nodes/base.ts | 30 ++- src/components/OverflowTags/index.tsx | 44 ++--- src/hooks/useDebounce.ts | 12 +- src/hooks/useMovable.ts | 174 +++++++++++++----- src/hooks/useProcessingText.ts | 30 ++- src/hooks/useSplitter.tsx | 13 +- 29 files changed, 323 insertions(+), 194 deletions(-) diff --git a/.storybook/locales/langs/en-US.ts b/.storybook/locales/langs/en-US.ts index dc3c304..5757107 100644 --- a/.storybook/locales/langs/en-US.ts +++ b/.storybook/locales/langs/en-US.ts @@ -404,10 +404,12 @@ const enUS = { 'storybook.stories.useLocalStorage.save': 'Save', 'storybook.stories.useLocalStorage.syncLabel': 'Sync across tabs', 'storybook.stories.useLocalStorage.tip': - 'The value is written to localStorage on every update. Toggle \"Sync\" to listen for the storage event across tabs.', + 'The value is written to localStorage on every update. Toggle "Sync" to listen for the storage event across tabs.', 'storybook.stories.useLocalStorage.valueLabel': 'Current value', 'storybook.stories.useMovable.argTypes.enabled.description': 'Whether dragging is enabled.', 'storybook.stories.useMovable.argTypes.persist.description': 'Persist the final position to localStorage.', + 'storybook.stories.useMovable.argTypes.positionType.description': + "Choose how the element's position is applied: `absolute` for CSS `top/left`, `transform` for CSS `transform: translate`.", 'storybook.stories.useMovable.cardTitle': 'Draggable element', 'storybook.stories.useMovable.description': 'Make an element movable by dragging, with optional position persistence in localStorage.', @@ -421,7 +423,7 @@ const enUS = { 'storybook.stories.useProcessingText.argTypes.enabled.description': 'Whether the animation is running.', 'storybook.stories.useProcessingText.argTypes.maxDots.description': 'Maximum number of dots.', 'storybook.stories.useProcessingText.cardTitle': 'Animated processing text', - 'storybook.stories.useProcessingText.description': 'Create an animated \"processing\" text that cycles through dots.', + 'storybook.stories.useProcessingText.description': 'Create an animated "processing" text that cycles through dots.', 'storybook.stories.useProcessingText.intervalLabel': 'Interval (ms)', 'storybook.stories.useProcessingText.prefixLabel': 'Prefix', 'storybook.stories.useProcessingText.prefixPlaceholder': 'e.g. Processing', diff --git a/.storybook/locales/langs/zh-CN.ts b/.storybook/locales/langs/zh-CN.ts index 3881ceb..7dbfcd8 100644 --- a/.storybook/locales/langs/zh-CN.ts +++ b/.storybook/locales/langs/zh-CN.ts @@ -372,10 +372,12 @@ const zhCN = { 'storybook.stories.useLocalStorage.save': '保存', 'storybook.stories.useLocalStorage.syncLabel': '跨标签页同步', 'storybook.stories.useLocalStorage.tip': - '每次更新都会将值写入 localStorage。开启\"同步\"后会监听跨标签页的 storage 事件。', + '每次更新都会将值写入 localStorage。开启"同步"后会监听跨标签页的 storage 事件。', 'storybook.stories.useLocalStorage.valueLabel': '当前值', 'storybook.stories.useMovable.argTypes.enabled.description': '是否启用拖拽', 'storybook.stories.useMovable.argTypes.persist.description': '是否将最终位置持久化到 localStorage', + 'storybook.stories.useMovable.argTypes.positionType.description': + '选择元素位置的应用方式:`absolute` 使用 CSS `top/left`,`transform` 使用 CSS `transform: translate`。', 'storybook.stories.useMovable.cardTitle': '可拖拽元素', 'storybook.stories.useMovable.description': '通过拖动使元素可移动,并可选地将位置持久化到 localStorage。', 'storybook.stories.useMovable.dragHint': '拖动下方卡片可移动它。', @@ -387,7 +389,7 @@ const zhCN = { 'storybook.stories.useProcessingText.argTypes.enabled.description': '是否运行动画', 'storybook.stories.useProcessingText.argTypes.maxDots.description': '点的最大数量', 'storybook.stories.useProcessingText.cardTitle': '处理中动画文本', - 'storybook.stories.useProcessingText.description': '创建循环显示点的\"处理中\"动画文本。', + 'storybook.stories.useProcessingText.description': '创建循环显示点的"处理中"动画文本。', 'storybook.stories.useProcessingText.intervalLabel': '间隔(毫秒)', 'storybook.stories.useProcessingText.prefixLabel': '前缀', 'storybook.stories.useProcessingText.prefixPlaceholder': '例如:处理中', diff --git a/.storybook/stories/hooks/useDebounce/index.stories.tsx b/.storybook/stories/hooks/useDebounce/index.stories.tsx index 26c3e49..da13ca1 100644 --- a/.storybook/stories/hooks/useDebounce/index.stories.tsx +++ b/.storybook/stories/hooks/useDebounce/index.stories.tsx @@ -182,7 +182,7 @@ function UseDebounceStoryDemo({ wait, leading, maxWait }: UseDebounceStoryArgs) <Divider style={{ margin: '8px 0' }} /> <Alert type={isDisabled ? 'warning' : pendingHint ? 'info' : appliedQuery ? 'success' : undefined} - message={statusMessage} + title={statusMessage} description={ <Space orientation="vertical" size={0}> <Typography.Text> diff --git a/.storybook/stories/hooks/useLocalStorage/index.stories.tsx b/.storybook/stories/hooks/useLocalStorage/index.stories.tsx index db43a4e..395e164 100644 --- a/.storybook/stories/hooks/useLocalStorage/index.stories.tsx +++ b/.storybook/stories/hooks/useLocalStorage/index.stories.tsx @@ -85,7 +85,7 @@ function UseLocalStorageStoryDemo({ sync }: UseLocalStorageStoryArgs) { <Typography.Text code>{value || '—'}</Typography.Text> </Space> - <Alert type="info" message={t('storybook.stories.useLocalStorage.tip')} showIcon /> + <Alert type="info" title={t('storybook.stories.useLocalStorage.tip')} showIcon /> </Space> </Card> ); diff --git a/.storybook/stories/hooks/useMovable/api-doc.en-US.md b/.storybook/stories/hooks/useMovable/api-doc.en-US.md index 1e1778f..6432360 100644 --- a/.storybook/stories/hooks/useMovable/api-doc.en-US.md +++ b/.storybook/stories/hooks/useMovable/api-doc.en-US.md @@ -2,19 +2,14 @@ ### Props — UseMovableProps -| Name | Description | Type | (Default) | -| ----------------- | ---------------------------------------------------------------------------------- | ------------------------ | --------- | -| `enabled` | Whether dragging is enabled | `boolean` | `true` | -| `containerRef` | Ref of the container element | `RefObject<HTMLElement>` | - | -| `ignoreSelectors` | Selectors of elements that should not trigger dragging (e.g. interactive controls) | `string[]` | - | -| `storageKey` | Key for storing position in `localStorage`; omit to disable persistence | `string` | - | - -### Return - -| Member | Description | Signature | -| --------------- | ------------------------------------------------------- | ------------------------------------------------- | -| `onPointerDown` | Pointer down handler; spread onto the draggable element | `(e: React.PointerEvent<HTMLDivElement>) => void` | -| `position` | Current position `{ left, top }` in pixels | `MovePosition \| undefined` | +| Name | Description | Type | (Default) | +| ----------------- | ---------------------------------------------------------------------------------- | ---------------------------------- | --------- | +| `enabled` | Whether dragging is enabled | `boolean` | `true` | +| `containerRef` | Ref of the container element | `RefObject<HTMLElement>` | - | +| `viewPortRef` | Ref of the viewport element (only used when `inContainer` is `true`) | `RefObject<HTMLElement>` | - | +| `ignoreSelectors` | Selectors of elements that should not trigger dragging (e.g. interactive controls) | `string[]` | - | +| `storageKey` | Key for storing position in `localStorage`; omit to disable persistence | `string` | - | +| `onMove` | Callback function triggered during dragging, receives the current position | `(position: MovePosition) => void` | - | ### Types diff --git a/.storybook/stories/hooks/useMovable/api-doc.zh-CN.md b/.storybook/stories/hooks/useMovable/api-doc.zh-CN.md index 87f7378..9858718 100644 --- a/.storybook/stories/hooks/useMovable/api-doc.zh-CN.md +++ b/.storybook/stories/hooks/useMovable/api-doc.zh-CN.md @@ -2,19 +2,14 @@ ### 参数 —— UseMovableProps -| 参数 | 说明 | 类型 | 默认值 | -| ----------------- | ------------------------------------------------------ | ------------------------ | ------ | -| `enabled` | 是否启用拖动 | `boolean` | `true` | -| `containerRef` | 容器元素的 ref | `RefObject<HTMLElement>` | - | -| `ignoreSelectors` | 不应触发拖动的元素选择器(如交互控件) | `string[]` | - | -| `storageKey` | 用于在 `localStorage` 中存储位置的 key;省略则不持久化 | `string` | - | - -### 返回值 - -| 成员 | 说明 | 签名 | -| --------------- | -------------------------------- | ------------------------------------------------- | -| `onPointerDown` | 按下事件处理器;绑定到可拖拽元素 | `(e: React.PointerEvent<HTMLDivElement>) => void` | -| `position` | 当前位置 `{ left, top }`(像素) | `MovePosition \| undefined` | +| 参数 | 说明 | 类型 | 默认值 | +| ----------------- | ------------------------------------------------------ | ---------------------------------- | ------ | +| `enabled` | 是否启用拖动 | `boolean` | `true` | +| `containerRef` | 容器元素的 ref | `RefObject<HTMLElement>` | - | +| `viewPortRef` | 视口元素的 ref(仅在 `inContainer` 为 `true` 时使用) | `RefObject<HTMLElement>` | - | +| `ignoreSelectors` | 不应触发拖动的元素选择器(如交互控件) | `string[]` | - | +| `storageKey` | 用于在 `localStorage` 中存储位置的 key;省略则不持久化 | `string` | - | +| `onMove` | 拖动事件回调函数;在拖动时触发,接收当前位置 | `(position: MovePosition) => void` | - | ### 类型 diff --git a/.storybook/stories/hooks/useMovable/index.stories.tsx b/.storybook/stories/hooks/useMovable/index.stories.tsx index da55d53..ff12824 100644 --- a/.storybook/stories/hooks/useMovable/index.stories.tsx +++ b/.storybook/stories/hooks/useMovable/index.stories.tsx @@ -1,15 +1,14 @@ -import { useRef, useState } from 'react'; +import { useEffect, useRef } from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; -import { Alert, Card, Space, Switch, Typography } from 'antd'; -import useMovable from '../../../../src/hooks/useMovable'; +import { Alert, Card, Space, Typography } from 'antd'; +import useMovable, { type UseMovableProps } from '../../../../src/hooks/useMovable'; import storyI18n, { storyT, useStoryT } from '../../../locales'; import apiDocEN from './api-doc.en-US.md?raw'; import apiDocCN from './api-doc.zh-CN.md?raw'; import introduceEN from './introduce.en-US.md?raw'; import introduceCN from './introduce.zh-CN.md?raw'; -interface UseMovableStoryArgs { - enabled: boolean; +interface UseMovableStoryArgs extends Pick<UseMovableProps, 'enabled'> { persist: boolean; } @@ -24,15 +23,17 @@ const meta: Meta<UseMovableStoryArgs> = { }, args: { enabled: true, - persist: true, + persist: false, }, argTypes: { enabled: { control: 'boolean', + table: { defaultValue: { summary: 'true' } }, description: storyT('storybook.stories.useMovable.argTypes.enabled.description'), }, persist: { control: 'boolean', + table: { defaultValue: { summary: 'false' } }, description: storyT('storybook.stories.useMovable.argTypes.persist.description'), }, }, @@ -41,7 +42,7 @@ const meta: Meta<UseMovableStoryArgs> = { export default meta; type Story = StoryObj<UseMovableStoryArgs>; -export const Playground: Story = { +export const MovingWithinContainer: Story = { parameters: { docs: { description: { @@ -50,26 +51,43 @@ export const Playground: Story = { }, }, }, + render: function Render(args: UseMovableStoryArgs) { + return <UseMovableStoryDemo inContainer {...args} />; + }, +}; +export const MovingFreely: Story = { + parameters: { + docs: { + description: { + story: + '- **EN:** Drag the blue card freely around the screen. Toggle `enabled` to turn dragging on/off and `persist` to store the position in `localStorage`.\\n- **CN:** 自由拖动蓝色卡片在屏幕上移动。切换 `enabled` 开关控制拖动,切换 `persist` 将位置持久化到 `localStorage`。', + }, + }, + }, render: function Render(args: UseMovableStoryArgs) { return <UseMovableStoryDemo {...args} />; }, }; -function UseMovableStoryDemo({ enabled, persist }: UseMovableStoryArgs) { +function UseMovableStoryDemo({ enabled, persist, inContainer }: UseMovableStoryArgs & { inContainer?: boolean }) { const t = useStoryT(); - const containerRef = useRef<HTMLDivElement>(null); - const [position, setPosition] = useState<{ left: number; top: number } | undefined>(undefined); - - const { onPointerDown, position: hookPosition } = useMovable({ + const viewPortRef = useRef<HTMLDivElement>(null); + const movableDomRef = useRef<HTMLDivElement>(null); + useMovable({ enabled, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - containerRef: containerRef as any, + movableDomRef, + viewPortRef: inContainer ? viewPortRef : undefined, storageKey: persist ? 'storybook.useMovable.position' : undefined, ignoreSelectors: ['.ant-btn'], }); - const effectivePosition = hookPosition ?? position ?? { left: 0, top: 0 }; - void setPosition; + useEffect(() => { + if (movableDomRef.current && !inContainer) { + const rect = movableDomRef.current.getBoundingClientRect(); + movableDomRef.current.style.left = rect.left + 'px'; + movableDomRef.current.style.top = rect.top + 'px'; + } + }, [inContainer]); return ( <Card variant="outlined" style={{ maxWidth: 920 }} title={t('storybook.stories.useMovable.cardTitle')}> @@ -77,31 +95,23 @@ function UseMovableStoryDemo({ enabled, persist }: UseMovableStoryArgs) { <Typography.Paragraph style={{ marginBottom: 0 }}> {t('storybook.stories.useMovable.description')} </Typography.Paragraph> - - <Space wrap> - <Typography.Text strong>{t('storybook.stories.useMovable.enabledLabel')}</Typography.Text> - <Switch checked={enabled} disabled /> - <Typography.Text strong>{t('storybook.stories.useMovable.persistLabel')}</Typography.Text> - <Switch checked={persist} disabled /> - </Space> + {inContainer && <Alert type="info" title={t('storybook.stories.useMovable.tip')} showIcon />} <div - ref={containerRef} + ref={viewPortRef} style={{ position: 'relative', + overflow: 'hidden', height: 260, border: '1px dashed #d9d9d9', borderRadius: 8, - overflow: 'hidden', background: '#fafafa', }} > <div - onPointerDown={onPointerDown} + ref={movableDomRef} style={{ - position: 'absolute', - left: effectivePosition.left || 16, - top: effectivePosition.top || 16, + position: inContainer ? 'absolute' : 'fixed', width: 180, padding: '16px 20px', borderRadius: 8, @@ -124,11 +134,9 @@ function UseMovableStoryDemo({ enabled, persist }: UseMovableStoryArgs) { <Space wrap> <Typography.Text strong>{t('storybook.stories.useMovable.positionLabel')}</Typography.Text> <Typography.Text code> - {`left: ${effectivePosition.left || 16}, top: ${effectivePosition.top || 16}`} + {`left: ${movableDomRef.current?.style.left ?? 0}, top: ${movableDomRef.current?.style.top ?? 0}`} </Typography.Text> </Space> - - <Alert type="info" message={t('storybook.stories.useMovable.tip')} showIcon /> </Space> </Card> ); diff --git a/.storybook/stories/hooks/useMovable/introduce.en-US.md b/.storybook/stories/hooks/useMovable/introduce.en-US.md index 720f507..49a22c0 100644 --- a/.storybook/stories/hooks/useMovable/introduce.en-US.md +++ b/.storybook/stories/hooks/useMovable/introduce.en-US.md @@ -1,4 +1,4 @@ -Make an element **movable by dragging**, with optional position persistence in `localStorage`. The hook tracks the element's position during `pointermove`, clamps it to the visible area, and keeps it in bounds on window resize. +Make an element **movable by dragging**, with optional position persistence in `localStorage`. The hook tracks the element's position during `pointermove`. You can choose a free movement mode or restrict it to the visible area, keeping it in bounds on window resize. If you choose container-bound movement, make sure to add `position: relative` to the parent container. ## When to use diff --git a/.storybook/stories/hooks/useMovable/introduce.zh-CN.md b/.storybook/stories/hooks/useMovable/introduce.zh-CN.md index 4fc7437..380cac4 100644 --- a/.storybook/stories/hooks/useMovable/introduce.zh-CN.md +++ b/.storybook/stories/hooks/useMovable/introduce.zh-CN.md @@ -1,4 +1,4 @@ -通过拖动使元素**可移动**,并可选地将位置持久化到 `localStorage`。hook 在 `pointermove` 期间跟踪元素位置,将其限制在可见区域内,并在窗口尺寸变化时保持不越界。 +通过拖动使元素**可移动**,并可选地将位置持久化到 `localStorage`。hook 在 `pointermove` 期间跟踪元素位置。可以选择自由移动模式,或选择将其限制在可见区域内,并在窗口尺寸变化时保持不越界。如果选择容器内移动模式,需要给父容器添加 `position: relative` 样式。 ## 适用场景 diff --git a/.storybook/stories/hooks/useProcessingText/index.stories.tsx b/.storybook/stories/hooks/useProcessingText/index.stories.tsx index 523f4c7..5e3f368 100644 --- a/.storybook/stories/hooks/useProcessingText/index.stories.tsx +++ b/.storybook/stories/hooks/useProcessingText/index.stories.tsx @@ -90,7 +90,7 @@ function UseProcessingTextStoryDemo({ enabled, maxDots }: UseProcessingTextStory {text} </Typography.Title> - <Alert type="info" message={t('storybook.stories.useProcessingText.tip')} showIcon /> + <Alert type="info" title={t('storybook.stories.useProcessingText.tip')} showIcon /> </Space> </Card> ); diff --git a/.storybook/stories/hooks/useRefFunction/index.stories.tsx b/.storybook/stories/hooks/useRefFunction/index.stories.tsx index 82a029e..9df5e6f 100644 --- a/.storybook/stories/hooks/useRefFunction/index.stories.tsx +++ b/.storybook/stories/hooks/useRefFunction/index.stories.tsx @@ -99,7 +99,7 @@ function UseRefFunctionStoryDemo({ showIdentity }: UseRefFunctionStoryArgs) { )} </Space> - <Alert type="info" message={t('storybook.stories.useRefFunction.tip')} showIcon /> + <Alert type="info" title={t('storybook.stories.useRefFunction.tip')} showIcon /> </Space> </Card> ); diff --git a/.storybook/stories/hooks/useRefValue/index.stories.tsx b/.storybook/stories/hooks/useRefValue/index.stories.tsx index a6e76a0..4c20825 100644 --- a/.storybook/stories/hooks/useRefValue/index.stories.tsx +++ b/.storybook/stories/hooks/useRefValue/index.stories.tsx @@ -61,7 +61,6 @@ function UseRefValueStoryDemo({ showSync }: UseRefValueStoryArgs) { setCaptured(countRef.current); }, 500); return () => window.clearInterval(timer); - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return ( @@ -91,7 +90,7 @@ function UseRefValueStoryDemo({ showSync }: UseRefValueStoryArgs) { )} </Space> - <Alert type="info" message={t('storybook.stories.useRefValue.tip')} showIcon /> + <Alert type="info" title={t('storybook.stories.useRefValue.tip')} showIcon /> </Space> </Card> ); diff --git a/.storybook/stories/hooks/useSSE/index.stories.tsx b/.storybook/stories/hooks/useSSE/index.stories.tsx index 6757387..8bd1770 100644 --- a/.storybook/stories/hooks/useSSE/index.stories.tsx +++ b/.storybook/stories/hooks/useSSE/index.stories.tsx @@ -101,7 +101,7 @@ function UseSSEStoryDemo({ url }: UseSSEStoryArgs) { {t('storybook.stories.useSSE.description')} </Typography.Paragraph> - <Alert type="info" message={t('storybook.stories.useSSE.serverHint')} showIcon /> + <Alert type="info" title={t('storybook.stories.useSSE.serverHint')} showIcon /> <Space wrap style={{ width: '100%' }}> <Input diff --git a/.storybook/stories/hooks/useStompSocket/index.stories.tsx b/.storybook/stories/hooks/useStompSocket/index.stories.tsx index 5a25499..27b2365 100644 --- a/.storybook/stories/hooks/useStompSocket/index.stories.tsx +++ b/.storybook/stories/hooks/useStompSocket/index.stories.tsx @@ -112,7 +112,7 @@ function UseStompSocketStoryDemo({ url: initialUrl, sendEndpoint, subscribeEndpo {t('storybook.stories.useStompSocket.description')} </Typography.Paragraph> - <Alert type="info" message={t('storybook.stories.useStompSocket.serverHint')} showIcon /> + <Alert type="info" title={t('storybook.stories.useStompSocket.serverHint')} showIcon /> <Space wrap style={{ width: '100%' }}> <Input @@ -156,6 +156,7 @@ function UseStompSocketStoryDemo({ url: initialUrl, sendEndpoint, subscribeEndpo <Typography.Text type="secondary">{t('storybook.stories.useStompSocket.emptyLog')}</Typography.Text> ) : ( log.map((item, index) => ( + // eslint-disable-next-line react/no-array-index-key <Typography.Text key={`${item}-${index}`} code> {item} </Typography.Text> diff --git a/.storybook/stories/hooks/useUserMedia/index.stories.tsx b/.storybook/stories/hooks/useUserMedia/index.stories.tsx index 9c68785..a79ed29 100644 --- a/.storybook/stories/hooks/useUserMedia/index.stories.tsx +++ b/.storybook/stories/hooks/useUserMedia/index.stories.tsx @@ -99,8 +99,8 @@ function UseUserMediaStoryDemo({ audio }: UseUserMediaStoryArgs) { </Tag> </Space> - {error && <Alert type="error" message={error} showIcon />} - <Alert type="info" message={t('storybook.stories.useUserMedia.tip')} showIcon /> + {error && <Alert type="error" title={error} showIcon />} + <Alert type="info" title={t('storybook.stories.useUserMedia.tip')} showIcon /> </Space> </Card> ); diff --git a/.storybook/stories/hooks/useValidator/index.stories.tsx b/.storybook/stories/hooks/useValidator/index.stories.tsx index 6a144a8..f9943c4 100644 --- a/.storybook/stories/hooks/useValidator/index.stories.tsx +++ b/.storybook/stories/hooks/useValidator/index.stories.tsx @@ -135,7 +135,7 @@ function UseValidatorStoryDemo({ </Typography.Text> </Space> - <Alert type="info" message={t('storybook.stories.useValidator.tip')} showIcon /> + <Alert type="info" title={t('storybook.stories.useValidator.tip')} showIcon /> </Space> </Card> ); diff --git a/.storybook/stories/hooks/useValidatorBuilder/index.stories.tsx b/.storybook/stories/hooks/useValidatorBuilder/index.stories.tsx index a292f0e..03313d7 100644 --- a/.storybook/stories/hooks/useValidatorBuilder/index.stories.tsx +++ b/.storybook/stories/hooks/useValidatorBuilder/index.stories.tsx @@ -150,7 +150,7 @@ function UseValidatorBuilderStoryDemo({ </Typography.Text> </Space> - <Alert type="info" message={t('storybook.stories.useValidatorBuilder.tip')} showIcon /> + <Alert type="info" title={t('storybook.stories.useValidatorBuilder.tip')} showIcon /> </Space> </Card> ); diff --git a/.storybook/stories/hooks/useValidators/index.stories.tsx b/.storybook/stories/hooks/useValidators/index.stories.tsx index 5a8d789..7b997db 100644 --- a/.storybook/stories/hooks/useValidators/index.stories.tsx +++ b/.storybook/stories/hooks/useValidators/index.stories.tsx @@ -141,7 +141,7 @@ function UseValidatorsStoryDemo({ rule, value }: UseValidatorsStoryArgs) { </Typography.Text> <Typography.Text type="secondary">{selected.message}</Typography.Text> - <Alert type="info" message={t('storybook.stories.useValidators.tip')} showIcon /> + <Alert type="info" title={t('storybook.stories.useValidators.tip')} showIcon /> </Space> </Card> ); diff --git a/src/components/Animation/Pulse/index.tsx b/src/components/Animation/Pulse/index.tsx index b69f3c5..16ce103 100644 --- a/src/components/Animation/Pulse/index.tsx +++ b/src/components/Animation/Pulse/index.tsx @@ -26,14 +26,18 @@ export interface PulseAnimationProps { */ prefixCls?: string; /** - * - **EN:** Number of bars, default is `24` - * - **CN:** 心跳条数量,默认`24` + * - **EN:** Number of bars + * - **CN:** 心跳条数量 + * + * @default 24 */ bars?: number; /** - * - **EN:** Gap between bars, default is `4px` - * - **CN:** 心跳柱子间隙,默认`4px` + * - **EN:** Gap between bars + * - **CN:** 心跳柱子间隙 + * + * @default '4px' */ barGap?: CSSProperties['gap']; /** @@ -42,18 +46,24 @@ export interface PulseAnimationProps { */ barColor?: CSSProperties['backgroundColor']; /** - * - **EN:** Minimum height of the bar, default is `10%` - * - **CN:** 心跳柱子最小高度,默认`10%` + * - **EN:** Minimum height of the bar + * - **CN:** 心跳柱子最小高度 + * + * @default '10%' */ // barMinSize?: CSSProperties['height']; /** - * - **EN:** Maximum height of the bar, default is `90%` - * - **CN:** 心跳柱子最大高度,默认`90%` + * - **EN:** Maximum height of the bar + * - **CN:** 心跳柱子最大高度 + * + * @default '90%' */ // barMaxSize?: CSSProperties['height']; /** - * - **EN:** Animation duration in seconds, default is `1.6` seconds - * - **CN:** 动画持续时间,单位秒,默认`1.6`秒 + * - **EN:** Animation duration in seconds + * - **CN:** 动画持续时间,单位秒 + * + * @default 1.6 */ duration?: number; /** diff --git a/src/components/ConfirmAction/index.tsx b/src/components/ConfirmAction/index.tsx index 73bef18..8628d00 100644 --- a/src/components/ConfirmAction/index.tsx +++ b/src/components/ConfirmAction/index.tsx @@ -31,8 +31,10 @@ export type ConfirmActionProps<TriggerProp extends object, Event extends keyof T */ danger?: boolean; /** - * - **EN:** The color of confirm box title, default is `warning` - * - **CN:** 弹框标题颜色,默认`warning` + * - **EN:** The color of confirm box title + * - **CN:** 弹框标题颜色 + * + * @default 'warning' */ titleColor?: TextProps['type'] | 'primary'; /** @@ -43,6 +45,8 @@ export type ConfirmActionProps<TriggerProp extends object, Event extends keyof T /** * - **EN:** The color of confirm box title icon, default is the same as `titleColor` * - **CN:** 弹框标题图标颜色,默认与`titleColor`相同 + * + * @default 'warning' */ iconColor?: TextProps['type'] | 'primary'; /** diff --git a/src/components/ContextMenu/index.tsx b/src/components/ContextMenu/index.tsx index d71931f..ca4f4ee 100644 --- a/src/components/ContextMenu/index.tsx +++ b/src/components/ContextMenu/index.tsx @@ -16,8 +16,10 @@ export interface ContextMenuProps extends MenuProps { */ items: (ContextMenuItem | ContextMenuSeparator | ContextMenuSubmenu)[] | undefined; /** - * - **EN:** Trigger methods to show the menu, default is `['contextMenu']` - * - **CN:** 触发显示菜单的方式,默认是 `['contextMenu']` + * - **EN:** Trigger methods to show the menu + * - **CN:** 触发显示菜单的方式 + * + * @default ['contextMenu'] */ trigger?: ('click' | 'doubleClick' | 'hover' | 'contextMenu')[]; /** diff --git a/src/components/FloatDrawer/index.tsx b/src/components/FloatDrawer/index.tsx index e303fa6..059a7ba 100644 --- a/src/components/FloatDrawer/index.tsx +++ b/src/components/FloatDrawer/index.tsx @@ -63,8 +63,8 @@ export interface FloatDrawerProps { */ edgeOffset?: CSSProperties['width']; /** - * - **EN:** Whether to show the toggle button, default is `true`. - * - **CN:** 是否显示展开/收起按钮,默认`true`。 + * - **EN:** Whether to show the toggle button + * - **CN:** 是否显示展开/收起按钮 * * @default true */ @@ -77,8 +77,8 @@ export interface FloatDrawerProps { */ resizable?: boolean; /** - * - **EN:** Whether to destroy the drawer content when closed, default is `false`. - * - **CN:** 抽屉关闭时是否销毁内容,默认`false`。 + * - **EN:** Whether to destroy the drawer content when closed + * - **CN:** 抽屉关闭时是否销毁内容 * * @default false */ diff --git a/src/components/Lexical/nodes/SelectNode.tsx b/src/components/Lexical/nodes/SelectNode.tsx index d2c224b..00f4e61 100644 --- a/src/components/Lexical/nodes/SelectNode.tsx +++ b/src/components/Lexical/nodes/SelectNode.tsx @@ -26,8 +26,10 @@ export interface SelectNodeProps< */ textContentMode?: 'label' | 'value'; /** - * - EN: Add a space around textContent. Default `true`. - * - CN: 是否在 textContent 两边添加一个空格,默认 `true`。 + * - EN: Add a space around textContent + * - CN: 是否在 textContent 两边添加一个空格 + * + * @default true */ spaceAround?: boolean; /** diff --git a/src/components/Lexical/nodes/base.ts b/src/components/Lexical/nodes/base.ts index f6c9d33..df39204 100644 --- a/src/components/Lexical/nodes/base.ts +++ b/src/components/Lexical/nodes/base.ts @@ -7,13 +7,17 @@ import { DecoratorNode, ElementNode } from 'lexical'; */ export interface BaseNodeProps { /** - * - EN: Whether the node can be removed. Default `true`. - * - CN: 是否可被删除,默认 `true`。 + * - EN: Whether the node can be removed + * - CN: 是否可被删除 + * + * @default true */ canBeRemoved?: boolean; /** - * - EN: Whether the node can be replaced. Default `true`. - * - CN: 是否可被替换,默认 `true`。 + * - EN: Whether the node can be replaced + * - CN: 是否可被替换 + * + * @default true */ canBeReplaced?: boolean; } @@ -23,18 +27,24 @@ export interface BaseNodeProps { */ export interface BaseElementProps extends BaseNodeProps { /** - * - EN: Whether an element is allowed to be empty. Default `false`. - * - CN: 是否允许节点为空,默认 `false`。 + * - EN: Whether an element is allowed to be empty + * - CN: 是否允许节点为空 + * + * @default false */ canBeEmpty?: boolean; /** - * - EN: Allow inserting text at the start of the element. Default `true`. - * - CN: 是否允许在节点内的起始位置插入文本,默认 `true`。 + * - EN: Allow inserting text at the start of the element + * - CN: 是否允许在节点内的起始位置插入文本 + * + * @default true */ canInsertTextBefore?: boolean; /** - * - EN: Allow inserting text at the end of the element. Default `true`. - * - CN: 是否允许在节点内的结束位置插入文本,默认 `true`。 + * - EN: Allow inserting text at the end of the element + * - CN: 是否允许在节点内的结束位置插入文本 + * + * @default true */ canInsertTextAfter?: boolean; } diff --git a/src/components/OverflowTags/index.tsx b/src/components/OverflowTags/index.tsx index 097b66b..77ddab7 100644 --- a/src/components/OverflowTags/index.tsx +++ b/src/components/OverflowTags/index.tsx @@ -12,54 +12,50 @@ import useStyle from './style'; export interface OverflowTagsProps<T> extends Omit<OverflowProps<T>, 'renderItem'> { /** - * **CN**: 标签集合的数据 - * - * **EN**: Data collection of tags + * - **CN**: 标签集合的数据 + * - **EN**: Data collection of tags */ tags: T[] | undefined; /** - * **EN**: Function to get the tag name, default is `tag.label` or `tag.name` + * - **EN**: Function to get the tag name + * - **CN**: 获取标签名称的函数 * - * **CN**: 获取标签名称的函数,默认取`tag.label`或`tag.name` + * @default tag => tag.label ?? tag.name */ getTagName?: (tag: T) => ReactNode; /** - * **EN**: Function to get the unique identifier of the tag, default is `tag.value` or `tag.id` + * - **EN**: Function to get the unique identifier of the tag + * - **CN**: 获取标签唯一标识的函数 * - * **CN**: 获取标签唯一标识的函数,默认取`tag.value`或`tag.id` + * @default tag => tag.value ?? tag.id */ getTagKey?: (tag: T) => React.Key; /** - * **EN**: Custom tag rendering function - * - * **CN**: 标签渲染函数 + * - **EN**: Custom tag rendering function + * - **CN**: 标签渲染函数 */ renderTag?: OverflowProps<T>['renderItem']; /** - * **EN**: Custom properties for the tag component - * - * **CN**: 自定义标签的组件属性 + * - **EN**: Custom properties for the tag component + * - **CN**: 自定义标签的组件属性 */ tagProps?: TagProps | ((tag: T, options: { tags: T[] }) => TagProps); /** - * **EN**: When the number of tags exceeds the maximum display count, an ellipsis tag will be - * shown. This property is used to set the style of the ellipsis tag. - * - * **CN**: 当标签数量超过最大显示数量时,会显示省略号的标签,此属性用于设置省略号标签的样式 + * - **EN**: When the number of tags exceeds the maximum display count, an ellipsis tag will be + * shown. This property is used to set the style of the ellipsis tag. + * - **CN**: 当标签数量超过最大显示数量时,会显示省略号的标签,此属性用于设置省略号标签的样式 */ ellipsisTagProps?: TagProps | ((tag: T, options: { omittedItems: T[]; allTags: T[] }) => TagProps); /** - * **EN**: Custom properties for the dropdown component when tags are overflowed - * - * **CN**: 当标签溢出时,下拉菜单的自定义属性 + * - **EN**: Custom properties for the dropdown component when tags are overflowed + * - **CN**: 当标签溢出时,下拉菜单的自定义属性 */ ellipsisDropdownProps?: DropdownProps; /** - * **EN**: Whether to use random colors, default is `false`. Note that the tag object can also - * contain a `color` property to specify the color, and the latter takes precedence. - * - * **CN**: 是否使用随机颜色,默认`false`。注意,tag对象还可以包含`color`属性来指定颜色,而且后者优先级更高。 + * - **EN**: Whether to use random colors, default is `false`. Note that the tag object can also + * contain a `color` property to specify the color, and the latter takes precedence. + * - **CN**: 是否使用随机颜色,默认`false`。注意,tag对象还可以包含`color`属性来指定颜色,而且后者优先级更高。 * * @default false */ diff --git a/src/hooks/useDebounce.ts b/src/hooks/useDebounce.ts index 906b772..bd2bfa9 100644 --- a/src/hooks/useDebounce.ts +++ b/src/hooks/useDebounce.ts @@ -3,18 +3,24 @@ import useRefFunction from './useRefFunction'; export interface UseDebounceOptions { /** - * - **EN:** Whether to execute at the start of the wait period. Default is `false`. - * - **CN:** 是否在等待周期开始时执行,默认值为 `false` + * - **EN:** Whether to execute at the start of the wait period + * - **CN:** 是否在等待周期开始时执行 + * + * @default false */ leading?: boolean; /** * - **EN:** Regular debounce interval in milliseconds. Default is `0`, meaning no debounce. * - **CN:** 常规防抖间隔 (ms),默认值为 `0`, 表示不进行防抖 + * + * @default 0 */ wait?: number; /** * - **EN:** Maximum wait time in milliseconds. Default is `0`, meaning no maximum wait. * - **CN:** 最大等待时间 (ms),默认值为 `0`, 表示不限制最大等待时间 + * + * @default 0 */ maxWait?: number; } @@ -38,7 +44,7 @@ export interface UseDebounceOptions { function useDebounce<T extends (...args: any[]) => unknown>( fn: T, deps: React.DependencyList, - options: UseDebounceOptions = {} + options: UseDebounceOptions = {}, ): DebouncedFunc<T> { const { wait = 0, maxWait = 0, leading = false } = options; const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); diff --git a/src/hooks/useMovable.ts b/src/hooks/useMovable.ts index fabf66e..826d61e 100644 --- a/src/hooks/useMovable.ts +++ b/src/hooks/useMovable.ts @@ -1,19 +1,25 @@ -import { type RefObject, useEffect, useRef, useState } from 'react'; +import type { RefObject } from 'react'; +import { useEffect, useRef, useState } from 'react'; import useLocalStorage from './useLocalStorage'; import useRefFunction from './useRefFunction'; import useRefValue from './useRefValue'; -export interface UseMovableProps { +export type UseMovableProps = (ContainerRefType | MovableRefType) & { /** - * - **EN:** Whether dragging is enabled, default is `true` - * - **CN:** 是否启用拖动,默认`true` + * - **EN:** The ref of the parent viewport container of the movable element; defaults to `window` + * if not set. + * - **CN:** 移动元素的父级可视区域容器的ref,如果不设置,则默认为 `window` + * + * @default `window` */ - enabled?: boolean; + viewPortRef?: RefObject<HTMLElement | null>; /** - * - **EN:** The ref of the container element - * - **CN:** 容器元素的ref + * - **EN:** Whether dragging is enabled. + * - **CN:** 是否启用拖动。 + * + * @default true */ - containerRef: RefObject<HTMLElement>; + enabled?: boolean; /** * - **EN:** Selectors of elements that should not trigger dragging, e.g., interactive controls * - **CN:** 不应触发拖动的元素的选择器,例如交互控件 @@ -24,6 +30,27 @@ export interface UseMovableProps { * - **CN:** 用于在 localStorage 中存储位置的键;如果未提供,则不会保存位置 */ storageKey?: string; + /** + * - **EN:** Callback function triggered when the element is moved. + * - **CN:** 元素移动时触发的回调函数。 + */ + onMove?: (e: MovePosition) => void; +}; +interface ContainerRefType { + /** + * - **EN:** The ref of the movable element + * - **CN:** 可移动元素的ref + * + * @deprecated use `movableDomRef` instead + */ + containerRef: RefObject<HTMLElement | null>; +} +interface MovableRefType { + /** + * - **EN:** The ref of the movable element + * - **CN:** 可移动元素的ref + */ + movableDomRef: RefObject<HTMLElement | null>; } /** @@ -31,7 +58,15 @@ export interface UseMovableProps { * - **CN:** 通过拖动使元素可移动的钩子,并使用 localStorage 持久化位置 */ const useMovable = (props: UseMovableProps) => { - const { enabled, containerRef, ignoreSelectors, storageKey } = props; + const { + enabled, + containerRef, + movableDomRef = containerRef, + viewPortRef, + ignoreSelectors, + storageKey, + onMove, + } = props as UseMovableProps & ContainerRefType & MovableRefType; const storageKeyRef = useRefValue(storageKey); const [savedPosition, savePosition] = useLocalStorage<MovePosition>(storageKey ?? ''); @@ -40,24 +75,38 @@ const useMovable = (props: UseMovableProps) => { const positionRef = useRefValue(position); const draggingRef = useRef(false); const dragOffsetRef = useRef({ x: 0, y: 0 }); - const sizeRef = useRef({ w: 0, h: 0 }); - + const onMoveRef = useRefValue(onMove); + + const getViewPortSize = useRefFunction(() => { + if (viewPortRef?.current) { + const rect = viewPortRef.current?.getBoundingClientRect(); + return { + x: rect?.x ?? 0, + y: rect?.y ?? 0, + width: viewPortRef.current.clientWidth, + height: viewPortRef.current.clientHeight, + }; + } + return { + x: 0, + y: 0, + width: window.innerWidth, + height: window.innerHeight, + }; + }); // Drag start (exclude interactive controls) const handlePointerDown = useRefFunction((e: React.PointerEvent<HTMLDivElement>) => { const target = e.target as HTMLElement; // Set the selector for elements that do not trigger dragging if (ignoreSelectors && target.closest(ignoreSelectors.join(','))) return; - if (!containerRef.current) return; - const rect = containerRef.current.getBoundingClientRect(); - sizeRef.current = { w: rect.width, h: rect.height }; - const currentLeft = position?.left ?? rect.left; - const currentTop = position?.top ?? rect.top; - - dragOffsetRef.current = { x: e.clientX - currentLeft, y: e.clientY - currentTop }; + const viewPortSize = getViewPortSize(); + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + dragOffsetRef.current = { x: e.clientX - rect.left, y: e.clientY - rect.top }; draggingRef.current = true; + console.log(e.clientX, e.clientY, viewPortSize, rect, dragOffsetRef.current, e.currentTarget); try { - containerRef.current.setPointerCapture?.(e.pointerId); + (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId); } catch { // do nothing } @@ -71,64 +120,95 @@ const useMovable = (props: UseMovableProps) => { if (!pos) return; // Refresh the container size before each convergence to ensure accurate boundaries. - const rect = containerRef.current?.getBoundingClientRect(); - if (rect) { - sizeRef.current = { w: rect.width, h: rect.height }; - } - - const maxLeft = Math.max(0, window.innerWidth - sizeRef.current.w); - const maxTop = Math.max(0, window.innerHeight - sizeRef.current.h); + const viewPortSize = getViewPortSize(); + const rect = movableDomRef.current?.getBoundingClientRect(); + const maxLeft = Math.max(0, viewPortSize.width - (rect?.width ?? 0)); + const maxTop = Math.max(0, viewPortSize.height - (rect?.height ?? 0)); const clampedLeft = Math.min(Math.max(0, pos.left), maxLeft); const clampedTop = Math.min(Math.max(0, pos.top), maxTop); if (clampedLeft !== pos.left || clampedTop !== pos.top) { const next = { left: clampedLeft, top: clampedTop }; setPosition(next); + if (movableDomRef.current) { + movableDomRef.current.style.left = next.left + 'px'; + movableDomRef.current.style.top = next.top + 'px'; + } if (storageKeyRef.current) { savePositionRef.current(next); } + onMoveRef.current?.(next); } }; - + const resizeObserver = new ResizeObserver(clampToViewport); + if (viewPortRef?.current) { + resizeObserver.observe(viewPortRef.current); + } window.addEventListener('resize', clampToViewport); // Calibrate immediately after the first mount/position change. clampToViewport(); return () => { + resizeObserver.disconnect(); window.removeEventListener('resize', clampToViewport); }; - }, [containerRef]); - + }, [movableDomRef, viewPortRef]); + + const onPointerMove = useRefFunction((e: PointerEvent) => { + if (!draggingRef.current) return; + const viewPortSize = getViewPortSize(); + + const newLeft = e.clientX - viewPortSize.x - dragOffsetRef.current.x; + const newTop = e.clientY - viewPortSize.y - dragOffsetRef.current.y; + const elWidth = (e.target as HTMLElement).offsetWidth; + const elHeight = (e.target as HTMLElement).offsetHeight; + const boundLeft = Math.max(0, viewPortSize.width - elWidth); + const boundTop = Math.max(0, viewPortSize.height - elHeight); + const clampedLeft = Math.min(Math.max(0, newLeft), boundLeft); + const clampedTop = Math.min(Math.max(0, newTop), boundTop); + const pos = { left: clampedLeft, top: clampedTop }; + console.log( + { clientX: e.clientX, clientY: e.clientY }, + viewPortSize, + { elWidth, elHeight }, + { boundLeft, boundTop }, + dragOffsetRef.current, + { newLeft, newTop }, + pos, + e.target, + ); + + setPosition(pos); + if (movableDomRef.current) { + movableDomRef.current.style.left = pos.left + 'px'; + movableDomRef.current.style.top = pos.top + 'px'; + } + if (storageKeyRef.current) { + savePositionRef.current(pos); + } + onMoveRef.current?.(pos); + }); // Update position during dragging; restrict within the visible area. useEffect(() => { - const onMove = (e: PointerEvent) => { - if (!draggingRef.current) return; - const newLeft = e.clientX - dragOffsetRef.current.x; - const newTop = e.clientY - dragOffsetRef.current.y; - const maxLeft = Math.max(0, window.innerWidth - sizeRef.current.w); - const maxTop = Math.max(0, window.innerHeight - sizeRef.current.h); - const clampedLeft = Math.min(Math.max(0, newLeft), maxLeft); - const clampedTop = Math.min(Math.max(0, newTop), maxTop); - const pos = { left: clampedLeft, top: clampedTop }; - setPosition(pos); - if (storageKeyRef.current) { - savePositionRef.current(pos); - } - }; + const movableDom = movableDomRef.current; const onUp = () => { - if (draggingRef.current) draggingRef.current = false; + if (draggingRef.current) { + draggingRef.current = false; + } }; if (enabled) { - window.addEventListener('pointermove', onMove); + window.addEventListener('pointermove', onPointerMove); window.addEventListener('pointerup', onUp); window.addEventListener('pointercancel', onUp); + movableDom?.addEventListener('pointerdown', handlePointerDown as never); } return () => { - window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointermove', onPointerMove); window.removeEventListener('pointerup', onUp); window.removeEventListener('pointercancel', onUp); + movableDom?.removeEventListener('pointerdown', handlePointerDown as never); }; - }, [enabled]); + }, [enabled, movableDomRef]); return { onPointerDown: handlePointerDown, diff --git a/src/hooks/useProcessingText.ts b/src/hooks/useProcessingText.ts index ba86bfb..824770b 100644 --- a/src/hooks/useProcessingText.ts +++ b/src/hooks/useProcessingText.ts @@ -2,28 +2,38 @@ import { useEffect, useRef, useState } from 'react'; export interface UseProcessingTextProps { /** - * - **EN:** Whether the animation is enabled, default is `true` - * - **CN:** 是否启用,默认`true` + * - **EN:** Whether the animation is enabled + * - **CN:** 是否启用 + * + * @default true */ enabled?: boolean; /** - * - **EN:** Prefix text (e.g., "Processing"), default is `""` - * - **CN:** 前缀文本(例如 "处理中"),默认`""` + * - **EN:** Prefix text (e.g., "Processing") + * - **CN:** 前缀文本(例如 "处理中") + * + * @default '' */ prefixText?: string; /** - * - **EN:** Dot text (e.g., "."), default is `"."` - * - **CN:** 点文本(例如 "."),默认`"."` + * - **EN:** Dot text (e.g., ".") + * - **CN:** 点文本(例如 ".") + * + * @default '.' */ dotText?: string; /** - * - **EN:** Animation interval (milliseconds), default is `300` - * - **CN:** 动画间隔(毫秒),默认`300` + * - **EN:** Animation interval (milliseconds) + * - **CN:** 动画间隔(毫秒) + * + * @default 300 */ interval?: number; /** - * - **EN:** Maximum number of dots, default is `3` - * - **CN:** 最大点数,默认`3` + * - **EN:** Maximum number of dots + * - **CN:** 最大点数 + * + * @default 3 */ maxDots?: number; } diff --git a/src/hooks/useSplitter.tsx b/src/hooks/useSplitter.tsx index 7988b2b..ef64d37 100644 --- a/src/hooks/useSplitter.tsx +++ b/src/hooks/useSplitter.tsx @@ -199,9 +199,16 @@ const useSplitter = (props: UseSplitterProps) => { [`--splitter-width` as any]: splitterWidth ? `${splitterWidth}px` : undefined, ...style, }} - onMouseDown={() => setDragging(true)} - onMouseEnter={() => setIsOver(true)} - onMouseLeave={() => setIsOver(false)} + onPointerDown={(e) => { + try { + splitterRef?.setPointerCapture?.(e.pointerId); + } catch { + // do nothing + } + setDragging(true); + }} + onPointerEnter={() => setIsOver(true)} + onPointerLeave={() => setIsOver(false)} role="separator" aria-orientation={vertical ? 'vertical' : 'horizontal'} aria-label="Resize" From 24cdcfd4a32f894fba89728e85509105dea559a9 Mon Sep 17 00:00:00 2001 From: Fengbao Li <shijistar@gmail.com> Date: Thu, 6 Aug 2026 00:10:52 +0800 Subject: [PATCH 19/39] test(hooks): fix useMovable and useSplitter tests for viewport/enhanced APIs - useMovable: adapt tests to the new viewport-aware API (movableDomRef, viewPortRef, onMove callback, pointer capture fallbacks). Cover all branches including viewport rect fallbacks, clamp without dom ref, persistence + onMove on clamp, and pointermove when not dragging. - useSplitter: switch event simulation from mouse to pointer events (pointerDown/pointerEnter/pointerLeave) to match the new onPointerDown implementation; cover setPointerCapture failure branch. - Verified: vitest node+browser 537/537 pass, global coverage 100% (lines/stmts/funcs/branches), prettier + tsc + eslint clean. --- test/hooks/useMovable.test.tsx | 366 +++++++++++++++++++++++--------- test/hooks/useSplitter.test.tsx | 15 +- 2 files changed, 271 insertions(+), 110 deletions(-) diff --git a/test/hooks/useMovable.test.tsx b/test/hooks/useMovable.test.tsx index 1fc57bc..3daf454 100644 --- a/test/hooks/useMovable.test.tsx +++ b/test/hooks/useMovable.test.tsx @@ -3,7 +3,23 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import useMovable from '../../src/hooks/useMovable'; -function defineRect(element: HTMLDivElement, rect: { left: number; top: number; width: number; height: number }) { +// Global polyfills for jsdom (jsdom does not implement ResizeObserver). +if (typeof ResizeObserver === 'undefined') { + class ResizeObserverMock { + observe() { + // stub method + } + unobserve() { + // stub method + } + disconnect() { + // stub method + } + } + globalThis.ResizeObserver = ResizeObserverMock; +} + +function defineRect(element: HTMLElement, rect: { left: number; top: number; width: number; height: number }) { Object.defineProperty(element, 'getBoundingClientRect', { configurable: true, value: () => ({ @@ -17,80 +33,88 @@ function defineRect(element: HTMLDivElement, rect: { left: number; top: number; }); } +function createMovableElement(rect: { left: number; top: number; width: number; height: number }) { + const el = document.createElement('div'); + document.body.appendChild(el); + defineRect(el, rect); + Object.defineProperty(el, 'offsetWidth', { configurable: true, value: rect.width }); + Object.defineProperty(el, 'offsetHeight', { configurable: true, value: rect.height }); + return el; +} + +function dispatchPointerMove(clientX: number, clientY: number, target?: HTMLElement) { + const event = new PointerEvent('pointermove', { clientX, clientY }); + if (target) { + Object.defineProperty(event, 'target', { value: target }); + } + window.dispatchEvent(event); +} + +function pointerDownArgs( + el: HTMLElement, + overrides: { + target?: HTMLElement; + clientX?: number; + clientY?: number; + pointerId?: number; + preventDefault?: () => void; + } = {}, +) { + return { + target: overrides.target ?? el, + currentTarget: el, + clientX: overrides.clientX ?? 10, + clientY: overrides.clientY ?? 10, + pointerId: overrides.pointerId ?? 1, + preventDefault: + overrides.preventDefault ?? + (() => { + // default no-op; tests pass their own mock when they need to assert calls + }), + } as never; +} + afterEach(() => { document.body.innerHTML = ''; window.localStorage.clear(); }); describe('useMovable', () => { - it('ignores configured selectors and safely handles missing container refs', () => { - const containerRef = { current: null } as unknown as RefObject<HTMLElement>; - const { result } = renderHook(() => useMovable({ enabled: true, containerRef, ignoreSelectors: ['button'] })); - const button = document.createElement('button'); - const div = document.createElement('div'); - const preventDefault = vi.fn(); - - act(() => { - result.current.onPointerDown({ - target: button, - clientX: 10, - clientY: 10, - pointerId: 1, - preventDefault, - } as never); - result.current.onPointerDown({ - target: div, - clientX: 10, - clientY: 10, - pointerId: 1, - preventDefault, - } as never); - window.dispatchEvent(new Event('pointermove')); - }); - - expect(preventDefault).not.toHaveBeenCalled(); - expect(result.current.position).toBeUndefined(); - }); - - it('updates, clamps and persists position during dragging and resize', async () => { + it('moves and clamps within the viewport, persists position and triggers onMove', async () => { Object.defineProperty(window, 'innerWidth', { configurable: true, value: 300 }); Object.defineProperty(window, 'innerHeight', { configurable: true, value: 200 }); - const container = document.createElement('div'); - document.body.appendChild(container); - let rect = { left: 10, top: 20, width: 100, height: 50 }; - defineRect(container, rect); - container.setPointerCapture = vi.fn(() => { - throw new Error('capture failed'); - }); - - const containerRef = { current: container } as unknown as RefObject<HTMLElement>; - const { result } = renderHook(() => useMovable({ enabled: true, containerRef, storageKey: 'movable-pos' })); + const el = createMovableElement({ left: 0, top: 0, width: 100, height: 50 }); + el.setPointerCapture = vi.fn(); + const movableDomRef = { current: el } as unknown as RefObject<HTMLElement>; + const onMove = vi.fn(); + const { result } = renderHook(() => + useMovable({ enabled: true, movableDomRef, storageKey: 'movable-pos', onMove }), + ); const preventDefault = vi.fn(); act(() => { - result.current.onPointerDown({ - target: container, - clientX: 30, - clientY: 40, - pointerId: 7, - preventDefault, - } as never); + result.current.onPointerDown(pointerDownArgs(el, { clientX: 20, clientY: 20, pointerId: 7, preventDefault })); }); expect(preventDefault).toHaveBeenCalledTimes(1); + expect(el.setPointerCapture).toHaveBeenCalledWith(7); act(() => { - window.dispatchEvent(new PointerEvent('pointermove', { clientX: 220, clientY: 170 })); + // newLeft = 240 - 0 - 20 = 220 -> clamped to boundLeft = 300 - 100 = 200 + // newTop = 190 - 0 - 20 = 170 -> clamped to boundTop = 200 - 50 = 150 + dispatchPointerMove(240, 190, el); }); await waitFor(() => { expect(result.current.position).toEqual({ left: 200, top: 150 }); }); + expect(el.style.left).toBe('200px'); + expect(el.style.top).toBe('150px'); expect(window.localStorage.getItem('movable-pos')).toBe(JSON.stringify({ left: 200, top: 150 })); + expect(onMove).toHaveBeenLastCalledWith({ left: 200, top: 150 }); - rect = { left: 10, top: 20, width: 100, height: 50 }; - defineRect(container, rect); + // Shrink the window: position must be clamped back into the viewport. Object.defineProperty(window, 'innerWidth', { configurable: true, value: 120 }); Object.defineProperty(window, 'innerHeight', { configurable: true, value: 90 }); @@ -101,105 +125,239 @@ describe('useMovable', () => { await waitFor(() => { expect(result.current.position).toEqual({ left: 20, top: 40 }); }); + expect(el.style.left).toBe('20px'); + expect(el.style.top).toBe('40px'); expect(window.localStorage.getItem('movable-pos')).toBe(JSON.stringify({ left: 20, top: 40 })); + expect(onMove).toHaveBeenLastCalledWith({ left: 20, top: 40 }); + + // A second resize while the position already fits must be a no-op. + act(() => { + window.dispatchEvent(new Event('resize')); + }); + expect(result.current.position).toEqual({ left: 20, top: 40 }); + // pointerup stops dragging; further pointermove events are ignored. act(() => { window.dispatchEvent(new PointerEvent('pointerup')); - window.dispatchEvent(new PointerEvent('pointermove', { clientX: 300, clientY: 300 })); + dispatchPointerMove(300, 300, el); window.dispatchEvent(new PointerEvent('pointercancel')); }); expect(result.current.position).toEqual({ left: 20, top: 40 }); + expect(onMove).toHaveBeenCalledTimes(2); + }); + + it('clamps a saved position on mount when it exceeds the viewport', async () => { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 120 }); + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 90 }); + window.localStorage.setItem('movable-saved', JSON.stringify({ left: 300, top: 200 })); + + const el = createMovableElement({ left: 0, top: 0, width: 100, height: 50 }); + const movableDomRef = { current: el } as unknown as RefObject<HTMLElement>; + const onMove = vi.fn(); + const { result } = renderHook(() => + useMovable({ enabled: true, movableDomRef, storageKey: 'movable-saved', onMove }), + ); + + await waitFor(() => { + expect(result.current.position).toEqual({ left: 20, top: 40 }); + }); + expect(el.style.left).toBe('20px'); + expect(el.style.top).toBe('40px'); + expect(window.localStorage.getItem('movable-saved')).toBe(JSON.stringify({ left: 20, top: 40 })); + expect(onMove).toHaveBeenCalledWith({ left: 20, top: 40 }); }); it('does not attach move handlers when disabled and does not persist without storageKey', () => { Object.defineProperty(window, 'innerWidth', { configurable: true, value: 300 }); Object.defineProperty(window, 'innerHeight', { configurable: true, value: 200 }); - const container = document.createElement('div'); - document.body.appendChild(container); - defineRect(container, { left: 0, top: 0, width: 100, height: 50 }); - container.setPointerCapture = vi.fn(); - - const containerRef = { current: container } as unknown as RefObject<HTMLElement>; - const { result } = renderHook(() => useMovable({ enabled: false, containerRef })); + const el = createMovableElement({ left: 0, top: 0, width: 100, height: 50 }); + const movableDomRef = { current: el } as unknown as RefObject<HTMLElement>; + const { result } = renderHook(() => useMovable({ enabled: false, movableDomRef })); act(() => { - result.current.onPointerDown({ - target: container, - clientX: 10, - clientY: 10, - pointerId: 1, - preventDefault: vi.fn(), - } as never); - window.dispatchEvent(new PointerEvent('pointermove', { clientX: 150, clientY: 150 })); + result.current.onPointerDown(pointerDownArgs(el)); + dispatchPointerMove(150, 150, el); }); + // Even though onPointerDown is invoked manually, the window listeners are + // never attached when `enabled` is false, so position stays untouched. expect(result.current.position).toBeUndefined(); expect(window.localStorage.length).toBe(0); }); - it('clamps saved position immediately on mount when it exceeds the viewport', async () => { + it('ignores configured selectors and safely handles missing movable dom refs', async () => { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 300 }); + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 200 }); + + const movableDomRef = { current: null } as unknown as RefObject<HTMLElement>; + const { result } = renderHook(() => useMovable({ enabled: true, movableDomRef, ignoreSelectors: ['button'] })); + const button = document.createElement('button'); + const div = document.createElement('div'); + const preventDefault = vi.fn(); + + act(() => { + result.current.onPointerDown(pointerDownArgs(div, { target: button, preventDefault })); + }); + expect(preventDefault).not.toHaveBeenCalled(); + expect(result.current.position).toBeUndefined(); + + // A non-ignored target starts dragging even without a dom ref; the move + // handler updates state but skips style writes and persistence. + act(() => { + result.current.onPointerDown(pointerDownArgs(div, { preventDefault })); + dispatchPointerMove(220, 170, div); + }); + + expect(preventDefault).toHaveBeenCalledTimes(1); + // div has no defined offsetWidth/offsetHeight (jsdom returns 0), so bounds + // stay at the window size: newLeft = 220 - 0 - 10 = 210, newTop = 170 - 10 = 160. + expect(result.current.position).toEqual({ left: 210, top: 160 }); + expect(window.localStorage.length).toBe(0); + + // Shrink the window: the clamp runs with a position but without a dom ref + // or storageKey, exercising the fallback branches. Object.defineProperty(window, 'innerWidth', { configurable: true, value: 120 }); Object.defineProperty(window, 'innerHeight', { configurable: true, value: 90 }); - window.localStorage.setItem('movable-saved', JSON.stringify({ left: 300, top: 200 })); - - const container = document.createElement('div'); - document.body.appendChild(container); - defineRect(container, { left: 0, top: 0, width: 100, height: 50 }); - const containerRef = { current: container } as unknown as RefObject<HTMLElement>; - const { result } = renderHook(() => useMovable({ enabled: true, containerRef, storageKey: 'movable-saved' })); + act(() => { + window.dispatchEvent(new Event('resize')); + }); await waitFor(() => { - expect(result.current.position).toEqual({ left: 20, top: 40 }); + // rect?.width ?? 0 -> maxLeft = 120 - 0 = 120, maxTop = 90 - 0 = 90 + expect(result.current.position).toEqual({ left: 120, top: 90 }); }); - expect(window.localStorage.getItem('movable-saved')).toBe(JSON.stringify({ left: 20, top: 40 })); + expect(window.localStorage.length).toBe(0); }); - it('moves and clamps without persisting when storageKey is absent', async () => { - Object.defineProperty(window, 'innerWidth', { configurable: true, value: 250 }); - Object.defineProperty(window, 'innerHeight', { configurable: true, value: 180 }); + it('uses viewPortRef as the bounds container and observes it', async () => { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 999 }); + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 999 }); + + const observe = vi.fn(); + const disconnect = vi.fn(); + globalThis.ResizeObserver = class { + observe = observe; + unobserve = vi.fn(); + disconnect = disconnect; + } as unknown as typeof ResizeObserver; + + const viewPort = document.createElement('div'); + document.body.appendChild(viewPort); + defineRect(viewPort, { left: 50, top: 30, width: 200, height: 150 }); + Object.defineProperty(viewPort, 'clientWidth', { configurable: true, value: 200 }); + Object.defineProperty(viewPort, 'clientHeight', { configurable: true, value: 150 }); + const viewPortRef = { current: viewPort } as unknown as RefObject<HTMLElement>; - const container = document.createElement('div'); - document.body.appendChild(container); - defineRect(container, { left: 0, top: 0, width: 100, height: 50 }); - container.setPointerCapture = vi.fn(); - const containerRef = { current: container } as unknown as RefObject<HTMLElement>; + const el = createMovableElement({ left: 0, top: 0, width: 50, height: 30 }); + const movableDomRef = { current: el } as unknown as RefObject<HTMLElement>; - const { result } = renderHook(() => useMovable({ enabled: true, containerRef })); + const { result, unmount } = renderHook(() => useMovable({ enabled: true, movableDomRef, viewPortRef })); + expect(observe).toHaveBeenCalledWith(viewPort); act(() => { - result.current.onPointerDown({ - target: container, - clientX: 20, - clientY: 20, - pointerId: 9, - preventDefault: vi.fn(), - } as never); - window.dispatchEvent(new PointerEvent('pointermove', { clientX: 170, clientY: 120 })); + result.current.onPointerDown(pointerDownArgs(el, { clientX: 60, clientY: 50, pointerId: 3 })); + dispatchPointerMove(130, 100, el); }); - await waitFor(() => { - expect(result.current.position).toEqual({ left: 150, top: 100 }); + // newLeft = clientX - viewPort.x - offsetX = 130 - 50 - 60 = 20 + // newTop = clientY - viewPort.y - offsetY = 100 - 30 - 50 = 20 + expect(result.current.position).toEqual({ left: 20, top: 20 }); + expect(el.style.left).toBe('20px'); + expect(el.style.top).toBe('20px'); + + unmount(); + expect(disconnect).toHaveBeenCalled(); + }); + + it('tolerates pointer capture failure', () => { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 300 }); + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 200 }); + + const el = createMovableElement({ left: 0, top: 0, width: 100, height: 50 }); + el.setPointerCapture = vi.fn(() => { + throw new Error('capture failed'); }); + const movableDomRef = { current: el } as unknown as RefObject<HTMLElement>; + const { result } = renderHook(() => useMovable({ enabled: true, movableDomRef })); + const preventDefault = vi.fn(); - Object.defineProperty(window, 'innerWidth', { configurable: true, value: 120 }); - Object.defineProperty(window, 'innerHeight', { configurable: true, value: 90 }); + act(() => { + result.current.onPointerDown(pointerDownArgs(el, { preventDefault })); + dispatchPointerMove(220, 170, el); + }); + + expect(preventDefault).toHaveBeenCalledTimes(1); + expect(result.current.position).toEqual({ left: 200, top: 150 }); + }); + + it('ignores pointermove when not dragging', () => { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 300 }); + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 200 }); + + const el = createMovableElement({ left: 0, top: 0, width: 100, height: 50 }); + const movableDomRef = { current: el } as unknown as RefObject<HTMLElement>; + const { result } = renderHook(() => useMovable({ enabled: true, movableDomRef })); act(() => { - window.dispatchEvent(new Event('resize')); + dispatchPointerMove(220, 170, el); }); - await waitFor(() => { - expect(result.current.position).toEqual({ left: 20, top: 40 }); + expect(result.current.position).toBeUndefined(); + }); + + it('falls back when the viewport rect lacks x/y coordinates', () => { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 999 }); + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 999 }); + globalThis.ResizeObserver = class { + observe = vi.fn(); + unobserve = vi.fn(); + disconnect = vi.fn(); + } as unknown as typeof ResizeObserver; + + const viewPort = document.createElement('div'); + document.body.appendChild(viewPort); + // rect without x/y -> getViewPortSize must fall back to 0 for the offset. + Object.defineProperty(viewPort, 'getBoundingClientRect', { + configurable: true, + value: () => ({ left: 0, top: 0, width: 200, height: 150 }), }); - expect(window.localStorage.length).toBe(0); + Object.defineProperty(viewPort, 'clientWidth', { configurable: true, value: 200 }); + Object.defineProperty(viewPort, 'clientHeight', { configurable: true, value: 150 }); + const viewPortRef = { current: viewPort } as unknown as RefObject<HTMLElement>; + + const el = createMovableElement({ left: 0, top: 0, width: 50, height: 30 }); + const movableDomRef = { current: el } as unknown as RefObject<HTMLElement>; + + const { result } = renderHook(() => useMovable({ enabled: true, movableDomRef, viewPortRef })); act(() => { - window.dispatchEvent(new Event('resize')); + result.current.onPointerDown(pointerDownArgs(el, { clientX: 30, clientY: 30, pointerId: 4 })); + dispatchPointerMove(120, 100, el); }); - expect(result.current.position).toEqual({ left: 20, top: 40 }); + // viewPortSize = { x: 0, y: 0, width: 200, height: 150 } + // newLeft = 120 - 0 - 30 = 90, newTop = 100 - 0 - 30 = 70 (within bounds) + expect(result.current.position).toEqual({ left: 90, top: 70 }); + }); + + it('clamps without a dom ref, still persisting and calling onMove', async () => { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 120 }); + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 90 }); + window.localStorage.setItem('movable-noref', JSON.stringify({ left: 300, top: 200 })); + + const movableDomRef = { current: null } as unknown as RefObject<HTMLElement>; + const onMove = vi.fn(); + const { result } = renderHook(() => + useMovable({ enabled: true, movableDomRef, storageKey: 'movable-noref', onMove }), + ); + + await waitFor(() => { + expect(result.current.position).toEqual({ left: 120, top: 90 }); + }); + expect(window.localStorage.getItem('movable-noref')).toBe(JSON.stringify({ left: 120, top: 90 })); + expect(onMove).toHaveBeenCalledWith({ left: 120, top: 90 }); }); }); diff --git a/test/hooks/useSplitter.test.tsx b/test/hooks/useSplitter.test.tsx index ae80589..dcb68fb 100644 --- a/test/hooks/useSplitter.test.tsx +++ b/test/hooks/useSplitter.test.tsx @@ -17,7 +17,7 @@ vi.mock('../../src/hooks/style/useSplitter', () => ({ interface SplitterElementProps { style: CSSProperties & { '--splitter-width'?: string }; - onMouseDown: () => void; + onPointerDown: (e: { pointerId: number }) => void; } function createWrapper(value?: Partial<ReactEasyContextProps>) { @@ -100,11 +100,14 @@ describe('useSplitter', () => { fireEvent.mouseMove(window, { clientX: 10, clientY: 10 }); expect(screen.getByTestId('percent').textContent).toBe('0.25'); - fireEvent.mouseEnter(separator); + fireEvent.pointerEnter(separator); expect(separator.className).toContain('easy-splitter-hover'); expect(separator.className).toContain('hovered'); - fireEvent.mouseDown(separator); + separator.setPointerCapture = vi.fn(() => { + throw new Error('capture failed'); + }); + fireEvent.pointerDown(separator, { pointerId: 1 }); await waitFor(() => { expect(screen.getByTestId('dragging').textContent).toBe('true'); @@ -125,7 +128,7 @@ describe('useSplitter', () => { expect(screen.getByTestId('dragging').textContent).toBe('false'); }); - fireEvent.mouseLeave(separator); + fireEvent.pointerLeave(separator); expect(separator.className).not.toContain('easy-splitter-hover'); const handle = separator.querySelector('.easy-splitter-handle.handle-extra') as HTMLDivElement; expect(handle.style.color).toBe('red'); @@ -149,7 +152,7 @@ describe('useSplitter', () => { expect(separator.className).toContain('ctx-splitter-horizontal'); expect(separator.getAttribute('aria-orientation')).toBe('horizontal'); - fireEvent.mouseDown(separator); + fireEvent.pointerDown(separator, { pointerId: 2 }); fireEvent.mouseMove(window, { clientX: 0, clientY: 400 }); await waitFor(() => { @@ -213,7 +216,7 @@ describe('useSplitter', () => { expect(element.props.style['--splitter-width']).toBe('1px'); act(() => { - element.props.onMouseDown(); + element.props.onPointerDown({ pointerId: 1 }); }); act(() => { From 6608838fdbd9dc1478f72150fe3a8f2da967d4f2 Mon Sep 17 00:00:00 2001 From: Fengbao Li <shijistar@gmail.com> Date: Thu, 6 Aug 2026 09:16:28 +0800 Subject: [PATCH 20/39] docs(story): add Sample code section to all story introduce docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Insert a bilingual 'Sample code' / '示例代码' section after Key features and before Usage notes in all 66 introduce markdown files (33 stories x en-US/zh-CN). Each sample shows the most typical usage backed by real source API, kept concise (10-32 lines). --- .../components/BreakLines/introduce.en-US.md | 11 +++++++ .../components/BreakLines/introduce.zh-CN.md | 11 +++++++ .../ColumnSetting/introduce.en-US.md | 25 +++++++++++++++ .../ColumnSetting/introduce.zh-CN.md | 25 +++++++++++++++ .../ConfigProvider/introduce.en-US.md | 22 +++++++++++++ .../ConfigProvider/introduce.zh-CN.md | 22 +++++++++++++ .../ConfirmAction/introduce.en-US.md | 21 ++++++++++++ .../ConfirmAction/introduce.zh-CN.md | 21 ++++++++++++ .../components/ContextMenu/introduce.en-US.md | 21 ++++++++++++ .../components/ContextMenu/introduce.zh-CN.md | 21 ++++++++++++ .../DeleteConfirmAction/introduce.en-US.md | 18 +++++++++++ .../DeleteConfirmAction/introduce.zh-CN.md | 18 +++++++++++ .../EditableText/introduce.en-US.md | 17 ++++++++++ .../EditableText/introduce.zh-CN.md | 17 ++++++++++ .../EllipsisTypography/introduce.en-US.md | 19 +++++++++++ .../EllipsisTypography/introduce.zh-CN.md | 17 ++++++++++ .../components/FloatDrawer/introduce.en-US.md | 17 ++++++++++ .../components/FloatDrawer/introduce.zh-CN.md | 17 ++++++++++ .../FormItemControl/introduce.en-US.md | 19 +++++++++++ .../FormItemControl/introduce.zh-CN.md | 19 +++++++++++ .../components/Iconfont/introduce.en-US.md | 17 ++++++++++ .../components/Iconfont/introduce.zh-CN.md | 17 ++++++++++ .../components/Loading/introduce.en-US.md | 14 ++++++++ .../components/Loading/introduce.zh-CN.md | 14 ++++++++ .../components/ModalAction/introduce.en-US.md | 32 +++++++++++++++++++ .../components/ModalAction/introduce.zh-CN.md | 32 +++++++++++++++++++ .../OverflowTags/introduce.en-US.md | 17 ++++++++++ .../OverflowTags/introduce.zh-CN.md | 17 ++++++++++ .../PulseAnimation/introduce.en-US.md | 10 ++++++ .../PulseAnimation/introduce.zh-CN.md | 10 ++++++ .../VirtualTextViewer/introduce.en-US.md | 10 ++++++ .../VirtualTextViewer/introduce.zh-CN.md | 10 ++++++ .../hooks/useAudioPlayer/introduce.en-US.md | 19 +++++++++++ .../hooks/useAudioPlayer/introduce.zh-CN.md | 19 +++++++++++ .../hooks/useDebounce/introduce.en-US.md | 24 ++++++++++++++ .../hooks/useDebounce/introduce.zh-CN.md | 24 ++++++++++++++ .../hooks/useLocalStorage/introduce.en-US.md | 18 +++++++++++ .../hooks/useLocalStorage/introduce.zh-CN.md | 18 +++++++++++ .../hooks/useMovable/introduce.en-US.md | 30 +++++++++++++++++ .../hooks/useMovable/introduce.zh-CN.md | 30 +++++++++++++++++ .../useProcessingText/introduce.en-US.md | 17 ++++++++++ .../useProcessingText/introduce.zh-CN.md | 17 ++++++++++ .../hooks/useRefFunction/introduce.en-US.md | 20 ++++++++++++ .../hooks/useRefFunction/introduce.zh-CN.md | 20 ++++++++++++ .../hooks/useRefValue/introduce.en-US.md | 16 ++++++++++ .../hooks/useRefValue/introduce.zh-CN.md | 15 +++++++++ .../hooks/useRowSelection/introduce.en-US.md | 25 +++++++++++++++ .../hooks/useRowSelection/introduce.zh-CN.md | 25 +++++++++++++++ .../stories/hooks/useSSE/introduce.en-US.md | 25 +++++++++++++++ .../stories/hooks/useSSE/introduce.zh-CN.md | 25 +++++++++++++++ .../hooks/useSplitter/introduce.en-US.md | 23 +++++++++++++ .../hooks/useSplitter/introduce.zh-CN.md | 23 +++++++++++++ .../hooks/useStompSocket/introduce.en-US.md | 26 +++++++++++++++ .../hooks/useStompSocket/introduce.zh-CN.md | 26 +++++++++++++++ .../useStreamDownloader/introduce.en-US.md | 25 +++++++++++++++ .../useStreamDownloader/introduce.zh-CN.md | 25 +++++++++++++++ .../hooks/useUserMedia/introduce.en-US.md | 24 ++++++++++++++ .../hooks/useUserMedia/introduce.zh-CN.md | 24 ++++++++++++++ .../hooks/useValidator/introduce.en-US.md | 27 ++++++++++++++++ .../hooks/useValidator/introduce.zh-CN.md | 27 ++++++++++++++++ .../useValidatorBuilder/introduce.en-US.md | 24 ++++++++++++++ .../useValidatorBuilder/introduce.zh-CN.md | 24 ++++++++++++++ .../hooks/useValidators/introduce.en-US.md | 25 +++++++++++++++ .../hooks/useValidators/introduce.zh-CN.md | 25 +++++++++++++++ .../utils/StreamDownloader/introduce.en-US.md | 18 +++++++++++ .../utils/StreamDownloader/introduce.zh-CN.md | 18 +++++++++++ 66 files changed, 1349 insertions(+) diff --git a/.storybook/stories/components/BreakLines/introduce.en-US.md b/.storybook/stories/components/BreakLines/introduce.en-US.md index 3e16e1e..500b04a 100644 --- a/.storybook/stories/components/BreakLines/introduce.en-US.md +++ b/.storybook/stories/components/BreakLines/introduce.en-US.md @@ -11,6 +11,17 @@ Display backend-provided text that contains `\n` (or another EOL marker) and you - **Toggle at runtime** — the `enabled` prop turns conversion on/off without changing the source value. - **Custom EOL** — `EOL` lets you split on any delimiter (e.g. `\r\n`, `|`, a custom token), not just `\n`. +## Sample code + +```tsx +import { BreakLines } from '@tiny-codes/react-easy'; + +export function Demo() { + const text = 'Line 1\nLine 2\nLine 3'; + return <BreakLines value={text} tagName="div" />; +} +``` + ## Usage notes - When `tagName` is `false` (default), the result is a fragment with `<br />` between segments, so it can be placed inside running text without introducing an extra DOM node. `className` is ignored in this mode. diff --git a/.storybook/stories/components/BreakLines/introduce.zh-CN.md b/.storybook/stories/components/BreakLines/introduce.zh-CN.md index c09aa24..3f4140c 100644 --- a/.storybook/stories/components/BreakLines/introduce.zh-CN.md +++ b/.storybook/stories/components/BreakLines/introduce.zh-CN.md @@ -11,6 +11,17 @@ - **运行时可切换** —— `enabled` 属性可在不修改源数据的情况下开关换行转换。 - **自定义换行符** —— `EOL` 支持任意分隔符(如 `\r\n`、`|`、自定义标记),不局限于 `\n`。 +## 示例代码 + +```tsx +import { BreakLines } from '@tiny-codes/react-easy'; + +export function Demo() { + const text = '第一行\n第二行\n第三行'; + return <BreakLines value={text} tagName="div" />; +} +``` + ## 使用注意 - 当 `tagName` 为 `false`(默认)时,结果为片段且段间使用 `<br />`,可放入行内文本而不会额外产生 DOM 节点;此模式下 `className` 不生效。 diff --git a/.storybook/stories/components/ColumnSetting/introduce.en-US.md b/.storybook/stories/components/ColumnSetting/introduce.en-US.md index b4207a6..6a3fb1c 100644 --- a/.storybook/stories/components/ColumnSetting/introduce.en-US.md +++ b/.storybook/stories/components/ColumnSetting/introduce.en-US.md @@ -12,6 +12,31 @@ Use `ColumnSetting` wherever a table exposes column customization: data grids, a - **Composable triggers** — `triggerProps` / `dropdownProps` / `popupProps` / `checkAllProps` / `resetProps` customize the button, dropdown, popup, and action buttons. - **Inherits Ant Design** — column items follow `ColumnType`, so your existing column definitions drop in directly. +## Sample code + +```tsx +import { useState } from 'react'; +import { ColumnSetting, type ColumnSettingItem } from '@tiny-codes/react-easy'; +import { Table } from 'antd'; + +interface User { + id: number; + name: string; + role: string; +} + +export function Demo() { + const [columns, setColumns] = useState<ColumnSettingItem<User>[]>(() => buildColumns()); + + return ( + <> + <ColumnSetting columns={columns} onChange={setColumns} storageKey="user-columns" /> + <Table rowKey="id" dataSource={data} columns={columns} pagination={false} /> + </> + ); +} +``` + ## Usage notes - At least one column always stays visible (the last visible checkbox is disabled) to prevent hiding everything. diff --git a/.storybook/stories/components/ColumnSetting/introduce.zh-CN.md b/.storybook/stories/components/ColumnSetting/introduce.zh-CN.md index 97b4c43..0939a4b 100644 --- a/.storybook/stories/components/ColumnSetting/introduce.zh-CN.md +++ b/.storybook/stories/components/ColumnSetting/introduce.zh-CN.md @@ -12,6 +12,31 @@ - **可组合触发器** —— `triggerProps` / `dropdownProps` / `popupProps` / `checkAllProps` / `resetProps` 分别定制按钮、下拉、弹出层与操作按钮。 - **继承 Ant Design** —— 列项遵循 `ColumnType`,你现有的列定义可直接接入。 +## 示例代码 + +```tsx +import { useState } from 'react'; +import { ColumnSetting, type ColumnSettingItem } from '@tiny-codes/react-easy'; +import { Table } from 'antd'; + +interface User { + id: number; + name: string; + role: string; +} + +export function Demo() { + const [columns, setColumns] = useState<ColumnSettingItem<User>[]>(() => buildColumns()); + + return ( + <> + <ColumnSetting columns={columns} onChange={setColumns} storageKey="user-columns" /> + <Table rowKey="id" dataSource={data} columns={columns} pagination={false} /> + </> + ); +} +``` + ## 使用注意 - 至少保留一列可见(最后一个可见复选框会被禁用),以防止全部隐藏。 diff --git a/.storybook/stories/components/ConfigProvider/introduce.en-US.md b/.storybook/stories/components/ConfigProvider/introduce.en-US.md index 5a66994..91b229e 100644 --- a/.storybook/stories/components/ConfigProvider/introduce.en-US.md +++ b/.storybook/stories/components/ConfigProvider/introduce.en-US.md @@ -16,6 +16,28 @@ Use `ConfigProvider` at the root of your application (or around any subtree that - **Custom i18n** — pass `locales` to override an existing language pack or register a brand-new one. - **Prefix customization** — `prefixCls` retunes the generated CSS class namespace. +## Sample code + +```tsx +import { ConfigProvider } from '@tiny-codes/react-easy'; +import { useTranslation } from 'react-i18next'; + +function Root() { + const { t, i18n } = useTranslation(); + + return ( + <ConfigProvider + lang={i18n.language} + localize={t} + defaultConfirmTitle="common.confirm" + defaultConfirmContent="common.confirm.content" + > + <App /> + </ConfigProvider> + ); +} +``` + ## Usage notes - It must wrap the parts of the tree that should inherit the configuration; nesting multiple providers creates scoped overrides. diff --git a/.storybook/stories/components/ConfigProvider/introduce.zh-CN.md b/.storybook/stories/components/ConfigProvider/introduce.zh-CN.md index 567d3ec..1232a77 100644 --- a/.storybook/stories/components/ConfigProvider/introduce.zh-CN.md +++ b/.storybook/stories/components/ConfigProvider/introduce.zh-CN.md @@ -16,6 +16,28 @@ - **自定义国际化** —— 通过 `locales` 覆盖已有语言包或注册全新的语言。 - **前缀定制** —— `prefixCls` 可调整生成的 CSS 类命名空间。 +## 示例代码 + +```tsx +import { ConfigProvider } from '@tiny-codes/react-easy'; +import { useTranslation } from 'react-i18next'; + +function Root() { + const { t, i18n } = useTranslation(); + + return ( + <ConfigProvider + lang={i18n.language} + localize={t} + defaultConfirmTitle="common.confirm" + defaultConfirmContent="common.confirm.content" + > + <App /> + </ConfigProvider> + ); +} +``` + ## 使用注意 - 它必须包裹需要继承配置的那部分组件树;嵌套多个 Provider 会产生作用域覆盖。 diff --git a/.storybook/stories/components/ConfirmAction/introduce.en-US.md b/.storybook/stories/components/ConfirmAction/introduce.en-US.md index 679f360..e1c490d 100644 --- a/.storybook/stories/components/ConfirmAction/introduce.en-US.md +++ b/.storybook/stories/components/ConfirmAction/introduce.en-US.md @@ -13,6 +13,27 @@ Use `ConfirmAction` whenever a user action is destructive, irreversible, or wort - **Global defaults** — default title/content and other props can be supplied through `ConfigProvider`, so you don't repeat them on every instance. - **Inherits antd** — everything from antd's `ModalFuncProps` (title, content, okText, cancelText, okButtonProps, …) is available. +## Sample code + +```tsx +import { ConfirmAction } from '@tiny-codes/react-easy'; + +export function DangerZone() { + return ( + <ConfirmAction.Button + title="Are you sure?" + content="This action cannot be undone." + danger + onOk={async () => { + await api.remove(); + }} + > + Delete item + </ConfirmAction.Button> + ); +} +``` + ## Usage notes - `onBeforeOpen` returning (or rejecting with) a value prevents the dialog from opening; use it for permission or pre-condition checks. diff --git a/.storybook/stories/components/ConfirmAction/introduce.zh-CN.md b/.storybook/stories/components/ConfirmAction/introduce.zh-CN.md index 85f74a7..994b123 100644 --- a/.storybook/stories/components/ConfirmAction/introduce.zh-CN.md +++ b/.storybook/stories/components/ConfirmAction/introduce.zh-CN.md @@ -13,6 +13,27 @@ - **全局默认值** —— 默认标题/内容等可通过 `ConfigProvider` 统一注入,无需在每个实例重复。 - **继承 antd** —— antd 的 `ModalFuncProps`(title、content、okText、cancelText、okButtonProps 等)均可使用。 +## 示例代码 + +```tsx +import { ConfirmAction } from '@tiny-codes/react-easy'; + +export function DangerZone() { + return ( + <ConfirmAction.Button + title="确定执行?" + content="该操作无法撤销。" + danger + onOk={async () => { + await api.remove(); + }} + > + 删除项目 + </ConfirmAction.Button> + ); +} +``` + ## 使用注意 - `onBeforeOpen` 返回(或 reject)值会阻止对话框打开,可用于权限或前置条件校验。 diff --git a/.storybook/stories/components/ContextMenu/introduce.en-US.md b/.storybook/stories/components/ContextMenu/introduce.en-US.md index 9a2d1fa..85915c5 100644 --- a/.storybook/stories/components/ContextMenu/introduce.en-US.md +++ b/.storybook/stories/components/ContextMenu/introduce.en-US.md @@ -13,6 +13,27 @@ You need a right-click (or other trigger) menu on an element, with keyboard shor - **Imperative control** — use a ref to call `show(event, options?)` and open the menu at an arbitrary position, or `hideAll()` to close every open menu. - **Theming** — integrates with antd `ConfigProvider` for prefix/class customization via `prefixCls`. +## Sample code + +```tsx +import { ContextMenu, type ContextMenuItem } from '@tiny-codes/react-easy'; +import { Button } from 'antd'; + +const items: ContextMenuItem[] = [ + { key: 'copy', label: 'Copy' }, + { key: 'rename', label: 'Rename' }, + { key: 'delete', label: 'Delete', danger: true }, +]; + +export function Demo() { + return ( + <ContextMenu items={items}> + <Button>Right click me</Button> + </ContextMenu> + ); +} +``` + ## Usage notes - The menu is rendered through `react-contexify`; its `MenuProps` (except `renderItem`) are forwarded, so you can rely on that library's item model. diff --git a/.storybook/stories/components/ContextMenu/introduce.zh-CN.md b/.storybook/stories/components/ContextMenu/introduce.zh-CN.md index 01ae357..390c94d 100644 --- a/.storybook/stories/components/ContextMenu/introduce.zh-CN.md +++ b/.storybook/stories/components/ContextMenu/introduce.zh-CN.md @@ -13,6 +13,27 @@ - **命令式控制** —— 通过 ref 调用 `show(event, options?)` 在任意位置打开菜单,或 `hideAll()` 关闭所有已打开的菜单。 - **主题定制** —— 借助 antd `ConfigProvider`,可通过 `prefixCls` 自定义类名前缀。 +## 示例代码 + +```tsx +import { ContextMenu, type ContextMenuItem } from '@tiny-codes/react-easy'; +import { Button } from 'antd'; + +const items: ContextMenuItem[] = [ + { key: 'copy', label: '复制' }, + { key: 'rename', label: '重命名' }, + { key: 'delete', label: '删除', danger: true }, +]; + +export function Demo() { + return ( + <ContextMenu items={items}> + <Button>右键点击我</Button> + </ContextMenu> + ); +} +``` + ## 使用注意 - 菜单基于 `react-contexify` 渲染,其 `MenuProps`(除 `renderItem` 外)均被透传,可直接沿用该库的菜单项模型。 diff --git a/.storybook/stories/components/DeleteConfirmAction/introduce.en-US.md b/.storybook/stories/components/DeleteConfirmAction/introduce.en-US.md index 48ad417..0318d7e 100644 --- a/.storybook/stories/components/DeleteConfirmAction/introduce.en-US.md +++ b/.storybook/stories/components/DeleteConfirmAction/introduce.en-US.md @@ -11,6 +11,24 @@ Use `DeleteConfirmAction` (instead of plain `ConfirmAction`) for any destructive - **Convenience triggers** — `DeleteConfirmAction.Button` / `.Switch` / `.Link` are ready-made trigger variants. - **Global defaults** — default title/content come from `ConfigProvider`'s deletion settings. +## Sample code + +```tsx +import { DeleteConfirmAction } from '@tiny-codes/react-easy'; + +export function UserList() { + return ( + <DeleteConfirmAction.Button + onOk={async () => { + await api.deleteUser(id); + }} + > + Delete + </DeleteConfirmAction.Button> + ); +} +``` + ## Usage notes - Because it is just `ConfirmAction` with `confirmType: 'delete'`, you can override `titleColor` / `iconColor` / `okButtonProps.type` per instance if you need a non-default look. diff --git a/.storybook/stories/components/DeleteConfirmAction/introduce.zh-CN.md b/.storybook/stories/components/DeleteConfirmAction/introduce.zh-CN.md index 1445ef2..ae9c9b6 100644 --- a/.storybook/stories/components/DeleteConfirmAction/introduce.zh-CN.md +++ b/.storybook/stories/components/DeleteConfirmAction/introduce.zh-CN.md @@ -11,6 +11,24 @@ - **便捷触发器** —— `DeleteConfirmAction.Button` / `.Switch` / `.Link` 是开箱即用的触发器变体。 - **全局默认值** —— 默认标题/内容取自 `ConfigProvider` 的删除确认配置。 +## 示例代码 + +```tsx +import { DeleteConfirmAction } from '@tiny-codes/react-easy'; + +export function UserList() { + return ( + <DeleteConfirmAction.Button + onOk={async () => { + await api.deleteUser(id); + }} + > + 删除 + </DeleteConfirmAction.Button> + ); +} +``` + ## 使用注意 - 它本质就是 `confirmType: 'delete'` 的 `ConfirmAction`,如需非默认外观,可在实例上覆盖 `titleColor` / `iconColor` / `okButtonProps.type`。 diff --git a/.storybook/stories/components/EditableText/introduce.en-US.md b/.storybook/stories/components/EditableText/introduce.en-US.md index 4397b06..ca41994 100644 --- a/.storybook/stories/components/EditableText/introduce.en-US.md +++ b/.storybook/stories/components/EditableText/introduce.en-US.md @@ -12,6 +12,23 @@ Use `EditableText` when a piece of text should be both displayed and quickly edi - **Custom renderers** — `displayText`/`children` override the read-only presentation, while `renderView`/`renderEdit`/`renderInput` (from `EditableFormProps`) customize each phase. - **Block or inline** — the `block` prop chooses full-width block vs inline display in view/edit modes. +## Sample code + +```tsx +import { EditableText } from '@tiny-codes/react-easy'; + +export function Demo() { + return ( + <EditableText + value="Click to edit" + onOk={async (value) => { + await api.save({ name: value }); + }} + /> + ); +} +``` + ## Usage notes - `editable={false}` hides the edit button entirely; drive `editing` from outside for programmatic control. diff --git a/.storybook/stories/components/EditableText/introduce.zh-CN.md b/.storybook/stories/components/EditableText/introduce.zh-CN.md index 6cfd424..c0744ef 100644 --- a/.storybook/stories/components/EditableText/introduce.zh-CN.md +++ b/.storybook/stories/components/EditableText/introduce.zh-CN.md @@ -12,6 +12,23 @@ - **自定义渲染** —— `displayText`/`children` 覆盖只读展示,`renderView`/`renderEdit`/`renderInput`(来自 `EditableFormProps`)定制各阶段。 - **块级或行内** —— `block` 属性选择只读/编辑态下的整行块级还是行内展示。 +## 示例代码 + +```tsx +import { EditableText } from '@tiny-codes/react-easy'; + +export function Demo() { + return ( + <EditableText + value="点击编辑" + onOk={async (value) => { + await api.save({ name: value }); + }} + /> + ); +} +``` + ## 使用注意 - `editable={false}` 会完全隐藏编辑按钮;可通过外部控制 `editing` 实现编程式编辑。 diff --git a/.storybook/stories/components/EllipsisTypography/introduce.en-US.md b/.storybook/stories/components/EllipsisTypography/introduce.en-US.md index d6983f8..c01bb93 100644 --- a/.storybook/stories/components/EllipsisTypography/introduce.en-US.md +++ b/.storybook/stories/components/EllipsisTypography/introduce.en-US.md @@ -11,6 +11,25 @@ Use these components instead of plain Ant Design Typography whenever long text m - **Rich ellipsis config** — supports row count (`rows`), tooltip title/content, and all of Ant Design's `ellipsis` options. - **Drop-in** — accepts the same props as the underlying Ant Design typography component, so migration is usually a one-line swap. +## Sample code + +```tsx +import { EllipsisParagraph, EllipsisText } from '@tiny-codes/react-easy'; + +export function Demo() { + return ( + <> + <EllipsisText ellipsis={{ tooltip: true }} style={{ maxWidth: 240 }}> + A very long text that should be clamped with a tooltip… + </EllipsisText> + <EllipsisParagraph ellipsis={{ rows: 2, tooltip: true }}> + Multi-line paragraph clamped to two rows. + </EllipsisParagraph> + </> + ); +} +``` + ## Usage notes - The tooltip is suppressed automatically when content fits, so you don't need conditional logic. diff --git a/.storybook/stories/components/EllipsisTypography/introduce.zh-CN.md b/.storybook/stories/components/EllipsisTypography/introduce.zh-CN.md index ecf5080..b789582 100644 --- a/.storybook/stories/components/EllipsisTypography/introduce.zh-CN.md +++ b/.storybook/stories/components/EllipsisTypography/introduce.zh-CN.md @@ -11,6 +11,23 @@ - **丰富的省略配置** —— 支持行数(`rows`)、tooltip 标题/内容,以及 Ant Design 的全部 `ellipsis` 选项。 - **即插即用** —— 接受底层 Ant Design 排版组件的相同 props,迁移通常只需替换一行。 +## 示例代码 + +```tsx +import { EllipsisParagraph, EllipsisText } from '@tiny-codes/react-easy'; + +export function Demo() { + return ( + <> + <EllipsisText ellipsis={{ tooltip: true }} style={{ maxWidth: 240 }}> + 这是一段超长文本,超出后将被截断并显示 tooltip… + </EllipsisText> + <EllipsisParagraph ellipsis={{ rows: 2, tooltip: true }}>多行段落,最多展示两行。</EllipsisParagraph> + </> + ); +} +``` + ## 使用注意 - 内容未溢出时会自动抑制 tooltip,无需自行写条件逻辑。 diff --git a/.storybook/stories/components/FloatDrawer/introduce.en-US.md b/.storybook/stories/components/FloatDrawer/introduce.en-US.md index d1592b7..9004bfb 100644 --- a/.storybook/stories/components/FloatDrawer/introduce.en-US.md +++ b/.storybook/stories/components/FloatDrawer/introduce.en-US.md @@ -12,6 +12,23 @@ Use `FloatDrawer` for persistent side panels — filters, inspectors, tool palet - **Persistence** — `cacheKey` remembers the size in `localStorage`. - **Inherits antd Card** — the surface accepts `CardProps` for header/footer/extra. +## Sample code + +```tsx +import { useState } from 'react'; +import { FloatDrawer } from '@tiny-codes/react-easy'; + +export function Demo() { + const [open, setOpen] = useState(false); + + return ( + <FloatDrawer open={open} position="right" defaultSize={320} onOpenChange={setOpen}> + Drawer content + </FloatDrawer> + ); +} +``` + ## Usage notes - It is positioned relative to its parent, so the parent needs `position: relative` (or non-static) for correct placement. diff --git a/.storybook/stories/components/FloatDrawer/introduce.zh-CN.md b/.storybook/stories/components/FloatDrawer/introduce.zh-CN.md index d02abf9..38d0b93 100644 --- a/.storybook/stories/components/FloatDrawer/introduce.zh-CN.md +++ b/.storybook/stories/components/FloatDrawer/introduce.zh-CN.md @@ -12,6 +12,23 @@ - **持久化** —— `cacheKey` 在 `localStorage` 中记忆尺寸。 - **继承 antd Card** —— 面板表面接受 `CardProps`,可配置 header/footer/extra。 +## 示例代码 + +```tsx +import { useState } from 'react'; +import { FloatDrawer } from '@tiny-codes/react-easy'; + +export function Demo() { + const [open, setOpen] = useState(false); + + return ( + <FloatDrawer open={open} position="right" defaultSize={320} onOpenChange={setOpen}> + 抽屉内容 + </FloatDrawer> + ); +} +``` + ## 使用注意 - 它相对父容器定位,因此父容器需为 `position: relative`(或非 static)才能正确摆放。 diff --git a/.storybook/stories/components/FormItemControl/introduce.en-US.md b/.storybook/stories/components/FormItemControl/introduce.en-US.md index 16b674f..df6b46e 100644 --- a/.storybook/stories/components/FormItemControl/introduce.en-US.md +++ b/.storybook/stories/components/FormItemControl/introduce.en-US.md @@ -10,6 +10,25 @@ Use `FormItemControl` when a form field's UI is not covered by Ant Design's buil - **Form-native** — validation, `initialValues`, `getFieldsValue`, and submit all work because it is a real `Form.Item` child. - **Zero markup** — you supply only the control UI; the wrapper handles the form wiring. +## Sample code + +```tsx +import { FormItemControl } from '@tiny-codes/react-easy'; +import { Form, InputNumber } from 'antd'; + +export function Demo() { + return ( + <Form> + <Form.Item name="count" label="Count"> + <FormItemControl> + {({ value, onChange }) => <InputNumber value={value} onChange={onChange} min={0} />} + </FormItemControl> + </Form.Item> + </Form> + ); +} +``` + ## Usage notes - It must be placed directly inside `<Form.Item>`; nesting other elements between breaks the binding. diff --git a/.storybook/stories/components/FormItemControl/introduce.zh-CN.md b/.storybook/stories/components/FormItemControl/introduce.zh-CN.md index a3f6316..a5a3b91 100644 --- a/.storybook/stories/components/FormItemControl/introduce.zh-CN.md +++ b/.storybook/stories/components/FormItemControl/introduce.zh-CN.md @@ -10,6 +10,25 @@ - **表单原生** —— 校验、`initialValues`、`getFieldsValue`、提交全部可用,因为它本身就是真正的 `Form.Item` 子节点。 - **零样板** —— 你只需提供控件 UI,包装层负责表单接线。 +## 示例代码 + +```tsx +import { FormItemControl } from '@tiny-codes/react-easy'; +import { Form, InputNumber } from 'antd'; + +export function Demo() { + return ( + <Form> + <Form.Item name="count" label="数量"> + <FormItemControl> + {({ value, onChange }) => <InputNumber value={value} onChange={onChange} min={0} />} + </FormItemControl> + </Form.Item> + </Form> + ); +} +``` + ## 使用注意 - 它必须直接放在 `<Form.Item>` 内部;中间夹入其它元素会破坏绑定。 diff --git a/.storybook/stories/components/Iconfont/introduce.en-US.md b/.storybook/stories/components/Iconfont/introduce.en-US.md index 2909a85..9dff200 100644 --- a/.storybook/stories/components/Iconfont/introduce.en-US.md +++ b/.storybook/stories/components/Iconfont/introduce.en-US.md @@ -11,6 +11,23 @@ Use `Iconfont` when your project's icon set lives on iconfont.cn (or a self-host - **Size & color** — `size` (`control`) and `color` tune the glyph; `rotate` (`control`) sets a fixed angle. - **Prefix handling** — `iconPrefix` (demo helper) is prepended to `type` unless already present. +## Sample code + +```tsx +import { createIconfont } from '@tiny-codes/react-easy'; + +const IconFont = createIconfont('//at.alicdn.com/t/font_xxx.js'); + +export function Demo() { + return ( + <> + <IconFont type="icon-tuichu" size={32} color="#1677ff" /> + <IconFont type="icon-facebook" spin /> + </> + ); +} +``` + ## Usage notes - Load the script via `scriptUrl` before icons render; a wrong/missing URL yields blank glyphs. diff --git a/.storybook/stories/components/Iconfont/introduce.zh-CN.md b/.storybook/stories/components/Iconfont/introduce.zh-CN.md index f740faa..a586770 100644 --- a/.storybook/stories/components/Iconfont/introduce.zh-CN.md +++ b/.storybook/stories/components/Iconfont/introduce.zh-CN.md @@ -11,6 +11,23 @@ - **尺寸与颜色** —— `size`(`control`)与 `color` 调整字形;`rotate`(`control`)设定固定角度。 - **前缀处理** —— `iconPrefix`(演示辅助)会在 `type` 前自动拼接,除非 `type` 已包含该前缀。 +## 示例代码 + +```tsx +import { createIconfont } from '@tiny-codes/react-easy'; + +const IconFont = createIconfont('//at.alicdn.com/t/font_xxx.js'); + +export function Demo() { + return ( + <> + <IconFont type="icon-tuichu" size={32} color="#1677ff" /> + <IconFont type="icon-facebook" spin /> + </> + ); +} +``` + ## 使用注意 - 图标渲染前需通过 `scriptUrl` 加载脚本;URL 错误或缺失会导致字形空白。 diff --git a/.storybook/stories/components/Loading/introduce.en-US.md b/.storybook/stories/components/Loading/introduce.en-US.md index f5525b9..8f0a36e 100644 --- a/.storybook/stories/components/Loading/introduce.en-US.md +++ b/.storybook/stories/components/Loading/introduce.en-US.md @@ -12,6 +12,20 @@ Use `Loading` to signal an in-progress state: wrap a region with the overlay spi - **Inherits Spin** — extends Ant Design `SpinProps`, so `tip`, `size`, `indicator`, `spinning`, etc. all apply. - **Standalone extras** — `mode` (`absolute` | `flex`) controls standalone positioning; `rootClassName` / `rootStyle` style the mask container. +## Sample code + +```tsx +import { Loading } from '@tiny-codes/react-easy'; + +export function Demo() { + return ( + <div style={{ position: 'relative', height: 120 }}> + <Loading mode="absolute" /> + </div> + ); +} +``` + ## Usage notes - In Spin mode the children stay in the DOM and are merely covered; in standalone mode there are no children. diff --git a/.storybook/stories/components/Loading/introduce.zh-CN.md b/.storybook/stories/components/Loading/introduce.zh-CN.md index 9295552..b184c56 100644 --- a/.storybook/stories/components/Loading/introduce.zh-CN.md +++ b/.storybook/stories/components/Loading/introduce.zh-CN.md @@ -12,6 +12,20 @@ - **继承 Spin** —— 扩展 Ant Design `SpinProps`,因此 `tip`、`size`、`indicator`、`spinning` 等均可用。 - **独立模式增强** —— `mode`(`absolute` | `flex`)控制独立定位;`rootClassName` / `rootStyle` 设置遮罩容器样式。 +## 示例代码 + +```tsx +import { Loading } from '@tiny-codes/react-easy'; + +export function Demo() { + return ( + <div style={{ position: 'relative', height: 120 }}> + <Loading mode="absolute" /> + </div> + ); +} +``` + ## 使用注意 - Spin 模式下子内容仍保留在 DOM 中,只是被覆盖;独立模式下没有 children。 diff --git a/.storybook/stories/components/ModalAction/introduce.en-US.md b/.storybook/stories/components/ModalAction/introduce.en-US.md index d6988b0..490a7c9 100644 --- a/.storybook/stories/components/ModalAction/introduce.en-US.md +++ b/.storybook/stories/components/ModalAction/introduce.en-US.md @@ -12,6 +12,38 @@ Use `ModalAction` when the pre-action step needs structured input — creating o - **afterOk** — fires only after a successful save, for navigation/refresh. - **Inherits antd** — all `ModalProps` (title, width, okText, …) are available. +## Sample code + +```tsx +import { type FormCompPropsConstraint, withModalAction } from '@tiny-codes/react-easy'; +import { Form, Input } from 'antd'; + +type User = { name: string }; +type UserFormProps = { data?: User }; + +function UserForm(props: UserFormProps & FormCompPropsConstraint<User>) { + const { form, data, onSave } = props; + + onSave(async (values) => { + await api.save(values); + }); + + return ( + <Form form={form} initialValues={data}> + <Form.Item name="name" label="Name"> + <Input /> + </Form.Item> + </Form> + ); +} + +const UserModalAction = withModalAction(UserForm); + +export function Demo() { + return <UserModalAction>Create user</UserModalAction>; +} +``` + ## Usage notes - Do not render a `<Form>` inside `formComp`; the parent already provides the instance — use the injected `form` and register save via `onSave`. diff --git a/.storybook/stories/components/ModalAction/introduce.zh-CN.md b/.storybook/stories/components/ModalAction/introduce.zh-CN.md index a665953..ca6b0c4 100644 --- a/.storybook/stories/components/ModalAction/introduce.zh-CN.md +++ b/.storybook/stories/components/ModalAction/introduce.zh-CN.md @@ -12,6 +12,38 @@ - **afterOk** —— 仅在保存成功后触发,用于跳转/刷新。 - **继承 antd** —— 全部 `ModalProps`(title、width、okText 等)均可用。 +## 示例代码 + +```tsx +import { type FormCompPropsConstraint, withModalAction } from '@tiny-codes/react-easy'; +import { Form, Input } from 'antd'; + +type User = { name: string }; +type UserFormProps = { data?: User }; + +function UserForm(props: UserFormProps & FormCompPropsConstraint<User>) { + const { form, data, onSave } = props; + + onSave(async (values) => { + await api.save(values); + }); + + return ( + <Form form={form} initialValues={data}> + <Form.Item name="name" label="姓名"> + <Input /> + </Form.Item> + </Form> + ); +} + +const UserModalAction = withModalAction(UserForm); + +export function Demo() { + return <UserModalAction>创建用户</UserModalAction>; +} +``` + ## 使用注意 - 不要在 `formComp` 内部再渲染 `<Form>`;父组件已提供实例——请使用注入的 `form` 并通过 `onSave` 注册保存。 diff --git a/.storybook/stories/components/OverflowTags/introduce.en-US.md b/.storybook/stories/components/OverflowTags/introduce.en-US.md index 6ce418b..b218ec3 100644 --- a/.storybook/stories/components/OverflowTags/introduce.en-US.md +++ b/.storybook/stories/components/OverflowTags/introduce.en-US.md @@ -12,6 +12,23 @@ Use `OverflowTags` to show a variable number of tags (selected filters, assigned - **Colors** — `randomColors` assigns preset colors; per-tag `color` takes precedence. - **Inherits rc-overflow** — `OverflowProps` (except `renderItem`) are forwarded, so sizing/spacing follow that library. +## Sample code + +```tsx +import { OverflowTags } from '@tiny-codes/react-easy'; + +const tags = [ + { id: 1, label: 'React' }, + { id: 2, label: 'TypeScript' }, + { id: 3, label: 'Ant Design' }, + // … +]; + +export function Demo() { + return <OverflowTags tags={tags} getTagKey={(tag) => tag.id} renderTag={(tag) => tag.label} />; +} +``` + ## Usage notes - Provide a stable `getTagKey` so React reconciliation and the dropdown stay correct when the list changes. diff --git a/.storybook/stories/components/OverflowTags/introduce.zh-CN.md b/.storybook/stories/components/OverflowTags/introduce.zh-CN.md index 673aecb..7bc0af2 100644 --- a/.storybook/stories/components/OverflowTags/introduce.zh-CN.md +++ b/.storybook/stories/components/OverflowTags/introduce.zh-CN.md @@ -12,6 +12,23 @@ - **配色** —— `randomColors` 分配预设颜色;单个标签的 `color` 优先级更高。 - **继承 rc-overflow** —— `OverflowProps`(除 `renderItem` 外)均被透传,尺寸/间距遵循该库。 +## 示例代码 + +```tsx +import { OverflowTags } from '@tiny-codes/react-easy'; + +const tags = [ + { id: 1, label: 'React' }, + { id: 2, label: 'TypeScript' }, + { id: 3, label: 'Ant Design' }, + // … +]; + +export function Demo() { + return <OverflowTags tags={tags} getTagKey={(tag) => tag.id} renderTag={(tag) => tag.label} />; +} +``` + ## 使用注意 - 请提供稳定的 `getTagKey`,以便列表变化时 React 协调与下拉表现正确。 diff --git a/.storybook/stories/components/PulseAnimation/introduce.en-US.md b/.storybook/stories/components/PulseAnimation/introduce.en-US.md index 9b765d6..0906a70 100644 --- a/.storybook/stories/components/PulseAnimation/introduce.en-US.md +++ b/.storybook/stories/components/PulseAnimation/introduce.en-US.md @@ -12,6 +12,16 @@ Use `PulseAnimation` to hint "something is active" without text — audio record - **Themable** — `barStyle` and the `token.AnimationPulse` token tune the look globally. - **No deps** — pure CSS animation, cheap to mount many instances. +## Sample code + +```tsx +import { PulseAnimation } from '@tiny-codes/react-easy'; + +export function Demo() { + return <PulseAnimation bars={24} barGap={3} style={{ width: '100%', height: 40 }} />; +} +``` + ## Usage notes - `bars`, `barGap`, `duration`, `delayRate` all have sensible defaults, so a bare `<PulseAnimation />` already animates. diff --git a/.storybook/stories/components/PulseAnimation/introduce.zh-CN.md b/.storybook/stories/components/PulseAnimation/introduce.zh-CN.md index a71aea7..e85c564 100644 --- a/.storybook/stories/components/PulseAnimation/introduce.zh-CN.md +++ b/.storybook/stories/components/PulseAnimation/introduce.zh-CN.md @@ -12,6 +12,16 @@ - **主题化** —— `barStyle` 与 `token.AnimationPulse` token 可全局微调外观。 - **无依赖** —— 纯 CSS 动画,挂载大量实例也很廉价。 +## 示例代码 + +```tsx +import { PulseAnimation } from '@tiny-codes/react-easy'; + +export function Demo() { + return <PulseAnimation bars={24} barGap={3} style={{ width: '100%', height: 40 }} />; +} +``` + ## 使用注意 - `bars`、`barGap`、`duration`、`delayRate` 均有合理默认值,裸 `<PulseAnimation />` 即可动起来。 diff --git a/.storybook/stories/components/VirtualTextViewer/introduce.en-US.md b/.storybook/stories/components/VirtualTextViewer/introduce.en-US.md index 7016a79..bb28482 100644 --- a/.storybook/stories/components/VirtualTextViewer/introduce.en-US.md +++ b/.storybook/stories/components/VirtualTextViewer/introduce.en-US.md @@ -11,6 +11,16 @@ Use `VirtualTextViewer` to display huge read-only text — logs, source files, S - **Typography control** — `lineHeight`, `font`, `letterSpacing`, `tabSize`, `wordBreak` tune the rendering and stay in sync with the canvas metric. - **Per-row styling** — `lineClassName` / `lineStyle` / `contentClassName` / `contentStyle` let you theme the surface and individual rows. +## Sample code + +```tsx +import { VirtualTextViewer } from '@tiny-codes/react-easy'; + +export function Demo() { + return <VirtualTextViewer value={hugeLogText} height={480} lineHeight={22} font="12px monospace" />; +} +``` + ## Usage notes - Keep `font` in sync with the actual CSS font; a mismatch makes the canvas metric diverge from the painted text. diff --git a/.storybook/stories/components/VirtualTextViewer/introduce.zh-CN.md b/.storybook/stories/components/VirtualTextViewer/introduce.zh-CN.md index e3c5dbf..25385f9 100644 --- a/.storybook/stories/components/VirtualTextViewer/introduce.zh-CN.md +++ b/.storybook/stories/components/VirtualTextViewer/introduce.zh-CN.md @@ -11,6 +11,16 @@ - **排版可控** —— `lineHeight`、`font`、`letterSpacing`、`tabSize`、`wordBreak` 调整渲染,并与 canvas 度量保持同步。 - **逐行样式** —— `lineClassName` / `lineStyle` / `contentClassName` / `contentStyle` 可对整体与单行进行主题定制。 +## 示例代码 + +```tsx +import { VirtualTextViewer } from '@tiny-codes/react-easy'; + +export function Demo() { + return <VirtualTextViewer value={hugeLogText} height={480} lineHeight={22} font="12px monospace" />; +} +``` + ## 使用注意 - 请让 `font` 与实际 CSS 字体保持一致;不一致会导致 canvas 度量与绘制文本错位。 diff --git a/.storybook/stories/hooks/useAudioPlayer/introduce.en-US.md b/.storybook/stories/hooks/useAudioPlayer/introduce.en-US.md index aa4cc8a..51647bf 100644 --- a/.storybook/stories/hooks/useAudioPlayer/introduce.en-US.md +++ b/.storybook/stories/hooks/useAudioPlayer/introduce.en-US.md @@ -14,6 +14,25 @@ Provides a stable `AudioPlayer` class instance for controlling audio playback wi - **Volume management** — `setVolume` / `volumeUp` / `volumeDown` with Web Audio `GainNode` when available. - **Event hooks** — `addEventListener` / `removeEventListener` delegate to the underlying `HTMLAudioElement`. +## Sample code + +```tsx +import { useAudioPlayer } from '@tiny-codes/react-easy'; + +export function Demo() { + const player = useAudioPlayer({ source: '/audio/sample.mp3', volume: 0.6 }); + + return ( + <> + <button onClick={() => player.play()}>Play</button> + <button onClick={() => player.pause()}>Pause</button> + <button onClick={() => player.stop()}>Stop</button> + <button onClick={() => player.setVolume(0.5)}>50% volume</button> + </> + ); +} +``` + ## Usage notes - If the source is a URL, the audio source must support cross-origin access, otherwise there may be no sound. diff --git a/.storybook/stories/hooks/useAudioPlayer/introduce.zh-CN.md b/.storybook/stories/hooks/useAudioPlayer/introduce.zh-CN.md index 81c34cb..6e5473d 100644 --- a/.storybook/stories/hooks/useAudioPlayer/introduce.zh-CN.md +++ b/.storybook/stories/hooks/useAudioPlayer/introduce.zh-CN.md @@ -14,6 +14,25 @@ - **音量管理** —— `setVolume` / `volumeUp` / `volumeDown`,可用时基于 Web Audio `GainNode`。 - **事件钩子** —— `addEventListener` / `removeEventListener` 委托给底层 `HTMLAudioElement`。 +## 示例代码 + +```tsx +import { useAudioPlayer } from '@tiny-codes/react-easy'; + +export function Demo() { + const player = useAudioPlayer({ source: '/audio/sample.mp3', volume: 0.6 }); + + return ( + <> + <button onClick={() => player.play()}>播放</button> + <button onClick={() => player.pause()}>暂停</button> + <button onClick={() => player.stop()}>停止</button> + <button onClick={() => player.setVolume(0.5)}>50% 音量</button> + </> + ); +} +``` + ## 使用注意 - 若音频源是 URL,需要音频源支持跨域访问,否则可能没有声音。 diff --git a/.storybook/stories/hooks/useDebounce/introduce.en-US.md b/.storybook/stories/hooks/useDebounce/introduce.en-US.md index 17f0bd5..37b1a03 100644 --- a/.storybook/stories/hooks/useDebounce/introduce.en-US.md +++ b/.storybook/stories/hooks/useDebounce/introduce.en-US.md @@ -13,6 +13,30 @@ Runs a debounced version of a callback with two complementary trigger mechanisms - **Runtime control** — `cancel()` drops pending execution, `disable()`/`enable()` toggle the debounce, `isDisabled()` reports state. - **Dependency-driven recreation** — the debounced function is re-created when `deps` change, like `useCallback`. +## Sample code + +```tsx +import { useEffect, useState } from 'react'; +import { useDebounce } from '@tiny-codes/react-easy'; + +export function SearchBox() { + const [query, setQuery] = useState(''); + const [applied, setApplied] = useState(''); + + const debouncedApply = useDebounce((value: string) => setApplied(value), [wait, leading, maxWait], { + wait: 300, + leading: false, + maxWait: 1000, + }); + + useEffect(() => { + debouncedApply(query); + }, [query, debouncedApply]); + + return <input value={query} onChange={(e) => setQuery(e.target.value)} />; +} +``` + ## Usage notes - `wait: 0` (default) disables debouncing entirely — calls execute immediately. diff --git a/.storybook/stories/hooks/useDebounce/introduce.zh-CN.md b/.storybook/stories/hooks/useDebounce/introduce.zh-CN.md index 1b94543..7b2ecfa 100644 --- a/.storybook/stories/hooks/useDebounce/introduce.zh-CN.md +++ b/.storybook/stories/hooks/useDebounce/introduce.zh-CN.md @@ -13,6 +13,30 @@ - **运行时控制** —— `cancel()` 丢弃待执行调用,`disable()` / `enable()` 开关防抖,`isDisabled()` 查询状态。 - **依赖驱动重建** —— `deps` 变化时重新创建防抖函数,行为类似 `useCallback`。 +## 示例代码 + +```tsx +import { useEffect, useState } from 'react'; +import { useDebounce } from '@tiny-codes/react-easy'; + +export function SearchBox() { + const [query, setQuery] = useState(''); + const [applied, setApplied] = useState(''); + + const debouncedApply = useDebounce((value: string) => setApplied(value), [wait, leading, maxWait], { + wait: 300, + leading: false, + maxWait: 1000, + }); + + useEffect(() => { + debouncedApply(query); + }, [query, debouncedApply]); + + return <input value={query} onChange={(e) => setQuery(e.target.value)} />; +} +``` + ## 使用注意 - `wait: 0`(默认)表示不防抖,调用立即执行。 diff --git a/.storybook/stories/hooks/useLocalStorage/introduce.en-US.md b/.storybook/stories/hooks/useLocalStorage/introduce.en-US.md index 2e1079d..b05f16c 100644 --- a/.storybook/stories/hooks/useLocalStorage/introduce.en-US.md +++ b/.storybook/stories/hooks/useLocalStorage/introduce.en-US.md @@ -13,6 +13,24 @@ Persist React state to `localStorage` with an API shaped like `useState`. The ho - **Custom (de)serialization** — override `serialize` / `deserialize` for non-JSON values. - **Safe fallbacks** — empty keys behave like `useState` and never touch storage; read/write errors are swallowed. +## Sample code + +```tsx +import { useLocalStorage } from '@tiny-codes/react-easy'; + +export function ThemeSwitcher() { + const [theme, setTheme, remove] = useLocalStorage<string>('app.theme', 'light', { sync: true }); + + return ( + <> + <button onClick={() => setTheme('dark')}>Dark</button> + <button onClick={() => setTheme('light')}>Light</button> + <button onClick={remove}>Reset</button> + </> + ); +} +``` + ## Usage notes - The `key` is trimmed; an empty key disables storage entirely. diff --git a/.storybook/stories/hooks/useLocalStorage/introduce.zh-CN.md b/.storybook/stories/hooks/useLocalStorage/introduce.zh-CN.md index e3a7c0f..224fde0 100644 --- a/.storybook/stories/hooks/useLocalStorage/introduce.zh-CN.md +++ b/.storybook/stories/hooks/useLocalStorage/introduce.zh-CN.md @@ -13,6 +13,24 @@ - **自定义序列化** —— 通过 `serialize` / `deserialize` 支持非 JSON 值。 - **安全降级** —— 空 key 时退化为 `useState` 且不触碰存储;读写错误被静默吞掉。 +## 示例代码 + +```tsx +import { useLocalStorage } from '@tiny-codes/react-easy'; + +export function ThemeSwitcher() { + const [theme, setTheme, remove] = useLocalStorage<string>('app.theme', 'light', { sync: true }); + + return ( + <> + <button onClick={() => setTheme('dark')}>深色</button> + <button onClick={() => setTheme('light')}>浅色</button> + <button onClick={remove}>重置</button> + </> + ); +} +``` + ## 使用注意 - `key` 会被去空格;空 key 完全禁用存储。 diff --git a/.storybook/stories/hooks/useMovable/introduce.en-US.md b/.storybook/stories/hooks/useMovable/introduce.en-US.md index 49a22c0..62afc51 100644 --- a/.storybook/stories/hooks/useMovable/introduce.en-US.md +++ b/.storybook/stories/hooks/useMovable/introduce.en-US.md @@ -13,6 +13,36 @@ Make an element **movable by dragging**, with optional position persistence in ` - **Selective ignore** — `ignoreSelectors` prevents drags that start on interactive controls. - **Optional persistence** — pass `storageKey` to save/restore the position via `localStorage`. +## Sample code + +```tsx +import { useRef } from 'react'; +import { useMovable } from '@tiny-codes/react-easy'; + +export function DraggableCard() { + const movableDomRef = useRef<HTMLDivElement>(null); + const viewPortRef = useRef<HTMLDivElement>(null); + + const { onPointerDown } = useMovable({ + movableDomRef, + viewPortRef, + storageKey: 'my-card.position', + }); + + return ( + <div ref={viewPortRef} style={{ position: 'relative', height: 300 }}> + <div + ref={movableDomRef} + onPointerDown={onPointerDown} + style={{ position: 'absolute', left: 0, top: 0, cursor: 'move' }} + > + Drag me + </div> + </div> + ); +} +``` + ## Usage notes - The hook does not render anything; spread `onPointerDown` on your draggable element and attach `containerRef` to the container. diff --git a/.storybook/stories/hooks/useMovable/introduce.zh-CN.md b/.storybook/stories/hooks/useMovable/introduce.zh-CN.md index 380cac4..9d75387 100644 --- a/.storybook/stories/hooks/useMovable/introduce.zh-CN.md +++ b/.storybook/stories/hooks/useMovable/introduce.zh-CN.md @@ -13,6 +13,36 @@ - **选择性忽略** —— `ignoreSelectors` 可排除交互控件上发起的拖动。 - **可选持久化** —— 传入 `storageKey` 即可通过 `localStorage` 保存/恢复位置。 +## 示例代码 + +```tsx +import { useRef } from 'react'; +import { useMovable } from '@tiny-codes/react-easy'; + +export function DraggableCard() { + const movableDomRef = useRef<HTMLDivElement>(null); + const viewPortRef = useRef<HTMLDivElement>(null); + + const { onPointerDown } = useMovable({ + movableDomRef, + viewPortRef, + storageKey: 'my-card.position', + }); + + return ( + <div ref={viewPortRef} style={{ position: 'relative', height: 300 }}> + <div + ref={movableDomRef} + onPointerDown={onPointerDown} + style={{ position: 'absolute', left: 0, top: 0, cursor: 'move' }} + > + 拖拽我 + </div> + </div> + ); +} +``` + ## 使用注意 - hook 不负责渲染;将 `onPointerDown` 绑定到可拖拽元素,并将 `containerRef` 绑定到容器。 diff --git a/.storybook/stories/hooks/useProcessingText/introduce.en-US.md b/.storybook/stories/hooks/useProcessingText/introduce.en-US.md index a61efe8..cae89fb 100644 --- a/.storybook/stories/hooks/useProcessingText/introduce.en-US.md +++ b/.storybook/stories/hooks/useProcessingText/introduce.en-US.md @@ -13,6 +13,23 @@ Create an animated "processing" text that cycles through dots — e.g. `Processi - **Custom text** — `prefixText` and `dotText` let you adapt the message to your language/UI. - **Pausable** — `enabled: false` freezes the text at the prefix (no dots). +## Sample code + +```tsx +import { useProcessingText } from '@tiny-codes/react-easy'; + +export function ProcessingLabel() { + const text = useProcessingText({ + prefixText: 'Processing', + dotText: '.', + interval: 300, + maxDots: 3, + }); + + return <span>{text}</span>; +} +``` + ## Usage notes - The animation starts when `enabled` is `true` (default). diff --git a/.storybook/stories/hooks/useProcessingText/introduce.zh-CN.md b/.storybook/stories/hooks/useProcessingText/introduce.zh-CN.md index 23fbced..37b6771 100644 --- a/.storybook/stories/hooks/useProcessingText/introduce.zh-CN.md +++ b/.storybook/stories/hooks/useProcessingText/introduce.zh-CN.md @@ -13,6 +13,23 @@ - **文本可定制** —— `prefixText` 与 `dotText` 适配不同语言/界面。 - **可暂停** —— `enabled: false` 时文本冻结为前缀(不带点)。 +## 示例代码 + +```tsx +import { useProcessingText } from '@tiny-codes/react-easy'; + +export function ProcessingLabel() { + const text = useProcessingText({ + prefixText: '处理中', + dotText: '.', + interval: 300, + maxDots: 3, + }); + + return <span>{text}</span>; +} +``` + ## 使用注意 - `enabled` 为 `true`(默认)时动画开始。 diff --git a/.storybook/stories/hooks/useRefFunction/introduce.en-US.md b/.storybook/stories/hooks/useRefFunction/introduce.en-US.md index 686a830..d2644df 100644 --- a/.storybook/stories/hooks/useRefFunction/introduce.en-US.md +++ b/.storybook/stories/hooks/useRefFunction/introduce.en-US.md @@ -12,6 +12,26 @@ Generate a function with an **immutable reference**. The function body keeps rea - **Fresh closure** — internal ref keeps the latest `fn`, so state/props are always current when called. - **Type-preserving** — generic signature returns a function typed as the input `T`. +## Sample code + +```tsx +import { useRefFunction } from '@tiny-codes/react-easy'; + +export function Demo() { + const stableLog = useRefFunction(() => { + console.log('latest value:', count); + }); + + // stableLog's reference never changes, safe to use in a deps array. + useEffect(() => { + window.addEventListener('resize', stableLog); + return () => window.removeEventListener('resize', stableLog); + }, [stableLog]); + + return null; +} +``` + ## Usage notes - The hook only wraps the _latest_ function; do not call it as a normal `useCallback` with a dependency list. diff --git a/.storybook/stories/hooks/useRefFunction/introduce.zh-CN.md b/.storybook/stories/hooks/useRefFunction/introduce.zh-CN.md index 2d78983..74f69b9 100644 --- a/.storybook/stories/hooks/useRefFunction/introduce.zh-CN.md +++ b/.storybook/stories/hooks/useRefFunction/introduce.zh-CN.md @@ -12,6 +12,26 @@ - **闭包最新** —— 内部 ref 始终持有最新的 `fn`,调用时读取到最新的 state/props。 - **类型保留** —— 泛型签名返回与输入 `T` 同类型的函数。 +## 示例代码 + +```tsx +import { useRefFunction } from '@tiny-codes/react-easy'; + +export function Demo() { + const stableLog = useRefFunction(() => { + console.log('最新值:', count); + }); + + // stableLog 的引用永不变化,可安全放入依赖数组。 + useEffect(() => { + window.addEventListener('resize', stableLog); + return () => window.removeEventListener('resize', stableLog); + }, [stableLog]); + + return null; +} +``` + ## 使用注意 - hook 只包装*最新*函数;不要像 `useCallback` 那样传入依赖列表使用。 diff --git a/.storybook/stories/hooks/useRefValue/introduce.en-US.md b/.storybook/stories/hooks/useRefValue/introduce.en-US.md index 5c6fbbd..ec71b77 100644 --- a/.storybook/stories/hooks/useRefValue/introduce.en-US.md +++ b/.storybook/stories/hooks/useRefValue/introduce.en-US.md @@ -12,6 +12,22 @@ Get a **mutable ref object** that automatically stays in sync with the latest va - **Stable reference** — the ref object itself never changes, safe for dependencies. - **Type-safe** — generic signature preserves the wrapped value type. +## Sample code + +```tsx +import { useRefValue } from '@tiny-codes/react-easy'; + +export function Demo() { + const countRef = useRefValue(count); + + // Read the latest value from inside a stable callback without + // re-creating it when `count` changes. + const capture = () => setCaptured(countRef.current); + + return <button onClick={capture}>Capture {countRef.current}</button>; +} +``` + ## Usage notes - Mutating `ref.current` does **not** trigger a re-render. diff --git a/.storybook/stories/hooks/useRefValue/introduce.zh-CN.md b/.storybook/stories/hooks/useRefValue/introduce.zh-CN.md index 800ff8f..b3f5d31 100644 --- a/.storybook/stories/hooks/useRefValue/introduce.zh-CN.md +++ b/.storybook/stories/hooks/useRefValue/introduce.zh-CN.md @@ -12,6 +12,21 @@ - **引用稳定** —— ref 对象本身永不变化,可安全用于依赖数组。 - **类型安全** —— 泛型签名保留包装值的类型。 +## 示例代码 + +```tsx +import { useRefValue } from '@tiny-codes/react-easy'; + +export function Demo() { + const countRef = useRefValue(count); + + // 在稳定回调中读取最新值,无需因 count 变化重建回调。 + const capture = () => setCaptured(countRef.current); + + return <button onClick={capture}>捕获 {countRef.current}</button>; +} +``` + ## 使用注意 - 修改 `ref.current` **不会**触发重新渲染。 diff --git a/.storybook/stories/hooks/useRowSelection/introduce.en-US.md b/.storybook/stories/hooks/useRowSelection/introduce.en-US.md index 532d9e1..e013f9c 100644 --- a/.storybook/stories/hooks/useRowSelection/introduce.en-US.md +++ b/.storybook/stories/hooks/useRowSelection/introduce.en-US.md @@ -13,6 +13,31 @@ Generate the `rowSelection` prop for Ant Design Table with **cross-page selectio - **Automatic row key** — falls back to the `id` or `code` field when `rowKey` is not provided. - **Restorable cache** — pass `cache` to rehydrate selections when the Table remounts (e.g. from `localStorage` or a MicroApp host). +## Sample code + +```tsx +import { useState } from 'react'; +import { useRowSelection } from '@tiny-codes/react-easy'; +import { Table } from 'antd'; + +interface User { + id: number; + name: string; +} + +export function Demo() { + const [selected, setSelected] = useState<User[]>([]); + + const rowSelection = useRowSelection<User>({ + value: selected, + onChange: setSelected, + rowKey: 'id', + }); + + return <Table rowKey="id" dataSource={users} columns={columns} rowSelection={rowSelection} pagination={false} />; +} +``` + ## Usage notes - Pass the returned object directly to `Table`'s `rowSelection` prop; it returns `undefined` when `checkable` is `false`. diff --git a/.storybook/stories/hooks/useRowSelection/introduce.zh-CN.md b/.storybook/stories/hooks/useRowSelection/introduce.zh-CN.md index e81c825..3e3a82f 100644 --- a/.storybook/stories/hooks/useRowSelection/introduce.zh-CN.md +++ b/.storybook/stories/hooks/useRowSelection/introduce.zh-CN.md @@ -13,6 +13,31 @@ - **自动行 key** —— 未提供 `rowKey` 时自动使用 `id` 或 `code` 字段。 - **可恢复缓存** —— 传入 `cache` 可在 Table 重新挂载时恢复选中(例如从 `localStorage` 或微前端主应用读取)。 +## 示例代码 + +```tsx +import { useState } from 'react'; +import { useRowSelection } from '@tiny-codes/react-easy'; +import { Table } from 'antd'; + +interface User { + id: number; + name: string; +} + +export function Demo() { + const [selected, setSelected] = useState<User[]>([]); + + const rowSelection = useRowSelection<User>({ + value: selected, + onChange: setSelected, + rowKey: 'id', + }); + + return <Table rowKey="id" dataSource={users} columns={columns} rowSelection={rowSelection} pagination={false} />; +} +``` + ## 使用注意 - 将返回值直接传给 Table 的 `rowSelection`;当 `checkable` 为 `false` 时返回 `undefined`。 diff --git a/.storybook/stories/hooks/useSSE/introduce.en-US.md b/.storybook/stories/hooks/useSSE/introduce.en-US.md index 5b743be..87df163 100644 --- a/.storybook/stories/hooks/useSSE/introduce.en-US.md +++ b/.storybook/stories/hooks/useSSE/introduce.en-US.md @@ -14,6 +14,31 @@ Establish a Server-Sent Events (SSE) connection using `@microsoft/fetch-event-so - **Observable state** — `isRequesting` and `isConnected` flags for UI feedback. - **Error-safe** — errors are routed to `onError` and connection failures do not throw into React. +## Sample code + +```tsx +import { useEffect } from 'react'; +import { useSSE } from '@tiny-codes/react-easy'; + +export function LiveFeed() { + const { connect, abort, isConnected } = useSSE<{ id: number; text: string }>({ + url: '/api/events', + autoConnect: true, + onMessage: (data) => appendLog(data), + }); + + useEffect(() => () => abort(), [abort]); + + return ( + <> + <button onClick={() => connect()}>Connect</button> + <button onClick={abort}>Disconnect</button> + <span>{isConnected ? 'connected' : 'disconnected'}</span> + </> + ); +} +``` + ## Usage notes - The stream is only opened when `connect()` is called, or when `autoConnect` is `true`. diff --git a/.storybook/stories/hooks/useSSE/introduce.zh-CN.md b/.storybook/stories/hooks/useSSE/introduce.zh-CN.md index 1337960..4075d55 100644 --- a/.storybook/stories/hooks/useSSE/introduce.zh-CN.md +++ b/.storybook/stories/hooks/useSSE/introduce.zh-CN.md @@ -14,6 +14,31 @@ - **状态可观察** —— `isRequesting` 与 `isConnected` 标志位方便 UI 反馈。 - **错误安全** —— 错误统一路由到 `onError`,连接失败不会向 React 抛异常。 +## 示例代码 + +```tsx +import { useEffect } from 'react'; +import { useSSE } from '@tiny-codes/react-easy'; + +export function LiveFeed() { + const { connect, abort, isConnected } = useSSE<{ id: number; text: string }>({ + url: '/api/events', + autoConnect: true, + onMessage: (data) => appendLog(data), + }); + + useEffect(() => () => abort(), [abort]); + + return ( + <> + <button onClick={() => connect()}>连接</button> + <button onClick={abort}>断开</button> + <span>{isConnected ? '已连接' : '未连接'}</span> + </> + ); +} +``` + ## 使用注意 - 仅在调用 `connect()` 或 `autoConnect` 为 `true` 时才建立连接。 diff --git a/.storybook/stories/hooks/useSplitter/introduce.en-US.md b/.storybook/stories/hooks/useSplitter/introduce.en-US.md index 7f03d3b..8b191b3 100644 --- a/.storybook/stories/hooks/useSplitter/introduce.en-US.md +++ b/.storybook/stories/hooks/useSplitter/introduce.en-US.md @@ -14,6 +14,29 @@ Make any two-pane layout resizable with a draggable splitter. The hook renders a - **Live feedback** — `percent`, `width`, and `dragging` are exposed for custom UI. - **Theme-aware** — uses `ConfigProvider` prefix and CSS variables; hover/dragging/handle class hooks available. +## Sample code + +```tsx +import { useSplitter } from '@tiny-codes/react-easy'; + +export function Demo() { + const { dom, percent, dragging } = useSplitter({ + direction: 'vertical', + defaultRatio: 0.32, + minRatio: 0.15, + maxRatio: 0.85, + }); + + return ( + <div style={{ display: 'flex', height: 400 }}> + <div style={{ width: `${(percent ?? 0.32) * 100}%` }}>Left pane</div> + {dom} + <div style={{ flex: 1 }}>Right pane</div> + </div> + ); +} +``` + ## Usage notes - The splitter resolves its container automatically from the `dom`'s parent element, or use the `container` prop explicitly. diff --git a/.storybook/stories/hooks/useSplitter/introduce.zh-CN.md b/.storybook/stories/hooks/useSplitter/introduce.zh-CN.md index bc37d1d..d7a3cad 100644 --- a/.storybook/stories/hooks/useSplitter/introduce.zh-CN.md +++ b/.storybook/stories/hooks/useSplitter/introduce.zh-CN.md @@ -14,6 +14,29 @@ - **实时反馈** —— 暴露 `percent`、`width`、`dragging` 供自定义 UI 使用。 - **主题感知** —— 使用 `ConfigProvider` 前缀与 CSS 变量;支持 hover/dragging/handle 类名钩子。 +## 示例代码 + +```tsx +import { useSplitter } from '@tiny-codes/react-easy'; + +export function Demo() { + const { dom, percent, dragging } = useSplitter({ + direction: 'vertical', + defaultRatio: 0.32, + minRatio: 0.15, + maxRatio: 0.85, + }); + + return ( + <div style={{ display: 'flex', height: 400 }}> + <div style={{ width: `${(percent ?? 0.32) * 100}%` }}>左侧面板</div> + {dom} + <div style={{ flex: 1 }}>右侧面板</div> + </div> + ); +} +``` + ## 使用注意 - 分割条默认自动解析容器(`dom` 的父元素),也可通过 `container` 显式指定。 diff --git a/.storybook/stories/hooks/useStompSocket/introduce.en-US.md b/.storybook/stories/hooks/useStompSocket/introduce.en-US.md index 230bfcb..9cdbfcd 100644 --- a/.storybook/stories/hooks/useStompSocket/introduce.en-US.md +++ b/.storybook/stories/hooks/useStompSocket/introduce.en-US.md @@ -13,6 +13,32 @@ Establish a **STOMP-over-WebSocket** connection using SockJS, mainly for bidirec - **Typed messaging** — `parseMessageBody` deserializes the raw body; `send(body)` publishes to `sendEndpoint`. - **Connection state** — `connecting` flag and callbacks `onConnected` / `onClose` for UI feedback. +## Sample code + +```tsx +import { useStompSocket } from '@tiny-codes/react-easy'; + +export function Chat() { + const { connect, send, close, connecting } = useStompSocket<string>({ + url: '/ws', + sendEndpoint: '/app/chat.send', + subscribeEndpoint: '/topic/chat', + onMessage: (body) => appendMessage(body), + onConnected: () => console.log('connected'), + }); + + return ( + <> + <button onClick={() => connect()} disabled={connecting}> + Connect + </button> + <button onClick={() => send('hello')}>Send</button> + <button onClick={close}>Close</button> + </> + ); +} +``` + ## Usage notes - Requires a SockJS-compatible STOMP server; the demo does not auto-connect. diff --git a/.storybook/stories/hooks/useStompSocket/introduce.zh-CN.md b/.storybook/stories/hooks/useStompSocket/introduce.zh-CN.md index 940e77c..d19c206 100644 --- a/.storybook/stories/hooks/useStompSocket/introduce.zh-CN.md +++ b/.storybook/stories/hooks/useStompSocket/introduce.zh-CN.md @@ -13,6 +13,32 @@ - **类型化消息** —— `parseMessageBody` 反序列化原始消息体;`send(body)` 发布到 `sendEndpoint`。 - **连接状态** —— `connecting` 标志与 `onConnected` / `onClose` 回调便于 UI 反馈。 +## 示例代码 + +```tsx +import { useStompSocket } from '@tiny-codes/react-easy'; + +export function Chat() { + const { connect, send, close, connecting } = useStompSocket<string>({ + url: '/ws', + sendEndpoint: '/app/chat.send', + subscribeEndpoint: '/topic/chat', + onMessage: (body) => appendMessage(body), + onConnected: () => console.log('connected'), + }); + + return ( + <> + <button onClick={() => connect()} disabled={connecting}> + 连接 + </button> + <button onClick={() => send('你好')}>发送</button> + <button onClick={close}>关闭</button> + </> + ); +} +``` + ## 使用注意 - 需要 SockJS 兼容的 STOMP 服务端;示例不会自动连接。 diff --git a/.storybook/stories/hooks/useStreamDownloader/introduce.en-US.md b/.storybook/stories/hooks/useStreamDownloader/introduce.en-US.md index 6562dbf..c034006 100644 --- a/.storybook/stories/hooks/useStreamDownloader/introduce.en-US.md +++ b/.storybook/stories/hooks/useStreamDownloader/introduce.en-US.md @@ -13,6 +13,31 @@ React adapter over the `StreamDownloader` class that exposes a stable downloader - **Bound helpers** — `start` / `cancel` / `reset` are pre-bound to the instance, so they can be passed around without losing `this`. - **Auto-dispose** — the downloader is disposed on unmount by default; disable via `autoDispose: false` when the instance must outlive the component. +## Sample code + +```tsx +import { useStreamDownloader } from '@tiny-codes/react-easy'; + +export function DownloadButton() { + const { start, cancel, isRunning, snapshot } = useStreamDownloader(); + + return ( + <> + <button + disabled={isRunning} + onClick={() => start({ url: '/api/report', fileName: 'report.pdf', saveStrategy: 'auto' })} + > + Download + </button> + <button onClick={cancel} disabled={!isRunning}> + Cancel + </button> + <span>{snapshot.progress.percent ?? 0}%</span> + </> + ); +} +``` + ## Usage notes - The full transport contract, save strategies, and type system live in the class-level page: [Utils/StreamDownloader](?path=/docs/utils-streamdownloader--playground). This page focuses on the hook-only surface. diff --git a/.storybook/stories/hooks/useStreamDownloader/introduce.zh-CN.md b/.storybook/stories/hooks/useStreamDownloader/introduce.zh-CN.md index e0f790d..df34c9d 100644 --- a/.storybook/stories/hooks/useStreamDownloader/introduce.zh-CN.md +++ b/.storybook/stories/hooks/useStreamDownloader/introduce.zh-CN.md @@ -13,6 +13,31 @@ - **绑定好的方法** —— `start` / `cancel` / `reset` 已预绑定实例,可直接传递而不会丢失 `this`。 - **自动释放** —— 默认在卸载时 dispose downloader;需要实例存活于组件之外时,可通过 `autoDispose: false` 关闭。 +## 示例代码 + +```tsx +import { useStreamDownloader } from '@tiny-codes/react-easy'; + +export function DownloadButton() { + const { start, cancel, isRunning, snapshot } = useStreamDownloader(); + + return ( + <> + <button + disabled={isRunning} + onClick={() => start({ url: '/api/report', fileName: 'report.pdf', saveStrategy: 'auto' })} + > + 下载 + </button> + <button onClick={cancel} disabled={!isRunning}> + 取消 + </button> + <span>{snapshot.progress.percent ?? 0}%</span> + </> + ); +} +``` + ## 使用注意 - 完整的 transport 契约、保存策略与类型体系在类级页面: [Utils/StreamDownloader](?path=/docs/utils-streamdownloader--playground)。本页聚焦 hook 自身接口。 diff --git a/.storybook/stories/hooks/useUserMedia/introduce.en-US.md b/.storybook/stories/hooks/useUserMedia/introduce.en-US.md index ace7d69..e7ded66 100644 --- a/.storybook/stories/hooks/useUserMedia/introduce.en-US.md +++ b/.storybook/stories/hooks/useUserMedia/introduce.en-US.md @@ -14,6 +14,30 @@ Record **audio/video media streams** from the user's devices (`getUserMedia`) wi - **Permission handling** — detects denied/prompt states and shows guided popups with device-reset instructions. - **Silence detection** — when no sound arrives within `soundDetectionTimeout`, prompts the user to re-select the microphone. +## Sample code + +```tsx +import { useUserMedia } from '@tiny-codes/react-easy'; + +export function Recorder() { + const { isRecording, startRecording, stopRecording } = useUserMedia({ + media: { audio: true, video: false }, + onStreamChunk: (chunk) => uploadChunk(chunk), + }); + + return ( + <> + <button onClick={() => startRecording()} disabled={isRecording}> + Record + </button> + <button onClick={stopRecording} disabled={!isRecording}> + Stop + </button> + </> + ); +} +``` + ## Usage notes - Requires `navigator.mediaDevices.getUserMedia` and `navigator.permissions.query`; unsupported browsers throw a readable error. diff --git a/.storybook/stories/hooks/useUserMedia/introduce.zh-CN.md b/.storybook/stories/hooks/useUserMedia/introduce.zh-CN.md index 05219e5..1d179ba 100644 --- a/.storybook/stories/hooks/useUserMedia/introduce.zh-CN.md +++ b/.storybook/stories/hooks/useUserMedia/introduce.zh-CN.md @@ -14,6 +14,30 @@ - **权限处理** —— 识别 denied/prompt 状态,弹出带设备重置指引的引导弹窗。 - **静音检测** —— 在 `soundDetectionTimeout` 内未检测到声音时,提示用户重新选择麦克风。 +## 示例代码 + +```tsx +import { useUserMedia } from '@tiny-codes/react-easy'; + +export function Recorder() { + const { isRecording, startRecording, stopRecording } = useUserMedia({ + media: { audio: true, video: false }, + onStreamChunk: (chunk) => uploadChunk(chunk), + }); + + return ( + <> + <button onClick={() => startRecording()} disabled={isRecording}> + 录制 + </button> + <button onClick={stopRecording} disabled={!isRecording}> + 停止 + </button> + </> + ); +} +``` + ## 使用注意 - 依赖 `navigator.mediaDevices.getUserMedia` 与 `navigator.permissions.query`;不支持的浏览器会抛出可读错误。 diff --git a/.storybook/stories/hooks/useValidator/introduce.en-US.md b/.storybook/stories/hooks/useValidator/introduce.en-US.md index f215a81..c38018f 100644 --- a/.storybook/stories/hooks/useValidator/introduce.en-US.md +++ b/.storybook/stories/hooks/useValidator/introduce.en-US.md @@ -13,6 +13,33 @@ Build a single **Ant-Design-compatible validation rule** once, from declarative - **Localized messages** — failure message is assembled from i18n tokens. - **antd-compatible** — returns `{ pattern, message, allowedOptions, startsWithOptions, flags }`. +## Sample code + +```tsx +import { type RuleRegExpFlags, useValidator } from '@tiny-codes/react-easy'; +import { Form, Input } from 'antd'; + +export function Demo() { + const allowed: RuleRegExpFlags = { + letter: true, + number: true, + underscore: true, + min: 6, + max: 20, + }; + + const rule = useValidator({ allowed }); + + return ( + <Form> + <Form.Item name="username" label="Username" rules={[rule]}> + <Input /> + </Form.Item> + </Form> + ); +} +``` + ## Usage notes - At least one allowed flag must be `true`; otherwise it throws a localized error. diff --git a/.storybook/stories/hooks/useValidator/introduce.zh-CN.md b/.storybook/stories/hooks/useValidator/introduce.zh-CN.md index cfc8dc4..59489de 100644 --- a/.storybook/stories/hooks/useValidator/introduce.zh-CN.md +++ b/.storybook/stories/hooks/useValidator/introduce.zh-CN.md @@ -13,6 +13,33 @@ - **本地化消息** —— 校验失败提示由 i18n token 组装。 - **antd 兼容** —— 返回 `{ pattern, message, allowedOptions, startsWithOptions, flags }`。 +## 示例代码 + +```tsx +import { type RuleRegExpFlags, useValidator } from '@tiny-codes/react-easy'; +import { Form, Input } from 'antd'; + +export function Demo() { + const allowed: RuleRegExpFlags = { + letter: true, + number: true, + underscore: true, + min: 6, + max: 20, + }; + + const rule = useValidator({ allowed }); + + return ( + <Form> + <Form.Item name="username" label="用户名" rules={[rule]}> + <Input /> + </Form.Item> + </Form> + ); +} +``` + ## 使用注意 - 至少一个允许标志必须为 `true`;否则抛出本地化错误。 diff --git a/.storybook/stories/hooks/useValidatorBuilder/introduce.en-US.md b/.storybook/stories/hooks/useValidatorBuilder/introduce.en-US.md index c1c4002..09040e9 100644 --- a/.storybook/stories/hooks/useValidatorBuilder/introduce.en-US.md +++ b/.storybook/stories/hooks/useValidatorBuilder/introduce.en-US.md @@ -13,6 +13,30 @@ Get a **validation rule builder** that constructs Ant-Design-compatible regex ru - **Localized messages** — error messages are assembled from i18n tokens describing each allowed set. - **antd-compatible** — the result is a `{ pattern, message, allowedOptions, startsWithOptions, flags }` rule. +## Sample code + +```tsx +import { type RuleRegExpFlags, useValidatorBuilder } from '@tiny-codes/react-easy'; +import { Form, Input } from 'antd'; + +export function Demo() { + const build = useValidatorBuilder(); + + const usernameRule = build({ + allowed: { letter: true, number: true, underscore: true, min: 6, max: 20 }, + startsWith: { letter: true }, + }); + + return ( + <Form> + <Form.Item name="username" label="Username" rules={[usernameRule]}> + <Input /> + </Form.Item> + </Form> + ); +} +``` + ## Usage notes - At least one allowed flag must be `true`; otherwise it throws a localized error. diff --git a/.storybook/stories/hooks/useValidatorBuilder/introduce.zh-CN.md b/.storybook/stories/hooks/useValidatorBuilder/introduce.zh-CN.md index dd84cbb..ad2ebab 100644 --- a/.storybook/stories/hooks/useValidatorBuilder/introduce.zh-CN.md +++ b/.storybook/stories/hooks/useValidatorBuilder/introduce.zh-CN.md @@ -13,6 +13,30 @@ - **本地化消息** —— 错误消息由描述每个允许集合的 i18n token 组装。 - **antd 兼容** —— 结果是 `{ pattern, message, allowedOptions, startsWithOptions, flags }` 规则。 +## 示例代码 + +```tsx +import { type RuleRegExpFlags, useValidatorBuilder } from '@tiny-codes/react-easy'; +import { Form, Input } from 'antd'; + +export function Demo() { + const build = useValidatorBuilder(); + + const usernameRule = build({ + allowed: { letter: true, number: true, underscore: true, min: 6, max: 20 }, + startsWith: { letter: true }, + }); + + return ( + <Form> + <Form.Item name="username" label="用户名" rules={[usernameRule]}> + <Input /> + </Form.Item> + </Form> + ); +} +``` + ## 使用注意 - 至少一个允许标志必须为 `true`;否则抛出本地化错误。 diff --git a/.storybook/stories/hooks/useValidators/introduce.en-US.md b/.storybook/stories/hooks/useValidators/introduce.en-US.md index 22a6a0e..bdb3df5 100644 --- a/.storybook/stories/hooks/useValidators/introduce.en-US.md +++ b/.storybook/stories/hooks/useValidators/introduce.en-US.md @@ -13,6 +13,31 @@ Get a map of **built-in validation rules** for common data formats. The rules ar - **Length variants** — `codeMax20/64/128` and `nameMax20/64/128` presets; `*WithMax` functions for custom limits. - **i18n messages** — failure messages are localized through the library's translation system. +## Sample code + +```tsx +import { useValidators } from '@tiny-codes/react-easy'; +import { Form, Input } from 'antd'; + +export function Demo() { + const { email, cnMobile, password } = useValidators(); + + return ( + <Form> + <Form.Item name="email" label="Email" rules={[{ validator: email }]}> + <Input /> + </Form.Item> + <Form.Item name="phone" label="Phone" rules={[{ validator: cnMobile }]}> + <Input /> + </Form.Item> + <Form.Item name="password" label="Password" rules={[{ validator: password }]}> + <Input.Password /> + </Form.Item> + </Form> + ); +} +``` + ## Usage notes - The map is memoized; all rules are stable across renders. diff --git a/.storybook/stories/hooks/useValidators/introduce.zh-CN.md b/.storybook/stories/hooks/useValidators/introduce.zh-CN.md index 56d3c50..0de0b4c 100644 --- a/.storybook/stories/hooks/useValidators/introduce.zh-CN.md +++ b/.storybook/stories/hooks/useValidators/introduce.zh-CN.md @@ -13,6 +13,31 @@ - **长度变体** —— `codeMax20/64/128`、`nameMax20/64/128` 预设;`*WithMax` 函数支持自定义长度。 - **i18n 消息** —— 校验失败提示通过库的翻译系统本地化。 +## 示例代码 + +```tsx +import { useValidators } from '@tiny-codes/react-easy'; +import { Form, Input } from 'antd'; + +export function Demo() { + const { email, cnMobile, password } = useValidators(); + + return ( + <Form> + <Form.Item name="email" label="邮箱" rules={[{ validator: email }]}> + <Input /> + </Form.Item> + <Form.Item name="phone" label="手机号" rules={[{ validator: cnMobile }]}> + <Input /> + </Form.Item> + <Form.Item name="password" label="密码" rules={[{ validator: password }]}> + <Input.Password /> + </Form.Item> + </Form> + ); +} +``` + ## 使用注意 - 映射经过记忆化,所有规则在渲染间保持稳定。 diff --git a/.storybook/stories/utils/StreamDownloader/introduce.en-US.md b/.storybook/stories/utils/StreamDownloader/introduce.en-US.md index fcee94d..ee328a7 100644 --- a/.storybook/stories/utils/StreamDownloader/introduce.en-US.md +++ b/.storybook/stories/utils/StreamDownloader/introduce.en-US.md @@ -15,6 +15,24 @@ Browser-side streaming downloader for large files. It reads the remote response - **Lifecycle control** — `cancel()`, `reset()`, and `dispose()` cover cancellation, terminal-state reset, and resource release. - **Stable error codes** — typed error codes (`TASK_ALREADY_RUNNING`, `DOWNLOAD_CANCELLED`, …) via `StreamDownloadError`. +## Sample code + +```ts +import { StreamDownloader } from '@tiny-codes/react-easy'; + +const downloader = new StreamDownloader({ progressThrottleMs: 200 }); + +downloader.subscribe((snapshot) => { + console.log(snapshot.status, snapshot.progress.percent); +}); + +await downloader.start({ + url: '/api/report', + fileName: 'report.pdf', + saveStrategy: 'auto', +}); +``` + ## Usage notes - The remote URL must support CORS and expose a readable stream body; otherwise the download fails with `HTTP_ERROR` or `EMPTY_RESPONSE_STREAM`. diff --git a/.storybook/stories/utils/StreamDownloader/introduce.zh-CN.md b/.storybook/stories/utils/StreamDownloader/introduce.zh-CN.md index a58ee0a..850a708 100644 --- a/.storybook/stories/utils/StreamDownloader/introduce.zh-CN.md +++ b/.storybook/stories/utils/StreamDownloader/introduce.zh-CN.md @@ -15,6 +15,24 @@ - **生命周期控制** —— `cancel()`、`reset()`、`dispose()` 覆盖取消、终态重置与资源释放。 - **稳定错误码** —— 通过 `StreamDownloadError` 提供类型化错误码(`TASK_ALREADY_RUNNING`、`DOWNLOAD_CANCELLED` 等)。 +## 示例代码 + +```ts +import { StreamDownloader } from '@tiny-codes/react-easy'; + +const downloader = new StreamDownloader({ progressThrottleMs: 200 }); + +downloader.subscribe((snapshot) => { + console.log(snapshot.status, snapshot.progress.percent); +}); + +await downloader.start({ + url: '/api/report', + fileName: 'report.pdf', + saveStrategy: 'auto', +}); +``` + ## 使用注意 - 远端 URL 必须支持 CORS 并暴露可读流 body,否则会以 `HTTP_ERROR` 或 `EMPTY_RESPONSE_STREAM` 失败。 From b1a2a85cb95bb55fd36253ade8cd5a41534d3377 Mon Sep 17 00:00:00 2001 From: Fengbao Li <shijistar@gmail.com> Date: Thu, 6 Aug 2026 09:59:12 +0800 Subject: [PATCH 21/39] feat(hooks): simplify useMovable docs and fix empty theme/language checks - Update useMovable story examples to reflect simplified hook usage - Add guard checks for theme and language globals in Storybook preview --- .storybook/preview.tsx | 4 ++-- .../stories/hooks/useMovable/introduce.en-US.md | 12 ++---------- .../stories/hooks/useMovable/introduce.zh-CN.md | 12 ++---------- 3 files changed, 6 insertions(+), 22 deletions(-) diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 45fc799..7788105 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -113,7 +113,7 @@ const preview: Preview = { const isDark = theme === 'dark' || (!theme && isPreferDark); // Reload the page if the theme changes. useMemo(() => { - if (themeFromGlobal !== prevTheme) { + if (themeFromGlobal && themeFromGlobal !== prevTheme) { setPrevTheme(themeFromGlobal); (window.top ?? window.parent ?? window).location.reload(); } @@ -121,7 +121,7 @@ const preview: Preview = { // Reload the page if the language changes. useMemo(() => { - if (storyI18n.language !== lang) { + if (lang && storyI18n.language !== lang) { storyI18n.changeLanguage(lang).then(() => { if (viewModeRef.current === 'docs') { addons.getChannel().emit(FORCE_RE_RENDER); diff --git a/.storybook/stories/hooks/useMovable/introduce.en-US.md b/.storybook/stories/hooks/useMovable/introduce.en-US.md index 62afc51..5e6b462 100644 --- a/.storybook/stories/hooks/useMovable/introduce.en-US.md +++ b/.storybook/stories/hooks/useMovable/introduce.en-US.md @@ -23,19 +23,11 @@ export function DraggableCard() { const movableDomRef = useRef<HTMLDivElement>(null); const viewPortRef = useRef<HTMLDivElement>(null); - const { onPointerDown } = useMovable({ - movableDomRef, - viewPortRef, - storageKey: 'my-card.position', - }); + useMovable({ movableDomRef, viewPortRef }); return ( <div ref={viewPortRef} style={{ position: 'relative', height: 300 }}> - <div - ref={movableDomRef} - onPointerDown={onPointerDown} - style={{ position: 'absolute', left: 0, top: 0, cursor: 'move' }} - > + <div ref={movableDomRef} style={{ position: 'absolute', left: 0, top: 0, cursor: 'move' }}> Drag me </div> </div> diff --git a/.storybook/stories/hooks/useMovable/introduce.zh-CN.md b/.storybook/stories/hooks/useMovable/introduce.zh-CN.md index 9d75387..e4791f8 100644 --- a/.storybook/stories/hooks/useMovable/introduce.zh-CN.md +++ b/.storybook/stories/hooks/useMovable/introduce.zh-CN.md @@ -23,19 +23,11 @@ export function DraggableCard() { const movableDomRef = useRef<HTMLDivElement>(null); const viewPortRef = useRef<HTMLDivElement>(null); - const { onPointerDown } = useMovable({ - movableDomRef, - viewPortRef, - storageKey: 'my-card.position', - }); + useMovable({ movableDomRef, viewPortRef }); return ( <div ref={viewPortRef} style={{ position: 'relative', height: 300 }}> - <div - ref={movableDomRef} - onPointerDown={onPointerDown} - style={{ position: 'absolute', left: 0, top: 0, cursor: 'move' }} - > + <div ref={movableDomRef} style={{ position: 'absolute', left: 0, top: 0, cursor: 'move' }}> 拖拽我 </div> </div> From e19cdfb18c3c192553595e2c26d0e822c25d10b9 Mon Sep 17 00:00:00 2001 From: Fengbao Li <shijistar@gmail.com> Date: Thu, 6 Aug 2026 10:22:27 +0800 Subject: [PATCH 22/39] fix(story): rework useRowSelection demo to showcase cross-page selection - Add 26 rows (11 names x 3 roles) and enable pagination (pageSize 10) so multi-page selection can actually be demonstrated. - Fix '{count}' not being interpolated: i18next default delimiters are {{ }}, but selectedCount labels used single braces {count}. - Replace the row-based <List> of selected rows with a compact inline Space+Tag display. --- .storybook/locales/langs/en-US.ts | 10 ++- .storybook/locales/langs/zh-CN.ts | 10 ++- .../hooks/useRowSelection/index.stories.tsx | 76 ++++++++++--------- 3 files changed, 59 insertions(+), 37 deletions(-) diff --git a/.storybook/locales/langs/en-US.ts b/.storybook/locales/langs/en-US.ts index 5757107..ea6e094 100644 --- a/.storybook/locales/langs/en-US.ts +++ b/.storybook/locales/langs/en-US.ts @@ -461,13 +461,21 @@ const enUS = { 'storybook.stories.useRowSelection.data.alice': 'Alice', 'storybook.stories.useRowSelection.data.bob': 'Bob', 'storybook.stories.useRowSelection.data.cindy': 'Cindy', + 'storybook.stories.useRowSelection.data.dave': 'Dave', + 'storybook.stories.useRowSelection.data.erin': 'Erin', + 'storybook.stories.useRowSelection.data.frank': 'Frank', + 'storybook.stories.useRowSelection.data.grace': 'Grace', + 'storybook.stories.useRowSelection.data.henry': 'Henry', + 'storybook.stories.useRowSelection.data.iris': 'Iris', + 'storybook.stories.useRowSelection.data.jack': 'Jack', + 'storybook.stories.useRowSelection.data.kate': 'Kate', 'storybook.stories.useRowSelection.description': 'Generate the rowSelection prop for Ant Design Table with cross-page selection support.', 'storybook.stories.useRowSelection.emptySelection': 'No rows selected', 'storybook.stories.useRowSelection.roles.admin': 'Admin', 'storybook.stories.useRowSelection.roles.editor': 'Editor', 'storybook.stories.useRowSelection.roles.viewer': 'Viewer', - 'storybook.stories.useRowSelection.selectedCount': '{count} row(s) selected', + 'storybook.stories.useRowSelection.selectedCount': '{{count}} row(s) selected', 'storybook.stories.useSSE.abort': 'Abort', 'storybook.stories.useSSE.aborted': 'Aborted', 'storybook.stories.useSSE.argTypes.url.description': 'The SSE endpoint URL.', diff --git a/.storybook/locales/langs/zh-CN.ts b/.storybook/locales/langs/zh-CN.ts index 7dbfcd8..4ce11f2 100644 --- a/.storybook/locales/langs/zh-CN.ts +++ b/.storybook/locales/langs/zh-CN.ts @@ -422,12 +422,20 @@ const zhCN = { 'storybook.stories.useRowSelection.data.alice': '爱丽丝', 'storybook.stories.useRowSelection.data.bob': '鲍勃', 'storybook.stories.useRowSelection.data.cindy': '辛迪', + 'storybook.stories.useRowSelection.data.dave': '大卫', + 'storybook.stories.useRowSelection.data.erin': '艾琳', + 'storybook.stories.useRowSelection.data.frank': '弗兰克', + 'storybook.stories.useRowSelection.data.grace': '格蕾丝', + 'storybook.stories.useRowSelection.data.henry': '亨利', + 'storybook.stories.useRowSelection.data.iris': '艾瑞斯', + 'storybook.stories.useRowSelection.data.jack': '杰克', + 'storybook.stories.useRowSelection.data.kate': '凯特', 'storybook.stories.useRowSelection.description': '为 Ant Design Table 生成带跨页选择支持的 rowSelection 配置。', 'storybook.stories.useRowSelection.emptySelection': '未选择任何行', 'storybook.stories.useRowSelection.roles.admin': '管理员', 'storybook.stories.useRowSelection.roles.editor': '编辑者', 'storybook.stories.useRowSelection.roles.viewer': '浏览者', - 'storybook.stories.useRowSelection.selectedCount': '已选 {count} 行', + 'storybook.stories.useRowSelection.selectedCount': '已选 {{count}} 行', 'storybook.stories.useSSE.abort': '中断', 'storybook.stories.useSSE.aborted': '已中断', 'storybook.stories.useSSE.argTypes.url.description': 'SSE 端点地址', diff --git a/.storybook/stories/hooks/useRowSelection/index.stories.tsx b/.storybook/stories/hooks/useRowSelection/index.stories.tsx index 0695942..76fea51 100644 --- a/.storybook/stories/hooks/useRowSelection/index.stories.tsx +++ b/.storybook/stories/hooks/useRowSelection/index.stories.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; -import { Button, Card, List, Space, Table, Tag, Typography } from 'antd'; +import { Button, Card, Space, Table, Tag, Typography } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import useRowSelection from '../../../../src/hooks/useRowSelection'; import storyI18n, { storyT, useStoryT } from '../../../locales'; @@ -19,6 +19,13 @@ interface UserRow { role: string; } +const NAMES = ['alice', 'bob', 'cindy', 'dave', 'erin', 'frank', 'grace', 'henry', 'iris', 'jack', 'kate'] as const; + +const ROLES = ['admin', 'editor', 'viewer'] as const; + +const PAGE_SIZE = 10; +const ROW_COUNT = 26; + const meta: Meta<UseRowSelectionStoryArgs> = { title: 'Hooks/useRowSelection', parameters: { @@ -47,7 +54,7 @@ export const Playground: Story = { docs: { description: { story: - '- **EN:** Toggle selection availability and select rows to see how the hook exposes a `rowSelection` object that returns the selected row objects (not just keys) through its `onChange` callback.\n- **CN:** 切换选中可用开关并选择行,观察 hook 如何通过 `onChange` 返回选中的行对象(而不是仅返回行 key)。', + '- **EN:** Toggle selection availability and select rows **across pages** to see how the hook exposes a `rowSelection` object that keeps the selected row objects (not just keys) consistent through its `onChange` callback.\\n- **CN:** 切换选中可用开关并在**多个分页**中选择行,观察 hook 通过 `onChange` 返回选中的行对象(而不是仅返回行 key),并跨页保持一致。', }, }, }, @@ -56,6 +63,18 @@ export const Playground: Story = { }, }; +function buildData(t: typeof storyT): UserRow[] { + return Array.from({ length: ROW_COUNT }, (_, index) => { + const nameKey = NAMES[index % NAMES.length]; + const roleKey = ROLES[index % ROLES.length]; + return { + id: index + 1, + name: t(`storybook.stories.useRowSelection.data.${nameKey}`), + role: t(`storybook.stories.useRowSelection.roles.${roleKey}`), + }; + }); +} + function UseRowSelectionStoryDemo({ checkable }: UseRowSelectionStoryArgs) { const t = useStoryT(); const [selectedRows, setSelectedRows] = useState<UserRow[]>([]); @@ -71,6 +90,12 @@ function UseRowSelectionStoryDemo({ checkable }: UseRowSelectionStoryArgs) { { title: t('storybook.stories.useRowSelection.columns.name'), dataIndex: 'name', + render: (name: string, record: UserRow) => ( + <Space> + <Typography.Text code>{record.id}</Typography.Text> + <span>{name}</span> + </Space> + ), }, { title: t('storybook.stories.useRowSelection.columns.role'), @@ -79,23 +104,7 @@ function UseRowSelectionStoryDemo({ checkable }: UseRowSelectionStoryArgs) { }, ]; - const data: UserRow[] = [ - { - id: 1, - name: t('storybook.stories.useRowSelection.data.alice'), - role: t('storybook.stories.useRowSelection.roles.admin'), - }, - { - id: 2, - name: t('storybook.stories.useRowSelection.data.bob'), - role: t('storybook.stories.useRowSelection.roles.editor'), - }, - { - id: 3, - name: t('storybook.stories.useRowSelection.data.cindy'), - role: t('storybook.stories.useRowSelection.roles.viewer'), - }, - ]; + const data: UserRow[] = buildData(t); return ( <Card variant="outlined" style={{ maxWidth: 920 }} title={t('storybook.stories.useRowSelection.cardTitle')}> @@ -109,7 +118,7 @@ function UseRowSelectionStoryDemo({ checkable }: UseRowSelectionStoryArgs) { columns={columns} dataSource={data} rowSelection={rowSelection} - pagination={false} + pagination={{ pageSize: PAGE_SIZE }} /> <Space align="center" wrap style={{ width: '100%', justifyContent: 'space-between' }}> @@ -126,21 +135,18 @@ function UseRowSelectionStoryDemo({ checkable }: UseRowSelectionStoryArgs) { </Button> </Space> - <List - size="small" - bordered - dataSource={selectedRows} - locale={{ emptyText: t('storybook.stories.useRowSelection.emptySelection') }} - renderItem={(item) => ( - <List.Item key={item.id}> - <Space wrap> - <Typography.Text code>{item.id}</Typography.Text> - <Typography.Text>{item.name}</Typography.Text> - <Tag>{item.role}</Tag> - </Space> - </List.Item> - )} - /> + {selectedRows.length > 0 ? ( + <Space wrap size={[8, 4]} data-testid="inline-selected"> + <Typography.Text type="secondary">{t('storybook.stories.useRowSelection.columns.name')}:</Typography.Text> + {selectedRows.map((item) => ( + <Tag key={item.id} color="blue"> + #{item.id} {item.name} · {item.role} + </Tag> + ))} + </Space> + ) : ( + <Typography.Text type="secondary">{t('storybook.stories.useRowSelection.emptySelection')}</Typography.Text> + )} </Space> </Card> ); From f3620c495f3b56c41c96698ab50f1f45d10d6747 Mon Sep 17 00:00:00 2001 From: Fengbao Li <shijistar@gmail.com> Date: Thu, 6 Aug 2026 15:33:15 +0800 Subject: [PATCH 23/39] feat(css): add easy-full-height-table-v6 --- .../hooks/useRowSelection/index.stories.tsx | 33 +++++++----- src/components/ConfigProvider/style/index.ts | 51 +++++++++++++++++++ 2 files changed, 71 insertions(+), 13 deletions(-) diff --git a/.storybook/stories/hooks/useRowSelection/index.stories.tsx b/.storybook/stories/hooks/useRowSelection/index.stories.tsx index 76fea51..e0c0895 100644 --- a/.storybook/stories/hooks/useRowSelection/index.stories.tsx +++ b/.storybook/stories/hooks/useRowSelection/index.stories.tsx @@ -113,14 +113,6 @@ function UseRowSelectionStoryDemo({ checkable }: UseRowSelectionStoryArgs) { {t('storybook.stories.useRowSelection.description')} </Typography.Paragraph> - <Table<UserRow> - rowKey="id" - columns={columns} - dataSource={data} - rowSelection={rowSelection} - pagination={{ pageSize: PAGE_SIZE }} - /> - <Space align="center" wrap style={{ width: '100%', justifyContent: 'space-between' }}> <Typography.Text strong> {t('storybook.stories.useRowSelection.selectedCount', { count: selectedRows.length })} @@ -135,18 +127,33 @@ function UseRowSelectionStoryDemo({ checkable }: UseRowSelectionStoryArgs) { </Button> </Space> - {selectedRows.length > 0 ? ( + {selectedRows.length > 0 && ( <Space wrap size={[8, 4]} data-testid="inline-selected"> <Typography.Text type="secondary">{t('storybook.stories.useRowSelection.columns.name')}:</Typography.Text> {selectedRows.map((item) => ( - <Tag key={item.id} color="blue"> - #{item.id} {item.name} · {item.role} + <Tag + key={item.id} + color="blue" + closable + onClose={() => { + setSelectedRows((prev) => prev.filter((row) => row.id !== item.id)); + }} + > + #{item.id} {item.name} • {item.role} </Tag> ))} </Space> - ) : ( - <Typography.Text type="secondary">{t('storybook.stories.useRowSelection.emptySelection')}</Typography.Text> )} + <div style={{ height: 500 }}> + <Table + className="easy-full-height-table-v6" + rowKey="id" + columns={columns} + dataSource={data} + rowSelection={rowSelection} + pagination={{ pageSize: PAGE_SIZE }} + /> + </div> </Space> </Card> ); diff --git a/src/components/ConfigProvider/style/index.ts b/src/components/ConfigProvider/style/index.ts index 51e7302..46a40fe 100644 --- a/src/components/ConfigProvider/style/index.ts +++ b/src/components/ConfigProvider/style/index.ts @@ -87,6 +87,57 @@ const genStyle: GenerateStyle<ConfigProviderToken> = (token): CSSObject => { }, }, }, + '.easy-full-height-table-v6': { + height: '100%', + [`& > ${token.antCls}-spin`]: { + height: '100%', + [`& > ${token.antCls}-spin-container`]: { + display: 'flex', + flexDirection: 'column', + height: '100%', + [`& > ${token.antCls}-table`]: { + flex: 1, + minHeight: 0, + [`& > ${token.antCls}-table-container`]: { + height: '100%', + [`& > ${token.antCls}-table-content`]: { + height: '100%', + // Raise the container by 1px more, otherwise a vertical scrollbar will always be displayed. + // paddingBottom: 1, + // Automatically show vertical scroll bar + // stylelint-disable-next-line declaration-no-important + overflow: 'auto !important', + + '& > table': { + height: '100%', + + [`${token.antCls}-table-thead`]: { + position: 'sticky', + top: 0, + zIndex: 10, + }, + + td: { + // When nesting tables, restore all full height styles to prevent the child table from being affected + '.easy-full-height-table-v6': { + height: 'auto', + }, + }, + }, + }, + }, + }, + [`& > ${token.antCls}-table-pagination`]: { + flex: 'none', + marginBottom: 0, + // [`& ${token.antCls}-pagination-prev ${token.antCls}-pagination-item-link, & ${token.antCls}-pagination-next ${token.antCls}-pagination-item-link`]: + // { + // lineHeight: 1, + // }, + }, + }, + }, + }, }, [`${antCls}-react-easy.easy-confirm-root-color-info`]: { From 25aa7658dea9817c70a8ad9a61ec5f7d99a20d08 Mon Sep 17 00:00:00 2001 From: Fengbao Li <shijistar@gmail.com> Date: Thu, 6 Aug 2026 21:16:22 +0800 Subject: [PATCH 24/39] feat: allow space in name validator --- src/hooks/useValidators.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/hooks/useValidators.ts b/src/hooks/useValidators.ts index d3d673c..bf6650f 100644 --- a/src/hooks/useValidators.ts +++ b/src/hooks/useValidators.ts @@ -20,13 +20,29 @@ const useValidators = (): ValidatorRuleMap => { ); const nameWithMax = useRefFunction((max?: number) => build({ - allowed: { letter: true, number: true, hyphen: true, underscore: true, chineseCharacter: true, max }, + allowed: { + letter: true, + number: true, + hyphen: true, + underscore: true, + chineseCharacter: true, + special: [' '], + max, + }, startsWith: { chineseCharacter: true, letter: true }, }), ); const strongNameWithMax = useRefFunction((max?: number) => build({ - allowed: { letter: true, number: true, hyphen: true, underscore: true, chineseCharacter: true, max }, + allowed: { + letter: true, + number: true, + hyphen: true, + underscore: true, + chineseCharacter: true, + special: [' '], + max, + }, startsWith: { chineseCharacter: true, letter: true }, }), ); From cfd43e12c80485d3f85fb555dc79d86dfed1b8d4 Mon Sep 17 00:00:00 2001 From: Fengbao Li <shijistar@gmail.com> Date: Thu, 6 Aug 2026 21:21:06 +0800 Subject: [PATCH 25/39] feat: enhance documentation and improve useValidator and useValidatorBuilder examples --- .storybook/locales/langs/en-US.ts | 10 +- .storybook/locales/langs/zh-CN.ts | 6 +- .../hooks/useProcessingText/index.stories.tsx | 46 +++---- .../hooks/useRefValue/introduce.en-US.md | 29 ++--- .../hooks/useRefValue/introduce.zh-CN.md | 28 +++-- .../hooks/useValidator/api-doc.en-US.md | 16 ++- .../hooks/useValidator/api-doc.zh-CN.md | 16 ++- .../hooks/useValidator/index.stories.tsx | 95 +++++++++------ .../useValidatorBuilder/api-doc.en-US.md | 26 ++-- .../useValidatorBuilder/api-doc.zh-CN.md | 26 ++-- .../useValidatorBuilder/index.stories.tsx | 115 +++++++++++------- .../useValidatorBuilder/introduce.en-US.md | 20 ++- .../useValidatorBuilder/introduce.zh-CN.md | 20 ++- .../hooks/useValidators/index.stories.tsx | 86 +++++++++---- 14 files changed, 339 insertions(+), 200 deletions(-) diff --git a/.storybook/locales/langs/en-US.ts b/.storybook/locales/langs/en-US.ts index ea6e094..87ff667 100644 --- a/.storybook/locales/langs/en-US.ts +++ b/.storybook/locales/langs/en-US.ts @@ -167,6 +167,7 @@ const enUS = { 'storybook.stories.useValidator.cardTitle': 'useValidator playground', 'storybook.stories.useValidator.description': 'Create a ValidatorRule from BuilderOptions. Configure allowed characters, startsWith, and regex flags to generate a rule compatible with Ant Design forms. Test it live below.', + 'storybook.stories.useValidator.allowedFlags': 'Allowed pattern flags', 'storybook.stories.useValidator.allowedLabel': 'Allowed characters', 'storybook.stories.useValidator.startsWithLabel': 'Must start with', 'storybook.stories.useValidator.letter': 'Letters (a-zA-Z)', @@ -421,7 +422,10 @@ const enUS = { 'storybook.stories.useMovable.tip': 'Position is clamped to the visible area and updates during pointermove. Persist keeps it across reloads.', 'storybook.stories.useProcessingText.argTypes.enabled.description': 'Whether the animation is running.', + 'storybook.stories.useProcessingText.argTypes.interval.description': 'Interval in milliseconds.', 'storybook.stories.useProcessingText.argTypes.maxDots.description': 'Maximum number of dots.', + 'storybook.stories.useProcessingText.argTypes.dotText.description': 'Text for the dots.', + 'storybook.stories.useProcessingText.argTypes.prefixText.description': 'Prefix text.', 'storybook.stories.useProcessingText.cardTitle': 'Animated processing text', 'storybook.stories.useProcessingText.description': 'Create an animated "processing" text that cycles through dots.', 'storybook.stories.useProcessingText.intervalLabel': 'Interval (ms)', @@ -541,10 +545,10 @@ const enUS = { 'storybook.stories.useValidator.argTypes.number.description': 'Allow numbers.', 'storybook.stories.useValidator.argTypes.testValue.description': 'The value to test against the rule.', 'storybook.stories.useValidator.argTypes.underscore.description': 'Allow the underscore character.', - 'storybook.stories.useValidator.fail': 'Fail', + 'storybook.stories.useValidator.fail': 'Failed', 'storybook.stories.useValidator.maxLabel': 'Max length', - 'storybook.stories.useValidator.pass': 'Pass', - 'storybook.stories.useValidator.patternLabel': 'Pattern', + 'storybook.stories.useValidator.pass': 'Passed', + 'storybook.stories.useValidator.patternLabel': 'Result pattern', 'storybook.stories.useValidator.testLabel': 'Test value', 'storybook.stories.useValidator.tip': 'At least one allowed flag must be enabled.', 'storybook.stories.useValidatorBuilder.argTypes.chineseCharacter.description': 'Allow Chinese characters.', diff --git a/.storybook/locales/langs/zh-CN.ts b/.storybook/locales/langs/zh-CN.ts index 4ce11f2..bd11d22 100644 --- a/.storybook/locales/langs/zh-CN.ts +++ b/.storybook/locales/langs/zh-CN.ts @@ -162,6 +162,7 @@ const zhCN = { 'storybook.stories.useValidator.cardTitle': 'useValidator 演示', 'storybook.stories.useValidator.description': '从 BuilderOptions 创建 ValidatorRule。配置允许的字符、开头规则和正则标志,生成与 Ant Design 表单兼容的规则。下方可实时测试。', + 'storybook.stories.useValidator.allowedFlags': '模式标识', 'storybook.stories.useValidator.allowedLabel': '允许的字符', 'storybook.stories.useValidator.startsWithLabel': '必须以...开头', 'storybook.stories.useValidator.letter': '字母 (a-zA-Z)', @@ -387,7 +388,10 @@ const zhCN = { 'storybook.stories.useMovable.positionLabel': '位置', 'storybook.stories.useMovable.tip': '位置会被限制在可见区域内,并在指针移动时更新。开启持久化后刷新页面仍会保留。', 'storybook.stories.useProcessingText.argTypes.enabled.description': '是否运行动画', + 'storybook.stories.useProcessingText.argTypes.interval.description': '间隔时间,单位毫秒', 'storybook.stories.useProcessingText.argTypes.maxDots.description': '点的最大数量', + 'storybook.stories.useProcessingText.argTypes.dotText.description': '点的文本', + 'storybook.stories.useProcessingText.argTypes.prefixText.description': '前缀文本', 'storybook.stories.useProcessingText.cardTitle': '处理中动画文本', 'storybook.stories.useProcessingText.description': '创建循环显示点的"处理中"动画文本。', 'storybook.stories.useProcessingText.intervalLabel': '间隔(毫秒)', @@ -504,7 +508,7 @@ const zhCN = { 'storybook.stories.useValidator.fail': '不通过', 'storybook.stories.useValidator.maxLabel': '最大长度', 'storybook.stories.useValidator.pass': '通过', - 'storybook.stories.useValidator.patternLabel': '正则', + 'storybook.stories.useValidator.patternLabel': '输出正则', 'storybook.stories.useValidator.testLabel': '测试值', 'storybook.stories.useValidator.tip': '至少需要启用一个允许的字符标志。', 'storybook.stories.useValidatorBuilder.argTypes.chineseCharacter.description': '允许中文字符', diff --git a/.storybook/stories/hooks/useProcessingText/index.stories.tsx b/.storybook/stories/hooks/useProcessingText/index.stories.tsx index 5e3f368..43e2fb7 100644 --- a/.storybook/stories/hooks/useProcessingText/index.stories.tsx +++ b/.storybook/stories/hooks/useProcessingText/index.stories.tsx @@ -1,6 +1,5 @@ -import { useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; -import { Alert, Card, Input, InputNumber, Space, Typography } from 'antd'; +import { Alert, Card, Space, Typography } from 'antd'; import useProcessingText from '../../../../src/hooks/useProcessingText'; import storyI18n, { storyT, useStoryT } from '../../../locales'; import apiDocEN from './api-doc.en-US.md?raw'; @@ -10,7 +9,10 @@ import introduceCN from './introduce.zh-CN.md?raw'; interface UseProcessingTextStoryArgs { enabled: boolean; + interval: number; maxDots: number; + dotText: string; + prefixText: string; } const meta: Meta<UseProcessingTextStoryArgs> = { @@ -24,17 +26,32 @@ const meta: Meta<UseProcessingTextStoryArgs> = { }, args: { enabled: true, + interval: 300, maxDots: 3, + dotText: '.', + prefixText: '', }, argTypes: { enabled: { control: 'boolean', description: storyT('storybook.stories.useProcessingText.argTypes.enabled.description'), }, + interval: { + control: { type: 'range', min: 100, max: 2000, step: 50 }, + description: storyT('storybook.stories.useProcessingText.argTypes.interval.description'), + }, maxDots: { control: { type: 'range', min: 1, max: 6, step: 1 }, description: storyT('storybook.stories.useProcessingText.argTypes.maxDots.description'), }, + dotText: { + control: 'text', + description: storyT('storybook.stories.useProcessingText.argTypes.dotText.description'), + }, + prefixText: { + control: 'text', + description: storyT('storybook.stories.useProcessingText.argTypes.prefixText.description'), + }, }, }; @@ -55,42 +72,25 @@ export const Playground: Story = { }, }; -function UseProcessingTextStoryDemo({ enabled, maxDots }: UseProcessingTextStoryArgs) { +function UseProcessingTextStoryDemo({ enabled, interval, maxDots, dotText, prefixText }: UseProcessingTextStoryArgs) { const t = useStoryT(); - const [prefixText, setPrefixText] = useState(''); - const [interval, setInterval] = useState(300); const text = useProcessingText({ enabled, prefixText, interval, maxDots, + dotText, }); return ( <Card variant="outlined" style={{ maxWidth: 920 }} title={t('storybook.stories.useProcessingText.cardTitle')}> <Space orientation="vertical" size="large" style={{ width: '100%' }}> - <Typography.Paragraph style={{ marginBottom: 0 }}> - {t('storybook.stories.useProcessingText.description')} - </Typography.Paragraph> - - <Space wrap> - <Typography.Text strong>{t('storybook.stories.useProcessingText.prefixLabel')}</Typography.Text> - <Input - style={{ maxWidth: 200 }} - placeholder={t('storybook.stories.useProcessingText.prefixPlaceholder')} - value={prefixText} - onChange={(e) => setPrefixText(e.target.value)} - /> - <Typography.Text strong>{t('storybook.stories.useProcessingText.intervalLabel')}</Typography.Text> - <InputNumber min={100} max={2000} step={50} value={interval} onChange={(v) => setInterval(v ?? 300)} /> - </Space> + <Alert type="info" title={t('storybook.stories.useProcessingText.tip')} showIcon /> - <Typography.Title level={3} style={{ margin: 0 }}> + <Typography.Title level={3} style={{ margin: 0, height: 32 }}> {text} </Typography.Title> - - <Alert type="info" title={t('storybook.stories.useProcessingText.tip')} showIcon /> </Space> </Card> ); diff --git a/.storybook/stories/hooks/useRefValue/introduce.en-US.md b/.storybook/stories/hooks/useRefValue/introduce.en-US.md index ec71b77..3198cca 100644 --- a/.storybook/stories/hooks/useRefValue/introduce.en-US.md +++ b/.storybook/stories/hooks/useRefValue/introduce.en-US.md @@ -2,9 +2,10 @@ Get a **mutable ref object** that automatically stays in sync with the latest va ## When to use -- Reading the latest state/props inside `setTimeout`, `setInterval`, or event listeners without re-subscribing. -- Passing a stable ref to child components while keeping its `.current` fresh. - Implementing stable-callback patterns such as `useRefFunction`. +- Reading the latest state/props inside `setTimeout`, `setInterval`, or event listeners without re-subscribing. +- Reading the latest value of a variable inside `useEffect` without adding it to the dependency array. +- Excluding a dependency from `useEffect`. ## Key features @@ -17,19 +18,19 @@ Get a **mutable ref object** that automatically stays in sync with the latest va ```tsx import { useRefValue } from '@tiny-codes/react-easy'; -export function Demo() { - const countRef = useRefValue(count); +export function Demo(props) { + const { enabled } = props; + const enabledRef = useRefValue(enabled); + const [count, setCount] = useState(0); + + useEffect(() => { + if (enabledRef.current) { + console.log(count); + } + }, [count]); - // Read the latest value from inside a stable callback without - // re-creating it when `count` changes. - const capture = () => setCaptured(countRef.current); + const capture = () => setCount(count + 1); - return <button onClick={capture}>Capture {countRef.current}</button>; + return <button onClick={capture}>Capture {count}</button>; } ``` - -## Usage notes - -- Mutating `ref.current` does **not** trigger a re-render. -- It is the building block for `useRefFunction` in this library. -- Use it to read fresh values inside long-lived callbacks without including them in dependency arrays. diff --git a/.storybook/stories/hooks/useRefValue/introduce.zh-CN.md b/.storybook/stories/hooks/useRefValue/introduce.zh-CN.md index b3f5d31..2669bcb 100644 --- a/.storybook/stories/hooks/useRefValue/introduce.zh-CN.md +++ b/.storybook/stories/hooks/useRefValue/introduce.zh-CN.md @@ -2,9 +2,10 @@ ## 适用场景 -- 在 `setTimeout`、`setInterval` 或事件监听中读取最新 state/props,而无需重新订阅。 -- 向子组件传递稳定的 ref,同时保持 `.current` 始终最新。 - 实现 `useRefFunction` 等稳定回调模式。 +- 在 `setTimeout`、`setInterval` 或事件监听中读取最新 state/props,而无需重新订阅。 +- 在 `useEffect` 中使用某个变量值,而无需将其加入依赖数组。 +- 在 `useEffect` 中排除某个依赖项 ## 核心特性 @@ -17,18 +18,19 @@ ```tsx import { useRefValue } from '@tiny-codes/react-easy'; -export function Demo() { - const countRef = useRefValue(count); +export function Demo(props) { + const { enabled } = props; + const enabledRef = useRefValue(enabled); + const [count, setCount] = useState(0); + + useEffect(() => { + if (enabledRef.current) { + console.log(count); + } + }, [count]); - // 在稳定回调中读取最新值,无需因 count 变化重建回调。 - const capture = () => setCaptured(countRef.current); + const capture = () => setCount(count + 1); - return <button onClick={capture}>捕获 {countRef.current}</button>; + return <button onClick={capture}>捕获 {count}</button>; } ``` - -## 使用注意 - -- 修改 `ref.current` **不会**触发重新渲染。 -- 它是本库 `useRefFunction` 的底层实现。 -- 适用于在长期存活的回调中读取最新值,而无需将其加入依赖数组。 diff --git a/.storybook/stories/hooks/useValidator/api-doc.en-US.md b/.storybook/stories/hooks/useValidator/api-doc.en-US.md index cb6a3d7..b69579b 100644 --- a/.storybook/stories/hooks/useValidator/api-doc.en-US.md +++ b/.storybook/stories/hooks/useValidator/api-doc.en-US.md @@ -20,4 +20,18 @@ ### Types -`RuleRegExpFlags`: `letter`, `lowerLetter`, `upperLetter`, `chineseCharacter`, `chinesePunctuation`, `number`, `hyphen`, `underscore`, `special: string[]`, `min: number`, `max: number`. +- RuleRegExpFlags + +| Name | Description | Type | +| -------------------- | -------------------------------------------------------------------------------------------------------------------------- | ---------- | +| `letter` | Include lowercase and uppercase Latin characters. If set to true, `lowerLetter` and `upperLetter` option are not effective | `boolean` | +| `lowerLetter` | Include lowercase English letters | `boolean` | +| `upperLetter` | Include uppercase English letters | `boolean` | +| `chineseCharacter` | Include Chinese characters | `boolean` | +| `chinesePunctuation` | Include Chinese (full-width) punctuation | `boolean` | +| `number` | Include numbers | `boolean` | +| `hyphen` | Include hyphens (-) | `boolean` | +| `underscore` | Include underscores (\_) | `boolean` | +| `special` | Include specified special characters | `string[]` | +| `min` | Minimum number of characters | `number` | +| `max` | Maximum number of characters | `number` | diff --git a/.storybook/stories/hooks/useValidator/api-doc.zh-CN.md b/.storybook/stories/hooks/useValidator/api-doc.zh-CN.md index dfc153d..53193d3 100644 --- a/.storybook/stories/hooks/useValidator/api-doc.zh-CN.md +++ b/.storybook/stories/hooks/useValidator/api-doc.zh-CN.md @@ -20,4 +20,18 @@ ### 类型 -`RuleRegExpFlags`:`letter`、`lowerLetter`、`upperLetter`、`chineseCharacter`、`chinesePunctuation`、`number`、`hyphen`、`underscore`、`special: string[]`、`min: number`、`max: number`。 +- RuleRegExpFlags + +| Name | Description | Type | +| -------------------- | ---------------------------------------------------------------------------- | ---------- | +| `letter` | 包含大小写拉丁字符。如果设置为true,则`lowerLetter`和`upperLetter`属性不生效 | `boolean` | +| `lowerLetter` | 包含小写英文字符 | `boolean` | +| `upperLetter` | 包含大写英文字符 | `boolean` | +| `chineseCharacter` | 包含中文字符 | `boolean` | +| `chinesePunctuation` | 包含中文(全角)标点符号 | `boolean` | +| `number` | 包含数字 | `boolean` | +| `hyphen` | 包含连字符(-) | `boolean` | +| `underscore` | 包含下划线(\_) | `boolean` | +| `special` | 包含指定的特殊字符 | `string[]` | +| `min` | 最小字符数量 | `number` | +| `max` | 最大字符数量 | `number` | diff --git a/.storybook/stories/hooks/useValidator/index.stories.tsx b/.storybook/stories/hooks/useValidator/index.stories.tsx index f9943c4..0328955 100644 --- a/.storybook/stories/hooks/useValidator/index.stories.tsx +++ b/.storybook/stories/hooks/useValidator/index.stories.tsx @@ -1,5 +1,6 @@ +import { useMemo } from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; -import { Alert, Card, Space, Tag, Typography } from 'antd'; +import { Alert, Card, Flex, Form, Input, Space, Tag, Typography } from 'antd'; import useValidator from '../../../../src/hooks/useValidator'; import type { RuleRegExpFlags } from '../../../../src/hooks/useValidatorBuilder'; import storyI18n, { storyT, useStoryT } from '../../../locales'; @@ -76,15 +77,7 @@ export const Playground: Story = { }, }; -function UseValidatorStoryDemo({ - letter, - number, - underscore, - hyphen, - chineseCharacter, - max, - testValue, -}: UseValidatorStoryArgs) { +function UseValidatorStoryDemo({ letter, number, underscore, hyphen, chineseCharacter, max }: UseValidatorStoryArgs) { const t = useStoryT(); const allowed: RuleRegExpFlags = { @@ -96,8 +89,11 @@ function UseValidatorStoryDemo({ max: max || undefined, }; + const [form] = Form.useForm(); + const initialValue = useMemo(() => ({ testContent: 'abc123' }), []); + const testContent = Form.useWatch(['testContent'], form) ?? initialValue.testContent; const rule = useValidator({ allowed }); - const passed = rule.pattern.test(testValue); + const passed = rule.pattern.test(testContent || ''); return ( <Card variant="outlined" style={{ maxWidth: 920 }} title={t('storybook.stories.useValidator.cardTitle')}> @@ -106,36 +102,57 @@ function UseValidatorStoryDemo({ {t('storybook.stories.useValidator.description')} </Typography.Paragraph> - <Space wrap> - <Typography.Text strong> - {t('storybook.stories.useValidator.maxLabel')} {max} - </Typography.Text> - </Space> + <Alert + type="info" + title={t('storybook.stories.useValidatorBuilder.tip') + ' ' + t('storybook.stories.useValidator.tip')} + showIcon + /> + <fieldset style={{ padding: '16px 16px 20px 16px' }}> + <legend> + <Typography.Text strong>{t('storybook.stories.useValidator.allowedFlags')}</Typography.Text> + </legend> + <Space wrap> + <Tag color={letter ? 'green' : 'default'}>a-zA-Z</Tag> + <Tag color={number ? 'green' : 'default'}>0-9</Tag> + <Tag color={underscore ? 'green' : 'default'}>_</Tag> + <Tag color={hyphen ? 'green' : 'default'}>-</Tag> + <Tag color={chineseCharacter ? 'green' : 'default'}>汉字</Tag> + <Tag color={max != null ? 'green' : 'default'}>Max: {max}</Tag> + </Space> + </fieldset> - <Space wrap> - <Tag color={letter ? 'green' : 'default'}>a-zA-Z</Tag> - <Tag color={number ? 'green' : 'default'}>0-9</Tag> - <Tag color={underscore ? 'green' : 'default'}>_</Tag> - <Tag color={hyphen ? 'green' : 'default'}>-</Tag> - <Tag color={chineseCharacter ? 'green' : 'default'}>汉字</Tag> - </Space> + <fieldset style={{ padding: '16px 16px 20px 16px' }}> + <legend> + <Typography.Text strong>{t('storybook.stories.useValidator.patternLabel')}</Typography.Text> + </legend> + <Space wrap> + <Typography.Text code copyable> + {rule.pattern.toString()} + </Typography.Text> + </Space> + </fieldset> - <Space wrap> - <Typography.Text strong>{t('storybook.stories.useValidator.testLabel')}</Typography.Text> - <Typography.Text code>{testValue}</Typography.Text> - <Tag color={passed ? 'green' : 'red'}> - {passed ? t('storybook.stories.useValidator.pass') : t('storybook.stories.useValidator.fail')} - </Tag> - </Space> - - <Space wrap> - <Typography.Text strong>{t('storybook.stories.useValidator.patternLabel')}</Typography.Text> - <Typography.Text code copyable> - {rule.pattern.toString()} - </Typography.Text> - </Space> - - <Alert type="info" title={t('storybook.stories.useValidator.tip')} showIcon /> + <fieldset style={{ padding: '16px 16px 20px 16px' }}> + <legend> + <Typography.Text strong>{t('storybook.stories.useValidator.testLabel')}</Typography.Text> + </legend> + <Typography.Paragraph> + <Form form={form} initialValues={initialValue}> + <Form.Item> + <Flex gap={8} align="center"> + <Form.Item noStyle name="testContent" rules={[rule]}> + <Input.TextArea style={{ flex: 1, minWidth: 0 }} /> + </Form.Item> + {testContent && ( + <Tag color={passed ? 'green' : 'red'} style={{ height: 22 }}> + {passed ? t('storybook.stories.useValidator.pass') : t('storybook.stories.useValidator.fail')} + </Tag> + )} + </Flex> + </Form.Item> + </Form> + </Typography.Paragraph> + </fieldset> </Space> </Card> ); diff --git a/.storybook/stories/hooks/useValidatorBuilder/api-doc.en-US.md b/.storybook/stories/hooks/useValidatorBuilder/api-doc.en-US.md index e3278c5..5b260bd 100644 --- a/.storybook/stories/hooks/useValidatorBuilder/api-doc.en-US.md +++ b/.storybook/stories/hooks/useValidatorBuilder/api-doc.en-US.md @@ -22,19 +22,19 @@ No parameters. `RuleRegExpFlags`: -| Name | Description | Type | -| -------------------- | ---------------------------------------- | ---------- | -| `letter` | Lowercase and uppercase Latin characters | `boolean` | -| `lowerLetter` | Lowercase English letters | `boolean` | -| `upperLetter` | Uppercase English letters | `boolean` | -| `chineseCharacter` | Chinese characters | `boolean` | -| `chinesePunctuation` | Chinese (full-width) punctuation | `boolean` | -| `number` | Numbers | `boolean` | -| `hyphen` | Hyphen `-` | `boolean` | -| `underscore` | Underscore `_` | `boolean` | -| `special` | Specific special characters | `string[]` | -| `min` | Minimum number of characters | `number` | -| `max` | Maximum number of characters | `number` | +| Name | Description | Type | +| -------------------- | --------------------------------------------------------------------------------------------------------------------------- | ---------- | +| `letter` | Include lowercase and uppercase Latin characters. If set to true, `lowerLetter` and `upperLetter` option are not effectives | `boolean` | +| `lowerLetter` | Lowercase English letters | `boolean` | +| `upperLetter` | Uppercase English letters | `boolean` | +| `chineseCharacter` | Chinese characters | `boolean` | +| `chinesePunctuation` | Chinese (full-width) punctuation | `boolean` | +| `number` | Numbers | `boolean` | +| `hyphen` | Hyphen `-` | `boolean` | +| `underscore` | Underscore `_` | `boolean` | +| `special` | Specific special characters | `string[]` | +| `min` | Minimum number of characters | `number` | +| `max` | Maximum number of characters | `number` | `ValidatorRule`: diff --git a/.storybook/stories/hooks/useValidatorBuilder/api-doc.zh-CN.md b/.storybook/stories/hooks/useValidatorBuilder/api-doc.zh-CN.md index 0ca4ffd..ed01eed 100644 --- a/.storybook/stories/hooks/useValidatorBuilder/api-doc.zh-CN.md +++ b/.storybook/stories/hooks/useValidatorBuilder/api-doc.zh-CN.md @@ -22,19 +22,19 @@ `RuleRegExpFlags`: -| 名称 | 说明 | 类型 | -| -------------------- | -------------------- | ---------- | -| `letter` | 大小写拉丁字符 | `boolean` | -| `lowerLetter` | 小写英文字符 | `boolean` | -| `upperLetter` | 大写英文字符 | `boolean` | -| `chineseCharacter` | 中文字符 | `boolean` | -| `chinesePunctuation` | 中文(全角)标点符号 | `boolean` | -| `number` | 数字 | `boolean` | -| `hyphen` | 连字符 `-` | `boolean` | -| `underscore` | 下划线 `_` | `boolean` | -| `special` | 指定的特殊字符 | `string[]` | -| `min` | 最小字符数量 | `number` | -| `max` | 最大字符数量 | `number` | +| 名称 | 说明 | 类型 | +| -------------------- | ------------------------------------------------------------------------------- | ---------- | +| `letter` | 包含大小写拉丁字符。如果设置为 true,则 `lowerLetter` 和 `upperLetter` 选项无效 | `boolean` | +| `lowerLetter` | 小写英文字符 | `boolean` | +| `upperLetter` | 大写英文字符 | `boolean` | +| `chineseCharacter` | 中文字符 | `boolean` | +| `chinesePunctuation` | 中文(全角)标点符号 | `boolean` | +| `number` | 数字 | `boolean` | +| `hyphen` | 连字符 `-` | `boolean` | +| `underscore` | 下划线 `_` | `boolean` | +| `special` | 指定的特殊字符 | `string[]` | +| `min` | 最小字符数量 | `number` | +| `max` | 最大字符数量 | `number` | `ValidatorRule`: diff --git a/.storybook/stories/hooks/useValidatorBuilder/index.stories.tsx b/.storybook/stories/hooks/useValidatorBuilder/index.stories.tsx index 03313d7..f433fe7 100644 --- a/.storybook/stories/hooks/useValidatorBuilder/index.stories.tsx +++ b/.storybook/stories/hooks/useValidatorBuilder/index.stories.tsx @@ -1,7 +1,6 @@ -import { useState } from 'react'; +import { useMemo } from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; -import { Alert, Card, Input, Space, Tag, Typography } from 'antd'; -import type { RuleRegExpFlags } from '../../../../src/hooks/useValidatorBuilder'; +import { Alert, Card, Flex, Form, Input, Space, Tag, Typography } from 'antd'; import useValidatorBuilder from '../../../../src/hooks/useValidatorBuilder'; import storyI18n, { storyT, useStoryT } from '../../../locales'; import apiDocEN from './api-doc.en-US.md?raw'; @@ -97,20 +96,25 @@ function UseValidatorBuilderStoryDemo({ }: UseValidatorBuilderStoryArgs) { const t = useStoryT(); const build = useValidatorBuilder(); - const [draft, setDraft] = useState(''); - const allowed: RuleRegExpFlags = { - letter, - number, - underscore, - hyphen, - chineseCharacter, - max: max || undefined, - }; - - const rule = build({ allowed }); - const passed = rule.pattern.test(testValue || draft); - const patternText = rule.pattern.toString(); + const [form] = Form.useForm(); + const initialValue = useMemo(() => ({ testContent: 'abc123' }), []); + const testContent = Form.useWatch(['testContent'], form) ?? initialValue.testContent; + const rule = useMemo( + () => + build({ + allowed: { + letter, + number, + underscore, + hyphen, + chineseCharacter, + max: max || undefined, + }, + }), + [build, chineseCharacter, hyphen, letter, max, number, underscore], + ); + const passed = rule.pattern.test(testContent || ''); return ( <Card variant="outlined" style={{ maxWidth: 920 }} title={t('storybook.stories.useValidatorBuilder.cardTitle')}> @@ -119,38 +123,57 @@ function UseValidatorBuilderStoryDemo({ {t('storybook.stories.useValidatorBuilder.description')} </Typography.Paragraph> - <Space wrap> - <Input - style={{ maxWidth: 300 }} - placeholder="abc123" - value={draft} - onChange={(e) => setDraft(e.target.value)} - /> - <Typography.Text strong> - {t('storybook.stories.useValidatorBuilder.maxLabel')} {max} - </Typography.Text> - </Space> - - <Space wrap> - <Tag color={letter ? 'green' : 'default'}>a-zA-Z</Tag> - <Tag color={number ? 'green' : 'default'}>0-9</Tag> - <Tag color={underscore ? 'green' : 'default'}>_</Tag> - <Tag color={hyphen ? 'green' : 'default'}>-</Tag> - <Tag color={chineseCharacter ? 'green' : 'default'}>汉字</Tag> - </Space> + <Alert + type="info" + title={t('storybook.stories.useValidatorBuilder.tip') + ' ' + t('storybook.stories.useValidator.tip')} + showIcon + /> + <fieldset style={{ padding: '16px 16px 20px 16px' }}> + <legend> + <Typography.Text strong>{t('storybook.stories.useValidator.allowedFlags')}</Typography.Text> + </legend> + <Space wrap> + <Tag color={letter ? 'green' : 'default'}>a-zA-Z</Tag> + <Tag color={number ? 'green' : 'default'}>0-9</Tag> + <Tag color={underscore ? 'green' : 'default'}>_</Tag> + <Tag color={hyphen ? 'green' : 'default'}>-</Tag> + <Tag color={chineseCharacter ? 'green' : 'default'}>汉字</Tag> + <Tag color={max != null ? 'green' : 'default'}>Max: {max}</Tag> + </Space> + </fieldset> - <Space wrap> - <Typography.Text strong>{t('storybook.stories.useValidatorBuilder.resultLabel')}</Typography.Text> - <Tag color={passed ? 'green' : 'red'}> - {passed ? t('storybook.stories.useValidatorBuilder.pass') : t('storybook.stories.useValidatorBuilder.fail')} - </Tag> - <Typography.Text strong>{t('storybook.stories.useValidatorBuilder.patternLabel')}</Typography.Text> - <Typography.Text code copyable> - {patternText} - </Typography.Text> - </Space> + <fieldset style={{ padding: '16px 16px 20px 16px' }}> + <legend> + <Typography.Text strong>{t('storybook.stories.useValidator.patternLabel')}</Typography.Text> + </legend> + <Space wrap> + <Typography.Text code copyable> + {rule.pattern.toString()} + </Typography.Text> + </Space> + </fieldset> - <Alert type="info" title={t('storybook.stories.useValidatorBuilder.tip')} showIcon /> + <fieldset style={{ padding: '16px 16px 20px 16px' }}> + <legend> + <Typography.Text strong>{t('storybook.stories.useValidator.testLabel')}</Typography.Text> + </legend> + <Typography.Paragraph> + <Form form={form} initialValues={initialValue}> + <Form.Item> + <Flex gap={8}> + <Form.Item noStyle name="testContent" rules={[rule]}> + <Input.TextArea style={{ flex: 1, minWidth: 0 }} /> + </Form.Item> + {testContent && ( + <Tag color={passed ? 'green' : 'red'} style={{ display: 'flex', alignItems: 'center' }}> + {passed ? t('storybook.stories.useValidator.pass') : t('storybook.stories.useValidator.fail')} + </Tag> + )} + </Flex> + </Form.Item> + </Form> + </Typography.Paragraph> + </fieldset> </Space> </Card> ); diff --git a/.storybook/stories/hooks/useValidatorBuilder/introduce.en-US.md b/.storybook/stories/hooks/useValidatorBuilder/introduce.en-US.md index 09040e9..00a809e 100644 --- a/.storybook/stories/hooks/useValidatorBuilder/introduce.en-US.md +++ b/.storybook/stories/hooks/useValidatorBuilder/introduce.en-US.md @@ -16,22 +16,34 @@ Get a **validation rule builder** that constructs Ant-Design-compatible regex ru ## Sample code ```tsx +import { useMemo } from 'react'; import { type RuleRegExpFlags, useValidatorBuilder } from '@tiny-codes/react-easy'; import { Form, Input } from 'antd'; export function Demo() { const build = useValidatorBuilder(); - const usernameRule = build({ - allowed: { letter: true, number: true, underscore: true, min: 6, max: 20 }, - startsWith: { letter: true }, - }); + const usernameRule = useMemo( + () => + build({ + allowed: { letter: true, number: true, underscore: true, min: 6, max: 20 }, + startsWith: { letter: true }, + }), + [build], + ); + const passwordRule = useMemo( + () => build({ allowed: { letter: true, number: true, special: true, min: 8 } }), + [build], + ); return ( <Form> <Form.Item name="username" label="Username" rules={[usernameRule]}> <Input /> </Form.Item> + <Form.Item name="password" label="Password" rules={[passwordRule]}> + <Input.Password /> + </Form.Item> </Form> ); } diff --git a/.storybook/stories/hooks/useValidatorBuilder/introduce.zh-CN.md b/.storybook/stories/hooks/useValidatorBuilder/introduce.zh-CN.md index ad2ebab..9b84090 100644 --- a/.storybook/stories/hooks/useValidatorBuilder/introduce.zh-CN.md +++ b/.storybook/stories/hooks/useValidatorBuilder/introduce.zh-CN.md @@ -16,22 +16,34 @@ ## 示例代码 ```tsx +import { useMemo } from 'react'; import { type RuleRegExpFlags, useValidatorBuilder } from '@tiny-codes/react-easy'; import { Form, Input } from 'antd'; export function Demo() { const build = useValidatorBuilder(); - const usernameRule = build({ - allowed: { letter: true, number: true, underscore: true, min: 6, max: 20 }, - startsWith: { letter: true }, - }); + const usernameRule = useMemo( + () => + build({ + allowed: { letter: true, number: true, underscore: true, min: 6, max: 20 }, + startsWith: { letter: true }, + }), + [build], + ); + const passwordRule = useMemo( + () => build({ allowed: { letter: true, number: true, special: true, min: 8 } }), + [build], + ); return ( <Form> <Form.Item name="username" label="用户名" rules={[usernameRule]}> <Input /> </Form.Item> + <Form.Item name="password" label="密码" rules={[passwordRule]}> + <Input.Password /> + </Form.Item> </Form> ); } diff --git a/.storybook/stories/hooks/useValidators/index.stories.tsx b/.storybook/stories/hooks/useValidators/index.stories.tsx index 7b997db..0507b33 100644 --- a/.storybook/stories/hooks/useValidators/index.stories.tsx +++ b/.storybook/stories/hooks/useValidators/index.stories.tsx @@ -1,6 +1,7 @@ +import { useEffect, useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; import { Alert, Card, Input, Select, Space, Tag, Typography } from 'antd'; -import useValidators from '../../../../src/hooks/useValidators'; +import useValidators, { type ValidatorRuleMap } from '../../../../src/hooks/useValidators'; import storyI18n, { storyT, useStoryT } from '../../../locales'; import apiDocEN from './api-doc.en-US.md?raw'; import apiDocCN from './api-doc.zh-CN.md?raw'; @@ -8,8 +9,7 @@ import introduceEN from './introduce.en-US.md?raw'; import introduceCN from './introduce.zh-CN.md?raw'; interface UseValidatorsStoryArgs { - rule: string; - value: string; + rule: keyof ValidatorRuleMap; } const meta: Meta<UseValidatorsStoryArgs> = { @@ -23,7 +23,6 @@ const meta: Meta<UseValidatorsStoryArgs> = { }, args: { rule: 'email', - value: 'user@example.com', }, argTypes: { rule: { @@ -46,13 +45,9 @@ const meta: Meta<UseValidatorsStoryArgs> = { 'strongName', 'strongNameMax64', 'strongNameMax128', - ], + ] satisfies (keyof ValidatorRuleMap)[], description: storyT('storybook.stories.useValidators.argTypes.rule.description'), }, - value: { - control: 'text', - description: storyT('storybook.stories.useValidators.argTypes.value.description'), - }, }, }; @@ -73,15 +68,62 @@ export const Playground: Story = { }, }; -function UseValidatorsStoryDemo({ rule, value }: UseValidatorsStoryArgs) { +function UseValidatorsStoryDemo({ rule }: UseValidatorsStoryArgs) { const t = useStoryT(); const validators = useValidators(); + const [ruleName, setRuleName] = useState(rule); // eslint-disable-next-line @typescript-eslint/no-explicit-any - const selected = (validators as any)[rule] ?? validators.email; + const selectedRule = (validators as any)[ruleName] ?? validators.email; + + const [value, setValue] = useState('user@example.com'); + const passed = selectedRule.pattern.test(value); + const patternText = selectedRule.pattern.toString(); + + useEffect(() => { + setRuleName(rule); + }, [rule]); - const passed = selected.pattern.test(value); - const patternText = selected.pattern.toString(); + useEffect(() => { + switch (ruleName) { + case 'email': + setValue('user@example.com'); + break; + case 'cnMobile': + setValue('13800138000'); + break; + case 'number': + setValue('123'); + break; + case 'floatNumber': + setValue('123.45'); + break; + case 'ip': + setValue('192.168.0.1'); + break; + case 'password': + setValue('ju7g_5ds@123'); + break; + case 'code': + case 'codeMax20': + case 'codeMax64': + case 'codeMax128': + case 'codeWithMax': + setValue('SCB_500_ORG_24PK'); + break; + case 'name': + case 'nameMax20': + case 'nameMax64': + case 'nameMax128': + case 'nameWithMax': + case 'strongName': + case 'strongNameMax64': + case 'strongNameMax128': + case 'strongNameWithMax': + setValue('Johnathan Alexander'); + break; + } + }, [ruleName]); return ( <Card variant="outlined" style={{ maxWidth: 920 }} title={t('storybook.stories.useValidators.cardTitle')}> @@ -89,11 +131,12 @@ function UseValidatorsStoryDemo({ rule, value }: UseValidatorsStoryArgs) { <Typography.Paragraph style={{ marginBottom: 0 }}> {t('storybook.stories.useValidators.description')} </Typography.Paragraph> + <Alert type="info" title={t('storybook.stories.useValidators.tip')} showIcon /> <Space wrap> <Select style={{ minWidth: 200 }} - value={rule} + value={ruleName} options={[ 'number', 'floatNumber', @@ -114,22 +157,17 @@ function UseValidatorsStoryDemo({ rule, value }: UseValidatorsStoryArgs) { 'strongNameMax128', ].map((key) => ({ value: key, label: key }))} onChange={(v) => { - // controlled by args - void v; + setRuleName(v); }} /> <Input - style={{ maxWidth: 300 }} + style={{ width: 240 }} placeholder={t('storybook.stories.useValidators.valuePlaceholder')} value={value} onChange={(e) => { - void e; + setValue(e.target.value); }} /> - </Space> - - <Space wrap> - <Typography.Text strong>{t('storybook.stories.useValidators.resultLabel')}</Typography.Text> <Tag color={passed ? 'green' : 'red'}> {passed ? t('storybook.stories.useValidators.pass') : t('storybook.stories.useValidators.fail')} </Tag> @@ -139,9 +177,7 @@ function UseValidatorsStoryDemo({ rule, value }: UseValidatorsStoryArgs) { <Typography.Text code copyable> {patternText} </Typography.Text> - <Typography.Text type="secondary">{selected.message}</Typography.Text> - - <Alert type="info" title={t('storybook.stories.useValidators.tip')} showIcon /> + <Typography.Text type="secondary">{selectedRule.message}</Typography.Text> </Space> </Card> ); From 6c1b21a2527aec9a9fecd4a0fc27191ff442160d Mon Sep 17 00:00:00 2001 From: Fengbao Li <shijistar@gmail.com> Date: Thu, 6 Aug 2026 22:58:47 +0800 Subject: [PATCH 26/39] docs(storybook): add bilingual stories for 7 utils modules Add complete bilingual (en-US/zh-CN) Storybook stories for the utils modules introduced by the recent utils refactor: AudioPlayer, base64, color, crypto, math, stream, string. Each story includes: - index.stories.tsx with an interactive Playground demo - introduce.*.md with Overview, When to use, Key features, Sample code, Usage notes - api-doc.*.md with API tables verified against the real source exports Also register the corresponding storybook.stories.* locale keys in en-US.ts and zh-CN.ts. Verified: prettier, eslint, tsc --noEmit, storybook build all pass. --- .storybook/locales/langs/en-US.ts | 105 +++++++ .storybook/locales/langs/zh-CN.ts | 97 +++++++ .../utils/AudioPlayer/api-doc.en-US.md | 44 +++ .../utils/AudioPlayer/api-doc.zh-CN.md | 44 +++ .../utils/AudioPlayer/index.stories.tsx | 268 ++++++++++++++++++ .../utils/AudioPlayer/introduce.en-US.md | 50 ++++ .../utils/AudioPlayer/introduce.zh-CN.md | 50 ++++ .../stories/utils/base64/api-doc.en-US.md | 40 +++ .../stories/utils/base64/api-doc.zh-CN.md | 40 +++ .../stories/utils/base64/index.stories.tsx | 114 ++++++++ .../stories/utils/base64/introduce.en-US.md | 36 +++ .../stories/utils/base64/introduce.zh-CN.md | 36 +++ .../stories/utils/color/api-doc.en-US.md | 19 ++ .../stories/utils/color/api-doc.zh-CN.md | 19 ++ .../stories/utils/color/index.stories.tsx | 102 +++++++ .../stories/utils/color/introduce.en-US.md | 33 +++ .../stories/utils/color/introduce.zh-CN.md | 33 +++ .../stories/utils/crypto/api-doc.en-US.md | 22 ++ .../stories/utils/crypto/api-doc.zh-CN.md | 22 ++ .../stories/utils/crypto/index.stories.tsx | 141 +++++++++ .../stories/utils/crypto/introduce.en-US.md | 41 +++ .../stories/utils/crypto/introduce.zh-CN.md | 41 +++ .../stories/utils/math/api-doc.en-US.md | 26 ++ .../stories/utils/math/api-doc.zh-CN.md | 26 ++ .../stories/utils/math/index.stories.tsx | 94 ++++++ .../stories/utils/math/introduce.en-US.md | 36 +++ .../stories/utils/math/introduce.zh-CN.md | 36 +++ .../stories/utils/stream/api-doc.en-US.md | 32 +++ .../stories/utils/stream/api-doc.zh-CN.md | 32 +++ .../stories/utils/stream/index.stories.tsx | 190 +++++++++++++ .../stories/utils/stream/introduce.en-US.md | 48 ++++ .../stories/utils/stream/introduce.zh-CN.md | 48 ++++ .../stories/utils/string/api-doc.en-US.md | 17 ++ .../stories/utils/string/api-doc.zh-CN.md | 17 ++ .../stories/utils/string/index.stories.tsx | 120 ++++++++ .../stories/utils/string/introduce.en-US.md | 32 +++ .../stories/utils/string/introduce.zh-CN.md | 32 +++ 37 files changed, 2183 insertions(+) create mode 100644 .storybook/stories/utils/AudioPlayer/api-doc.en-US.md create mode 100644 .storybook/stories/utils/AudioPlayer/api-doc.zh-CN.md create mode 100644 .storybook/stories/utils/AudioPlayer/index.stories.tsx create mode 100644 .storybook/stories/utils/AudioPlayer/introduce.en-US.md create mode 100644 .storybook/stories/utils/AudioPlayer/introduce.zh-CN.md create mode 100644 .storybook/stories/utils/base64/api-doc.en-US.md create mode 100644 .storybook/stories/utils/base64/api-doc.zh-CN.md create mode 100644 .storybook/stories/utils/base64/index.stories.tsx create mode 100644 .storybook/stories/utils/base64/introduce.en-US.md create mode 100644 .storybook/stories/utils/base64/introduce.zh-CN.md create mode 100644 .storybook/stories/utils/color/api-doc.en-US.md create mode 100644 .storybook/stories/utils/color/api-doc.zh-CN.md create mode 100644 .storybook/stories/utils/color/index.stories.tsx create mode 100644 .storybook/stories/utils/color/introduce.en-US.md create mode 100644 .storybook/stories/utils/color/introduce.zh-CN.md create mode 100644 .storybook/stories/utils/crypto/api-doc.en-US.md create mode 100644 .storybook/stories/utils/crypto/api-doc.zh-CN.md create mode 100644 .storybook/stories/utils/crypto/index.stories.tsx create mode 100644 .storybook/stories/utils/crypto/introduce.en-US.md create mode 100644 .storybook/stories/utils/crypto/introduce.zh-CN.md create mode 100644 .storybook/stories/utils/math/api-doc.en-US.md create mode 100644 .storybook/stories/utils/math/api-doc.zh-CN.md create mode 100644 .storybook/stories/utils/math/index.stories.tsx create mode 100644 .storybook/stories/utils/math/introduce.en-US.md create mode 100644 .storybook/stories/utils/math/introduce.zh-CN.md create mode 100644 .storybook/stories/utils/stream/api-doc.en-US.md create mode 100644 .storybook/stories/utils/stream/api-doc.zh-CN.md create mode 100644 .storybook/stories/utils/stream/index.stories.tsx create mode 100644 .storybook/stories/utils/stream/introduce.en-US.md create mode 100644 .storybook/stories/utils/stream/introduce.zh-CN.md create mode 100644 .storybook/stories/utils/string/api-doc.en-US.md create mode 100644 .storybook/stories/utils/string/api-doc.zh-CN.md create mode 100644 .storybook/stories/utils/string/index.stories.tsx create mode 100644 .storybook/stories/utils/string/introduce.en-US.md create mode 100644 .storybook/stories/utils/string/introduce.zh-CN.md diff --git a/.storybook/locales/langs/en-US.ts b/.storybook/locales/langs/en-US.ts index 87ff667..9e8d982 100644 --- a/.storybook/locales/langs/en-US.ts +++ b/.storybook/locales/langs/en-US.ts @@ -1,4 +1,26 @@ const enUS = { + 'storybook.stories.AudioPlayer.actions.backward': 'Back {{seconds}}s', + 'storybook.stories.AudioPlayer.actions.forward': 'Forward {{seconds}}s', + 'storybook.stories.AudioPlayer.actions.pause': 'Pause', + 'storybook.stories.AudioPlayer.actions.play': 'Play', + 'storybook.stories.AudioPlayer.actions.stop': 'Stop', + 'storybook.stories.AudioPlayer.applySource': 'Apply source', + 'storybook.stories.AudioPlayer.argTypes.initialVolume.description': + 'Initial player volume used when the instance is created.', + 'storybook.stories.AudioPlayer.argTypes.seekStep.description': + 'Demo-only option that controls how many seconds the forward/backward buttons seek.', + 'storybook.stories.AudioPlayer.argTypes.source.description': + 'Initial audio source URL used by the demo. Prefer local static assets served by Storybook for reliable playback.', + 'storybook.stories.AudioPlayer.cardTitle': 'AudioPlayer playground', + 'storybook.stories.AudioPlayer.currentTime': 'Current time: {{value}}s', + 'storybook.stories.AudioPlayer.duration': 'Duration: {{value}}s', + 'storybook.stories.AudioPlayer.emptyLog': 'No events yet', + 'storybook.stories.AudioPlayer.eventLogTitle': 'Event log', + 'storybook.stories.AudioPlayer.progressLabel': 'Playback progress', + 'storybook.stories.AudioPlayer.sourcePlaceholder': 'Enter a remote audio URL', + 'storybook.stories.AudioPlayer.status.paused': 'Paused', + 'storybook.stories.AudioPlayer.status.playing': 'Playing', + 'storybook.stories.AudioPlayer.volumeLabel': 'Volume', 'storybook.stories.Backgrounds.light': 'Light Mode', 'storybook.stories.Backgrounds.dark': 'Dark Mode', 'storybook.stories.BreakLines.args.value': 'First line\nSecond line\nThird line', @@ -89,6 +111,89 @@ const enUS = { 'storybook.stories.VirtualTextViewer.demo.viewerLine': 'This is a virtualized plain-text viewer powered by Pretext.', 'storybook.stories.VirtualTextViewer.demo.wrappingLine': 'It keeps wrapping stable for mixed content like 北京, مرحبا, emoji 👩‍🚀, and long URLs.', + 'storybook.stories.base64.actions.decode': 'Decode', + 'storybook.stories.base64.actions.encode': 'Encode', + 'storybook.stories.base64.argTypes.urlSafe.description': + 'Use the URL-safe Base64 alphabet (`+`/`/` → `-`/`_`, padding stripped).', + 'storybook.stories.base64.bufferLabel': 'Decoded ArrayBuffer bytes', + 'storybook.stories.base64.cardTitle': 'Base64 utilities playground', + 'storybook.stories.base64.decodedLabel': 'Decoded', + 'storybook.stories.base64.description': + 'Encode UTF-8 text to Base64 (standard or URL-safe) and decode it back, or bridge ArrayBuffer and Base64.', + 'storybook.stories.base64.encodedLabel': 'Encoded', + 'storybook.stories.base64.inputPlaceholder': 'Type text or a Base64 string', + 'storybook.stories.base64.tip': + 'Decoding requires a valid Base64 string; the urlSafe flag must match between encode and decode.', + 'storybook.stories.base64.urlSafeLabel': 'URL-safe', + 'storybook.stories.color.argTypes.color.description': + 'Color value to compute relative luminance for (`#rrggbb`, `#rgb`, or `rgb(r, g, b)`).', + 'storybook.stories.color.cardTitle': 'Color luminance playground', + 'storybook.stories.color.darkTag': 'Dark', + 'storybook.stories.color.description': + 'Compute the WCAG relative luminance of a color and judge whether it is light or dark.', + 'storybook.stories.color.inputPlaceholder': 'Enter a color (#hex or rgb())', + 'storybook.stories.color.lightTag': 'Light', + 'storybook.stories.color.luminanceLabel': 'Relative luminance', + 'storybook.stories.color.tip': 'Luminance below 0.5 is treated as dark; above or equal to 0.5 as light.', + 'storybook.stories.crypto.actions.decrypt': 'Decrypt', + 'storybook.stories.crypto.actions.encrypt': 'Encrypt', + 'storybook.stories.crypto.advancedLabel': 'Advanced', + 'storybook.stories.crypto.argTypes.advanced.description': + 'Use the double-pass advancedEncrypt/advancedDecrypt flow instead of the standard AES flow.', + 'storybook.stories.crypto.argTypes.key.description': 'Secret key used for encryption and decryption.', + 'storybook.stories.crypto.argTypes.text.description': 'Plain text to encrypt in the demo.', + 'storybook.stories.crypto.cardTitle': 'Crypto utilities playground', + 'storybook.stories.crypto.cipherLabel': 'Cipher text', + 'storybook.stories.crypto.description': + 'Encrypt plain text with AES (or the advanced double-pass flow) and decrypt it back with a shared key.', + 'storybook.stories.crypto.keyLabel': 'Key', + 'storybook.stories.crypto.plainTextLabel': 'Plain text', + 'storybook.stories.crypto.resultLabel': 'Decrypted result', + 'storybook.stories.crypto.tip': 'Keep the key secret and use the matching decrypt function for the flow you chose.', + 'storybook.stories.math.actions.randomDecimal': 'random()', + 'storybook.stories.math.actions.randomInteger': 'random(min, max)', + 'storybook.stories.math.argTypes.max.description': 'Maximum value (inclusive) for random(min, max).', + 'storybook.stories.math.argTypes.min.description': 'Minimum value (inclusive) for random(min, max).', + 'storybook.stories.math.cardTitle': 'Math utilities playground', + 'storybook.stories.math.decimalResultLabel': 'random() result', + 'storybook.stories.math.description': + 'Generate a random decimal in [0, 1) with random(), or an inclusive random integer in [min, max] with random(min, max).', + 'storybook.stories.math.integerResultLabel': 'random(min, max) result', + 'storybook.stories.math.maxLabel': 'Max', + 'storybook.stories.math.minLabel': 'Min', + 'storybook.stories.math.tip': 'random(min, max) requires finite integer bounds; min must not exceed max.', + 'storybook.stories.stream.actions.flush': 'Flush', + 'storybook.stories.stream.actions.push': 'Push frame', + 'storybook.stories.stream.actions.reset': 'Reset', + 'storybook.stories.stream.argTypes.chunkLength.description': 'Number of samples pushed per frame in the demo.', + 'storybook.stories.stream.argTypes.sliceSize.description': + "Target accumulated sample count that triggers onSlice in 'size' mode.", + 'storybook.stories.stream.cardTitle': 'Stream time slicer playground', + 'storybook.stories.stream.chunkLengthLabel': 'Frame length', + 'storybook.stories.stream.description': + "Push simulated mono PCM frames into a StreamTimeSlicerClass configured with sliceMode: 'size'; once accumulated samples reach the target, onSlice emits a merged Float32Array.", + 'storybook.stories.stream.emptyLog': 'No slices emitted yet', + 'storybook.stories.stream.logChannels': 'Channels: {{value}}', + 'storybook.stories.stream.logDuration': 'Duration: {{value}}ms', + 'storybook.stories.stream.logSamples': 'Samples: {{value}}', + 'storybook.stories.stream.logTitle': 'Emitted slices', + 'storybook.stories.stream.modeTag': 'Mode: {{value}}', + 'storybook.stories.stream.sliceSizeLabel': 'Slice size', + 'storybook.stories.stream.tip': + 'Use flush() to force output the current accumulation (below threshold), or reset() to clear without output.', + 'storybook.stories.string.actions.generate': 'Generate', + 'storybook.stories.string.actions.read': 'Read from Blob', + 'storybook.stories.string.argTypes.length.description': 'Length of the random string generated by randomChars.', + 'storybook.stories.string.argTypes.sourceText.description': + 'Initial text used for the readTextAnyEncoding Blob demo.', + 'storybook.stories.string.cardTitle': 'String utilities playground', + 'storybook.stories.string.description': + 'Generate a random alphanumeric string with randomChars, then read text back from a Blob with readTextAnyEncoding.', + 'storybook.stories.string.randomCharsTitle': 'randomChars', + 'storybook.stories.string.readTextTitle': 'readTextAnyEncoding', + 'storybook.stories.string.resultLabel': 'Result', + 'storybook.stories.string.tip': + 'readTextAnyEncoding detects common encodings (UTF-8, GBK/GB2312, etc.); non-UTF-8 text is best served as a Blob.', 'storybook.stories.useAudioPlayer.cardTitle': 'Audio player playground', 'storybook.stories.useAudioPlayer.sourcePlaceholder': 'Enter a remote audio URL', 'storybook.stories.useAudioPlayer.applySource': 'Apply source', diff --git a/.storybook/locales/langs/zh-CN.ts b/.storybook/locales/langs/zh-CN.ts index bd11d22..98c1938 100644 --- a/.storybook/locales/langs/zh-CN.ts +++ b/.storybook/locales/langs/zh-CN.ts @@ -1,4 +1,24 @@ const zhCN = { + 'storybook.stories.AudioPlayer.actions.backward': '后退 {{seconds}} 秒', + 'storybook.stories.AudioPlayer.actions.forward': '前进 {{seconds}} 秒', + 'storybook.stories.AudioPlayer.actions.pause': '暂停', + 'storybook.stories.AudioPlayer.actions.play': '播放', + 'storybook.stories.AudioPlayer.actions.stop': '停止', + 'storybook.stories.AudioPlayer.applySource': '应用音源', + 'storybook.stories.AudioPlayer.argTypes.initialVolume.description': '创建实例时使用的初始音量。', + 'storybook.stories.AudioPlayer.argTypes.seekStep.description': '仅用于演示,控制前进/后退按钮跳转的秒数。', + 'storybook.stories.AudioPlayer.argTypes.source.description': + '演示使用的初始音频源 URL。建议使用 Storybook 托管的本地静态资源以保证播放可靠。', + 'storybook.stories.AudioPlayer.cardTitle': 'AudioPlayer 演示', + 'storybook.stories.AudioPlayer.currentTime': '当前时间:{{value}}秒', + 'storybook.stories.AudioPlayer.duration': '总时长:{{value}}秒', + 'storybook.stories.AudioPlayer.emptyLog': '暂无事件', + 'storybook.stories.AudioPlayer.eventLogTitle': '事件日志', + 'storybook.stories.AudioPlayer.progressLabel': '播放进度', + 'storybook.stories.AudioPlayer.sourcePlaceholder': '请输入远程音频地址', + 'storybook.stories.AudioPlayer.status.paused': '已暂停', + 'storybook.stories.AudioPlayer.status.playing': '播放中', + 'storybook.stories.AudioPlayer.volumeLabel': '音量', 'storybook.stories.Backgrounds.light': '浅色模式', 'storybook.stories.Backgrounds.dark': '深色模式', 'storybook.stories.BreakLines.args.value': '第一行\n第二行\n第三行', @@ -87,6 +107,83 @@ const zhCN = { 'storybook.stories.VirtualTextViewer.demo.viewerLine': '这是一个由 Pretext 驱动的虚拟化纯文本查看器。', 'storybook.stories.VirtualTextViewer.demo.wrappingLine': '它能让包含北京、مرحبا、emoji 👩‍🚀 和长链接的混合内容保持稳定换行。', + 'storybook.stories.base64.actions.decode': '解码', + 'storybook.stories.base64.actions.encode': '编码', + 'storybook.stories.base64.argTypes.urlSafe.description': + '使用 URL 安全的 Base64 字符集(`+`/`/` 转为 `-`/`_`,并去除补位)。', + 'storybook.stories.base64.bufferLabel': '解码后 ArrayBuffer 字节数', + 'storybook.stories.base64.cardTitle': 'Base64 工具演示', + 'storybook.stories.base64.decodedLabel': '解码结果', + 'storybook.stories.base64.description': + '将 UTF-8 文本编码为 Base64(标准或 URL 安全)并解码还原,或在 ArrayBuffer 与 Base64 之间转换。', + 'storybook.stories.base64.encodedLabel': '编码结果', + 'storybook.stories.base64.inputPlaceholder': '输入文本或 Base64 字符串', + 'storybook.stories.base64.tip': '解码需要合法的 Base64 字符串;编码与解码的 urlSafe 标志必须一致。', + 'storybook.stories.base64.urlSafeLabel': 'URL 安全', + 'storybook.stories.color.argTypes.color.description': + '用于计算相对亮度的颜色值(`#rrggbb`、`#rgb` 或 `rgb(r, g, b)`)。', + 'storybook.stories.color.cardTitle': '颜色亮度演示', + 'storybook.stories.color.darkTag': '深色', + 'storybook.stories.color.description': '计算颜色的 WCAG 相对亮度,并判断其属于亮色还是暗色。', + 'storybook.stories.color.inputPlaceholder': '输入颜色(#hex 或 rgb())', + 'storybook.stories.color.lightTag': '亮色', + 'storybook.stories.color.luminanceLabel': '相对亮度', + 'storybook.stories.color.tip': '亮度小于 0.5 视为深色;大于或等于 0.5 视为亮色。', + 'storybook.stories.crypto.actions.decrypt': '解密', + 'storybook.stories.crypto.actions.encrypt': '加密', + 'storybook.stories.crypto.advancedLabel': '高级模式', + 'storybook.stories.crypto.argTypes.advanced.description': + '使用双重加密流程(advancedEncrypt/advancedDecrypt)而非标准 AES 流程。', + 'storybook.stories.crypto.argTypes.key.description': '用于加密与解密的密钥。', + 'storybook.stories.crypto.argTypes.text.description': '演示中要加密的明文。', + 'storybook.stories.crypto.cardTitle': '加密工具演示', + 'storybook.stories.crypto.cipherLabel': '密文', + 'storybook.stories.crypto.description': '使用 AES(或高级双重加密流程)加密明文,并用共享密钥解密还原。', + 'storybook.stories.crypto.keyLabel': '密钥', + 'storybook.stories.crypto.plainTextLabel': '明文', + 'storybook.stories.crypto.resultLabel': '解密结果', + 'storybook.stories.crypto.tip': '请妥善保管密钥,并为所选流程使用对应的解密函数。', + 'storybook.stories.math.actions.randomDecimal': 'random()', + 'storybook.stories.math.actions.randomInteger': 'random(min, max)', + 'storybook.stories.math.argTypes.max.description': 'random(min, max) 的最大值(包含)。', + 'storybook.stories.math.argTypes.min.description': 'random(min, max) 的最小值(包含)。', + 'storybook.stories.math.cardTitle': '数学工具演示', + 'storybook.stories.math.decimalResultLabel': 'random() 结果', + 'storybook.stories.math.description': + '使用 random() 生成 [0, 1) 之间的随机小数,或使用 random(min, max) 生成包含两端的随机整数。', + 'storybook.stories.math.integerResultLabel': 'random(min, max) 结果', + 'storybook.stories.math.maxLabel': '最大值', + 'storybook.stories.math.minLabel': '最小值', + 'storybook.stories.math.tip': 'random(min, max) 要求有限的整数边界,且 min 不能大于 max。', + 'storybook.stories.stream.actions.flush': '强制输出', + 'storybook.stories.stream.actions.push': '推入一帧', + 'storybook.stories.stream.actions.reset': '清空', + 'storybook.stories.stream.argTypes.chunkLength.description': '演示中每帧推入的采样数。', + 'storybook.stories.stream.argTypes.sliceSize.description': '‘size’ 模式下触发 onSlice 的目标累积采样数。', + 'storybook.stories.stream.cardTitle': '流切片器演示', + 'storybook.stories.stream.chunkLengthLabel': '帧长度', + 'storybook.stories.stream.description': + "向配置了 sliceMode: 'size' 的 StreamTimeSlicerClass 推入模拟的单声道 PCM 帧;当累积采样数达到目标时,onSlice 输出合并后的 Float32Array。", + 'storybook.stories.stream.emptyLog': '暂无切片输出', + 'storybook.stories.stream.logChannels': '声道数:{{value}}', + 'storybook.stories.stream.logDuration': '时长:{{value}}ms', + 'storybook.stories.stream.logSamples': '采样数:{{value}}', + 'storybook.stories.stream.logTitle': '已输出切片', + 'storybook.stories.stream.modeTag': '模式:{{value}}', + 'storybook.stories.stream.sliceSizeLabel': '切片大小', + 'storybook.stories.stream.tip': '使用 flush() 强制输出当前累积(即使未达阈值),或使用 reset() 清空且不输出。', + 'storybook.stories.string.actions.generate': '生成', + 'storybook.stories.string.actions.read': '从 Blob 读取', + 'storybook.stories.string.argTypes.length.description': 'randomChars 生成的随机字符串长度。', + 'storybook.stories.string.argTypes.sourceText.description': 'readTextAnyEncoding Blob 演示使用的初始文本。', + 'storybook.stories.string.cardTitle': '字符串工具演示', + 'storybook.stories.string.description': + '使用 randomChars 生成随机字母数字字符串,再用 readTextAnyEncoding 从 Blob 读取文本。', + 'storybook.stories.string.randomCharsTitle': 'randomChars', + 'storybook.stories.string.readTextTitle': 'readTextAnyEncoding', + 'storybook.stories.string.resultLabel': '结果', + 'storybook.stories.string.tip': + 'readTextAnyEncoding 可识别常见编码(UTF-8、GBK/GB2312 等);非 UTF-8 文本建议以 Blob 形式传入。', 'storybook.stories.useAudioPlayer.cardTitle': '音频播放器演示', 'storybook.stories.useAudioPlayer.sourcePlaceholder': '请输入远程音频地址', 'storybook.stories.useAudioPlayer.applySource': '应用音源', diff --git a/.storybook/stories/utils/AudioPlayer/api-doc.en-US.md b/.storybook/stories/utils/AudioPlayer/api-doc.en-US.md new file mode 100644 index 0000000..b971c39 --- /dev/null +++ b/.storybook/stories/utils/AudioPlayer/api-doc.en-US.md @@ -0,0 +1,44 @@ +## API + +### Class: `AudioPlayer` + +| Member | Description | Signature | +| --------------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| `constructor` | Create an audio player instance | `new AudioPlayer(options?: AudioPlayerInit): AudioPlayer` | +| `isPlaying` | Check if audio is currently playing | `get isPlaying: boolean` | +| `currentTime` | Get current playback time (seconds) | `get currentTime: number` | +| `duration` | Get total audio duration (seconds) | `get duration: number` | +| `volume` | Get current volume value (0-1) | `get volume: number` | +| `play` | Play audio; resumes from the pause position if previously paused | `play(): Promise<void>` | +| `pause` | Pause audio playback; resumes from the current position | `pause(): void` | +| `stop` | Stop audio playback; progress resets to the beginning | `stop(): void` | +| `seek` | Set current playback time (seconds) | `seek(time: number): void` | +| `seekForward` | Seek forward by a certain number of seconds | `seekForward(seconds: number): void` | +| `seekBackward` | Seek backward by a certain number of seconds | `seekBackward(seconds: number): void` | +| `setAudioSource` | Update the audio source (URL or streaming data) | `setAudioSource(source?: AudioSource): Promise<{ stopLoading: () => void }>` | +| `setVolume` | Set volume to a specific value (0-1) | `setVolume(value: number): void` | +| `volumeUp` | Increase volume (default 10% per call) | `volumeUp(percent?: number): void` | +| `volumeDown` | Decrease volume (default 10% per call) | `volumeDown(percent?: number): void` | +| `addEventListener` | Add an audio event listener | `addEventListener(event: string, listener: EventListenerOrEventListenerObject): void` | +| `removeEventListener` | Remove an audio event listener | `removeEventListener(event: string, listener: EventListenerOrEventListenerObject): void` | +| `dispose` | Release resources (pause, close AudioContext, remove listeners) | `dispose(): void` | + +### Type: `AudioSource` + +| Name | Description | Type | +| ------- | ----------------------------------------- | ---------------------------------------------------------------------------------------- | +| (union) | URL string or streaming/binary audio data | `string \| ReadableStreamDefaultReader<Uint8Array> \| ArrayBuffer \| Uint8Array \| Blob` | + +### Interface: `AudioPlayerInit` + +| Name | Description | Type | (Default) | +| ------------- | ---------------------------------------------------------- | ------------------------------------------------------------ | --------- | +| `source` | Audio source (URL or streaming data); may be a lazy getter | `AudioSource \| (() => AudioSource \| Promise<AudioSource>)` | - | +| `mimeType` | MIME type of the audio (e.g., `audio/mpeg`, `audio/wav`) | `string` | - | +| `volume` | Initial volume level (0-1) | `number` | `0.5` | +| `crossOrigin` | Cross-origin setting for the audio element | `HTMLMediaElement['crossOrigin']` | - | +| `onPlay` | Callback when audio starts playing | `() => void` | - | +| `onPause` | Callback when audio is paused | `() => void` | - | +| `onStop` | Callback when audio is stopped | `() => void` | - | +| `onPlayEnd` | Callback when audio playback ends | `() => void` | - | +| `onError` | Callback when an error occurs | `(error: any) => void` | - | diff --git a/.storybook/stories/utils/AudioPlayer/api-doc.zh-CN.md b/.storybook/stories/utils/AudioPlayer/api-doc.zh-CN.md new file mode 100644 index 0000000..9c431d0 --- /dev/null +++ b/.storybook/stories/utils/AudioPlayer/api-doc.zh-CN.md @@ -0,0 +1,44 @@ +## API + +### 类:`AudioPlayer` + +| 成员 | 描述 | 签名 | +| --------------------- | ---------------------------------------------- | ---------------------------------------------------------------------------------------- | +| `constructor` | 创建音频播放器实例 | `new AudioPlayer(options?: AudioPlayerInit): AudioPlayer` | +| `isPlaying` | 检查音频是否正在播放 | `get isPlaying: boolean` | +| `currentTime` | 获取当前播放时间(秒) | `get currentTime: number` | +| `duration` | 获取音频总时长(秒) | `get duration: number` | +| `volume` | 获取当前音量值(0-1) | `get volume: number` | +| `play` | 播放音频;如果之前暂停过,将从暂停位置继续播放 | `play(): Promise<void>` | +| `pause` | 暂停音频播放;再次播放时将从当前位置继续 | `pause(): void` | +| `stop` | 停止音频播放;进度会重置到开始位置 | `stop(): void` | +| `seek` | 设置当前播放时间(以秒为单位) | `seek(time: number): void` | +| `seekForward` | 向前跳转一定秒数 | `seekForward(seconds: number): void` | +| `seekBackward` | 向后跳转一定秒数 | `seekBackward(seconds: number): void` | +| `setAudioSource` | 更新音频源(URL 或流数据) | `setAudioSource(source?: AudioSource): Promise<{ stopLoading: () => void }>` | +| `setVolume` | 将音量设置为特定值(0-1) | `setVolume(value: number): void` | +| `volumeUp` | 增加音量(每次默认增加10%) | `volumeUp(percent?: number): void` | +| `volumeDown` | 降低音量(每次默认降低10%) | `volumeDown(percent?: number): void` | +| `addEventListener` | 添加音频事件监听器 | `addEventListener(event: string, listener: EventListenerOrEventListenerObject): void` | +| `removeEventListener` | 移除音频事件监听器 | `removeEventListener(event: string, listener: EventListenerOrEventListenerObject): void` | +| `dispose` | 释放资源(暂停、关闭 AudioContext、移除监听) | `dispose(): void` | + +### 类型:`AudioSource` + +| 名称 | 描述 | 类型 | +| ------ | ------------------------------- | ---------------------------------------------------------------------------------------- | +| (联合) | URL 字符串或流式/二进制音频数据 | `string \| ReadableStreamDefaultReader<Uint8Array> \| ArrayBuffer \| Uint8Array \| Blob` | + +### 接口:`AudioPlayerInit` + +| 名称 | 描述 | 类型 | (默认值) | +| ------------- | ------------------------------------------------- | ------------------------------------------------------------ | -------- | +| `source` | 音频源(URL或流数据);可以是惰性 getter | `AudioSource \| (() => AudioSource \| Promise<AudioSource>)` | - | +| `mimeType` | 音频的MIME类型(例如,`audio/mpeg`,`audio/wav`) | `string` | - | +| `volume` | 初始音量级别(0-1) | `number` | `0.5` | +| `crossOrigin` | 音频元素的跨域设置 | `HTMLMediaElement['crossOrigin']` | - | +| `onPlay` | 音频开始播放时的回调 | `() => void` | - | +| `onPause` | 音频暂停时的回调 | `() => void` | - | +| `onStop` | 音频停止时的回调 | `() => void` | - | +| `onPlayEnd` | 音频播放结束时的回调 | `() => void` | - | +| `onError` | 发生错误时的回调 | `(error: any) => void` | - | diff --git a/.storybook/stories/utils/AudioPlayer/index.stories.tsx b/.storybook/stories/utils/AudioPlayer/index.stories.tsx new file mode 100644 index 0000000..ea289a2 --- /dev/null +++ b/.storybook/stories/utils/AudioPlayer/index.stories.tsx @@ -0,0 +1,268 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { Button, Card, Divider, Input, List, Slider, Space, Tag, Typography } from 'antd'; +import AudioPlayer from '../../../../src/utils/AudioPlayer'; +import musicUrl from '../../../assets/sample.mp3'; +import storyI18n, { storyT, useStoryT } from '../../../locales'; +import apiDocEN from './api-doc.en-US.md?raw'; +import apiDocCN from './api-doc.zh-CN.md?raw'; +import introduceEN from './introduce.en-US.md?raw'; +import introduceCN from './introduce.zh-CN.md?raw'; + +interface AudioPlayerStoryArgs { + source: string; + initialVolume: number; + seekStep: number; +} + +interface AudioEventLog { + id: number; + type: string; + time: string; +} + +const meta: Meta<AudioPlayerStoryArgs> = { + title: 'Utils/AudioPlayer', + parameters: { + docs: { + description: { + component: storyI18n.language === 'zh-CN' ? `${introduceCN}\n${apiDocCN}` : `${introduceEN}\n${apiDocEN}`, + }, + }, + }, + args: { + source: musicUrl, + initialVolume: 0.5, + seekStep: 10, + }, + argTypes: { + source: { + control: 'text', + description: storyT('storybook.stories.AudioPlayer.argTypes.source.description'), + }, + initialVolume: { + control: { type: 'range', min: 0, max: 1, step: 0.1 }, + description: storyT('storybook.stories.AudioPlayer.argTypes.initialVolume.description'), + }, + seekStep: { + control: { type: 'number', min: 1, max: 60, step: 1 }, + description: storyT('storybook.stories.AudioPlayer.argTypes.seekStep.description'), + }, + }, +}; + +export default meta; +type Story = StoryObj<AudioPlayerStoryArgs>; + +export const Playground: Story = { + parameters: { + docs: { + description: { + story: + '- **EN:** The `AudioPlayer` class is UI-less: instantiate it, then drive playback programmatically. This demo creates one instance, wires its callbacks into the event log, and cleans it up with `dispose()` when the story unmounts.\n- **CN:** `AudioPlayer` 类不包含用户界面:实例化后通过编程方式控制播放。本 demo 创建一个实例,将其回调接入事件日志,并在 story 卸载时通过 `dispose()` 释放资源。', + }, + }, + }, + render: function Render(args: AudioPlayerStoryArgs) { + return <AudioPlayerStoryDemo {...args} />; + }, +}; + +function AudioPlayerStoryDemo({ source, initialVolume, seekStep }: AudioPlayerStoryArgs) { + const t = useStoryT(); + const sourceRef = useRef(source); + const eventIdRef = useRef(0); + const [draftSource, setDraftSource] = useState(source); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + const [volume, setVolume] = useState(initialVolume); + const [isPlaying, setIsPlaying] = useState(false); + const [eventLogs, setEventLogs] = useState<AudioEventLog[]>([]); + + const appendEventLog = useCallback((type: string) => { + setEventLogs((prev) => { + const next = [ + { + id: ++eventIdRef.current, + type, + time: new Date().toLocaleTimeString(), + }, + ...prev, + ]; + return next.slice(0, 8); + }); + }, []); + + // AudioPlayer is a plain class; create it once and mirror args via imperative methods. + const playerRef = useRef<AudioPlayer | null>(null); + if (!playerRef.current) { + playerRef.current = new AudioPlayer({ + source, + volume: initialVolume, + onPlay: () => appendEventLog('play'), + onPause: () => appendEventLog('pause'), + onStop: () => appendEventLog('stop'), + onPlayEnd: () => appendEventLog('ended'), + onError: () => appendEventLog('error'), + }); + } + const player = playerRef.current; + + useEffect(() => { + setDraftSource(source); + if (source !== sourceRef.current) { + sourceRef.current = source; + void player.setAudioSource(source); + appendEventLog('source-change'); + } + }, [player, source, appendEventLog]); + + useEffect(() => { + let cancelled = false; + + const syncState = () => { + if (cancelled) return; + setCurrentTime(Number.isFinite(player.currentTime) ? player.currentTime : 0); + setDuration(Number.isFinite(player.duration) ? player.duration : 0); + setVolume(player.volume); + setIsPlaying(player.isPlaying); + }; + + const handler = () => syncState(); + const errorHandler = () => { + appendEventLog('error'); + syncState(); + }; + const interval = window.setInterval(syncState, 250); + player.addEventListener('timeupdate', handler); + player.addEventListener('loadedmetadata', handler); + player.addEventListener('ended', handler); + player.addEventListener('play', handler); + player.addEventListener('pause', handler); + player.addEventListener('error', errorHandler); + + syncState(); + + return () => { + cancelled = true; + window.clearInterval(interval); + player.removeEventListener('timeupdate', handler); + player.removeEventListener('loadedmetadata', handler); + player.removeEventListener('ended', handler); + player.removeEventListener('play', handler); + player.removeEventListener('pause', handler); + player.removeEventListener('error', errorHandler); + player.dispose(); + }; + }, [player, appendEventLog]); + + const statusTag = useMemo(() => { + return isPlaying ? ( + <Tag color="green">{t('storybook.stories.AudioPlayer.status.playing')}</Tag> + ) : ( + <Tag color="default">{t('storybook.stories.AudioPlayer.status.paused')}</Tag> + ); + }, [isPlaying, t]); + const progressMax = useMemo(() => { + return Number.isFinite(duration) && duration > 0 ? duration : Math.max(currentTime, 0); + }, [currentTime, duration]); + + return ( + <Card + variant="outlined" + style={{ maxWidth: 880 }} + title={t('storybook.stories.AudioPlayer.cardTitle')} + extra={statusTag} + > + <Space orientation="vertical" size="large" style={{ width: '100%' }}> + <Space.Compact style={{ width: '100%' }}> + <Input + value={draftSource} + onChange={(event) => setDraftSource(event.target.value)} + placeholder={t('storybook.stories.AudioPlayer.sourcePlaceholder')} + /> + <Button + onClick={async () => { + sourceRef.current = draftSource; + await player.setAudioSource(draftSource); + appendEventLog('source-change'); + }} + > + {t('storybook.stories.AudioPlayer.applySource')} + </Button> + </Space.Compact> + + <Space wrap> + {/* eslint-disable-next-line storybook/context-in-play-function -- calling AudioPlayer.play(), not Storybook play() */} + <Button type="primary" onClick={() => void player.play().catch(() => undefined)}> + {t('storybook.stories.AudioPlayer.actions.play')} + </Button> + <Button onClick={() => player.pause()}>{t('storybook.stories.AudioPlayer.actions.pause')}</Button> + <Button onClick={() => player.stop()}>{t('storybook.stories.AudioPlayer.actions.stop')}</Button> + <Button onClick={() => player.seekBackward(seekStep)}> + {t('storybook.stories.AudioPlayer.actions.backward', { seconds: seekStep })} + </Button> + <Button onClick={() => player.seekForward(seekStep)}> + {t('storybook.stories.AudioPlayer.actions.forward', { seconds: seekStep })} + </Button> + </Space> + + <div> + <Typography.Text strong>{t('storybook.stories.AudioPlayer.progressLabel')}</Typography.Text> + <Slider + min={0} + max={progressMax} + step={0.1} + value={Math.min(Math.max(currentTime, 0), progressMax)} + onChange={(value) => player.seek(Number(value))} + tooltip={{ open: false }} + /> + <Space split={<Divider type="vertical" />} size="small"> + <Typography.Text> + {t('storybook.stories.AudioPlayer.currentTime', { value: currentTime.toFixed(1) })} + </Typography.Text> + <Typography.Text> + {t('storybook.stories.AudioPlayer.duration', { value: duration ? duration.toFixed(1) : '--' })} + </Typography.Text> + </Space> + </div> + + <div> + <Typography.Text strong>{t('storybook.stories.AudioPlayer.volumeLabel')}</Typography.Text> + <Slider + min={0} + max={1} + step={0.1} + value={volume} + onChange={(value) => { + const nextVolume = Number(value); + player.setVolume(nextVolume); + setVolume(nextVolume); + appendEventLog('volume-change'); + }} + tooltip={{ open: false }} + /> + </div> + + <div> + <Typography.Text strong>{t('storybook.stories.AudioPlayer.eventLogTitle')}</Typography.Text> + <List + bordered + size="small" + rowKey="id" + dataSource={eventLogs} + locale={{ emptyText: t('storybook.stories.AudioPlayer.emptyLog') }} + renderItem={(item) => ( + <List.Item> + <Space split={<Divider type="vertical" />} size="small"> + <Typography.Text code>{item.type}</Typography.Text> + <Typography.Text type="secondary">{item.time}</Typography.Text> + </Space> + </List.Item> + )} + /> + </div> + </Space> + </Card> + ); +} diff --git a/.storybook/stories/utils/AudioPlayer/introduce.en-US.md b/.storybook/stories/utils/AudioPlayer/introduce.en-US.md new file mode 100644 index 0000000..a5869e0 --- /dev/null +++ b/.storybook/stories/utils/AudioPlayer/introduce.en-US.md @@ -0,0 +1,50 @@ +`AudioPlayer` is a UI-less audio playback class that accepts either a URL or a streaming source. It exposes an imperative API — play, pause, stop, seek, and volume control — so you can build your own player UI on top of it, or drive audio purely from logic. It supports three source forms: a URL string, raw binary data (`ArrayBuffer` / `Uint8Array` / `Blob`), or a `ReadableStreamDefaultReader` that is progressively appended into a `MediaSource` as data arrives. + +## When to use + +- You need a headless audio engine and want to build a fully custom player UI. +- You receive audio over a streaming interface (for example WebSocket chunks or a fetch response body reader) and want playback to start before the whole file is available. +- You need volume management through the Web Audio API for smoother gain control. +- You want playback driven programmatically (auto-play, queueing, or AI-triggered playback) without DOM or React state coupling. + +## Key features + +- **Multiple source types** — URL string, `Blob`, `ArrayBuffer`, `Uint8Array`, or a `ReadableStreamDefaultReader` for progressive playback. +- **Streaming support** — reader chunks are appended into a `MediaSource`/`SourceBuffer`; falls back to one-time `Blob` buffering when `MediaSource` is unsupported. +- **Full transport control** — `play()`, `pause()`, `stop()`, `seek()`, `seekForward()`, `seekBackward()`. +- **Volume management** — `setVolume()`, `volumeUp()`, `volumeDown()`, wired through a `GainNode` for smooth changes. +- **Lifecycle callbacks** — `onPlay`, `onPause`, `onStop`, `onPlayEnd`, `onError` in the constructor options. +- **Event bridge** — `addEventListener` / `removeEventListener` proxy the native `HTMLAudioElement` events. +- **Resource cleanup** — `dispose()` pauses playback, closes the `AudioContext`, and releases sources. + +## Sample code + +```tsx +import { useEffect } from 'react'; +import { AudioPlayer } from '@tiny-codes/react-easy'; + +function PlayerDemo({ url }: { url: string }) { + useEffect(() => { + const player = new AudioPlayer({ + source: url, + volume: 0.6, + onPlay: () => console.log('playing'), + onPause: () => console.log('paused'), + onError: (error) => console.error('playback error', error), + }); + + void player.play(); + return () => player.dispose(); + }, [url]); + + return null; // AudioPlayer renders no UI; drive it programmatically +} +``` + +## Usage notes + +- `AudioPlayer` renders nothing — it is a programming interface only. Build your own UI or control it from logic. +- URL sources must support cross-origin access, otherwise playback may be silent. +- Default volume is `0.5`; `setVolume` clamps to `[0, 1]`. +- When the source is a stream reader, the browser must support `MediaSource` and the provided MIME type; otherwise the player falls back to buffering the entire stream into a `Blob` before playback. +- Call `dispose()` when the player is no longer needed to release the `AudioContext` and event listeners. diff --git a/.storybook/stories/utils/AudioPlayer/introduce.zh-CN.md b/.storybook/stories/utils/AudioPlayer/introduce.zh-CN.md new file mode 100644 index 0000000..263e89e --- /dev/null +++ b/.storybook/stories/utils/AudioPlayer/introduce.zh-CN.md @@ -0,0 +1,50 @@ +`AudioPlayer` 是一个无 UI 的音频播放类,支持 URL 或流式数据输入。它只提供编程接口——播放、暂停、停止、跳转与音量控制——你可以基于它构建自己的播放器界面,或纯粹用逻辑驱动音频播放。它支持三种数据源:URL 字符串、原始二进制数据(`ArrayBuffer` / `Uint8Array` / `Blob`),或 `ReadableStreamDefaultReader`(数据到达时逐步写入 `MediaSource`,实现边下边播)。 + +## 适用场景 + +- 需要一个无界面的音频引擎,并希望完全自定义播放器 UI。 +- 通过流式接口接收音频(如 WebSocket 分片或 fetch 响应体 reader),希望在完整文件下载前就开始播放。 +- 需要通过 Web Audio API 进行更平滑的音量增益控制。 +- 希望完全以编程方式驱动播放(自动播放、队列、AI 触发播放),不依赖 DOM 或 React 状态。 + +## 核心特性 + +- **多种数据源** — URL 字符串、`Blob`、`ArrayBuffer`、`Uint8Array` 或 `ReadableStreamDefaultReader`(支持渐进式播放)。 +- **流式支持** — reader 分片写入 `MediaSource`/`SourceBuffer`;当 `MediaSource` 不可用时自动回退为一次性 `Blob` 缓冲。 +- **完整播放控制** — `play()`、`pause()`、`stop()`、`seek()`、`seekForward()`、`seekBackward()`。 +- **音量管理** — `setVolume()`、`volumeUp()`、`volumeDown()`,通过 `GainNode` 平滑调节。 +- **生命周期回调** — 构造参数中的 `onPlay`、`onPause`、`onStop`、`onPlayEnd`、`onError`。 +- **事件桥接** — `addEventListener` / `removeEventListener` 代理原生 `HTMLAudioElement` 事件。 +- **资源释放** — `dispose()` 暂停播放、关闭 `AudioContext` 并释放数据源。 + +## 示例代码 + +```tsx +import { useEffect } from 'react'; +import { AudioPlayer } from '@tiny-codes/react-easy'; + +function PlayerDemo({ url }: { url: string }) { + useEffect(() => { + const player = new AudioPlayer({ + source: url, + volume: 0.6, + onPlay: () => console.log('playing'), + onPause: () => console.log('paused'), + onError: (error) => console.error('playback error', error), + }); + + void player.play(); + return () => player.dispose(); + }, [url]); + + return null; // AudioPlayer 不渲染任何 UI,请以编程方式控制 +} +``` + +## 使用注意 + +- `AudioPlayer` 不渲染任何界面——它只是编程接口。请自行构建 UI 或用逻辑控制。 +- URL 数据源必须支持跨域访问,否则可能没有声音。 +- 默认音量为 `0.5`;`setVolume` 会将值限制在 `[0, 1]`。 +- 当数据源为流 reader 时,浏览器必须支持 `MediaSource` 且 MIME 类型可用;否则播放器会回退为先把整个流缓冲成 `Blob` 再播放。 +- 不再需要播放器时请调用 `dispose()`,以释放 `AudioContext` 与事件监听器。 diff --git a/.storybook/stories/utils/base64/api-doc.en-US.md b/.storybook/stories/utils/base64/api-doc.en-US.md new file mode 100644 index 0000000..97cb990 --- /dev/null +++ b/.storybook/stories/utils/base64/api-doc.en-US.md @@ -0,0 +1,40 @@ +## API + +### Functions + +| Function | Description | Signature | +| --------------------- | -------------------------------------------------------- | --------------------------------------------------------------- | +| `stringToBase64` | Encode a UTF-8 string into Base64 (standard or URL-safe) | `(content: string, opts?: { urlSafe?: boolean }) => string` | +| `base64ToString` | Decode a Base64 string back into UTF-8 text | `(content: string, opts?: { urlSafe?: boolean }) => string` | +| `arrayBufferToBase64` | Convert an ArrayBuffer to a Base64 string | `(buf: ArrayBuffer) => string` | +| `base64ToArrayBuffer` | Decode a Base64 string into an ArrayBuffer | `(base64: string, opts?: { urlSafe?: boolean }) => ArrayBuffer` | + +### stringToBase64(content, opts?) + +| Name | Description | Type | (Default) | +| -------------- | ------------------------------------------------------------------ | ----------------------- | --------- | +| `content` | Input text to encode | `string` | - | +| `opts` | Optional encoding options | `{ urlSafe?: boolean }` | `{}` | +| `opts.urlSafe` | Use URL-safe Base64 if true (replace `+`/`/` with `-_`, strip `=`) | `boolean` | `false` | + +### base64ToString(content, opts?) + +| Name | Description | Type | (Default) | +| -------------- | ------------------------------------------------------------- | ----------------------- | --------- | +| `content` | Base64 encoded string to decode | `string` | - | +| `opts` | Optional decoding options | `{ urlSafe?: boolean }` | `{}` | +| `opts.urlSafe` | Normalize URL-safe Base64 if true (replace `-_` back to `+/`) | `boolean` | `false` | + +### arrayBufferToBase64(buf) + +| Name | Description | Type | (Default) | +| ----- | -------------------------- | ------------- | --------- | +| `buf` | The ArrayBuffer to convert | `ArrayBuffer` | - | + +### base64ToArrayBuffer(base64, opts?) + +| Name | Description | Type | (Default) | +| -------------- | ------------------------------------------------------------- | ----------------------- | --------- | +| `base64` | The Base64 encoded string to decode | `string` | - | +| `opts` | Optional decoding options | `{ urlSafe?: boolean }` | `{}` | +| `opts.urlSafe` | Normalize URL-safe Base64 if true (replace `-_` back to `+/`) | `boolean` | `false` | diff --git a/.storybook/stories/utils/base64/api-doc.zh-CN.md b/.storybook/stories/utils/base64/api-doc.zh-CN.md new file mode 100644 index 0000000..f95223c --- /dev/null +++ b/.storybook/stories/utils/base64/api-doc.zh-CN.md @@ -0,0 +1,40 @@ +## API + +### 函数 + +| 函数 | 说明 | 签名 | +| --------------------- | --------------------------------------------------- | --------------------------------------------------------------- | +| `stringToBase64` | 将 UTF-8 字符串编码为 Base64(标准或 URL 安全格式) | `(content: string, opts?: { urlSafe?: boolean }) => string` | +| `base64ToString` | 将 Base64 字符串解码为 UTF-8 文本 | `(content: string, opts?: { urlSafe?: boolean }) => string` | +| `arrayBufferToBase64` | 将 ArrayBuffer 转换为 Base64 字符串 | `(buf: ArrayBuffer) => string` | +| `base64ToArrayBuffer` | 将 Base64 字符串解码为 ArrayBuffer | `(base64: string, opts?: { urlSafe?: boolean }) => ArrayBuffer` | + +### stringToBase64(content, opts?) + +| 参数 | 说明 | 类型 | 默认值 | +| -------------- | --------------------------------------------------------------- | ----------------------- | ------- | +| `content` | 要编码的输入文本 | `string` | - | +| `opts` | 可选编码配置 | `{ urlSafe?: boolean }` | `{}` | +| `opts.urlSafe` | 为 true 时使用 URL 安全 Base64(`+`/`/` 替换为 `-_`,去掉 `=`) | `boolean` | `false` | + +### base64ToString(content, opts?) + +| 参数 | 说明 | 类型 | 默认值 | +| -------------- | ---------------------------------------------------------- | ----------------------- | ------- | +| `content` | 要解码的 Base64 字符串 | `string` | - | +| `opts` | 可选解码配置 | `{ urlSafe?: boolean }` | `{}` | +| `opts.urlSafe` | 为 true 时按 URL 安全 Base64 规范化(将 `-_` 还原为 `+/`) | `boolean` | `false` | + +### arrayBufferToBase64(buf) + +| 参数 | 说明 | 类型 | 默认值 | +| ----- | -------------------- | ------------- | ------ | +| `buf` | 要转换的 ArrayBuffer | `ArrayBuffer` | - | + +### base64ToArrayBuffer(base64, opts?) + +| 参数 | 说明 | 类型 | 默认值 | +| -------------- | ---------------------------------------------------------- | ----------------------- | ------- | +| `base64` | 要解码的 Base64 字符串 | `string` | - | +| `opts` | 可选解码配置 | `{ urlSafe?: boolean }` | `{}` | +| `opts.urlSafe` | 为 true 时按 URL 安全 Base64 规范化(将 `-_` 还原为 `+/`) | `boolean` | `false` | diff --git a/.storybook/stories/utils/base64/index.stories.tsx b/.storybook/stories/utils/base64/index.stories.tsx new file mode 100644 index 0000000..8b826b5 --- /dev/null +++ b/.storybook/stories/utils/base64/index.stories.tsx @@ -0,0 +1,114 @@ +import { useMemo, useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { Alert, Button, Card, Input, Space, Switch, Typography } from 'antd'; +import { base64ToArrayBuffer, base64ToString, stringToBase64 } from '../../../../src/utils/base64'; +import storyI18n, { storyT, useStoryT } from '../../../locales'; +import apiDocEN from './api-doc.en-US.md?raw'; +import apiDocCN from './api-doc.zh-CN.md?raw'; +import introduceEN from './introduce.en-US.md?raw'; +import introduceCN from './introduce.zh-CN.md?raw'; + +interface Base64StoryArgs { + urlSafe: boolean; +} + +const meta: Meta<Base64StoryArgs> = { + title: 'Utils/base64', + parameters: { + docs: { + description: { + component: storyI18n.language === 'zh-CN' ? `${introduceCN}\n${apiDocCN}` : `${introduceEN}\n${apiDocEN}`, + }, + }, + }, + args: { + urlSafe: false, + }, + argTypes: { + urlSafe: { + control: 'boolean', + description: storyT('storybook.stories.base64.argTypes.urlSafe.description'), + }, + }, +}; + +export default meta; +type Story = StoryObj<Base64StoryArgs>; + +export const Playground: Story = { + parameters: { + docs: { + description: { + story: + '- **EN:** Type some text, then click **Encode** to turn it into Base64 (standard or URL-safe). Use **Decode** to convert a Base64 string back into text. Toggle `urlSafe` to switch between the two formats.\n- **CN:** 输入文本后点击**编码**将其转为 Base64(标准或 URL 安全格式)。使用**解码**将 Base64 字符串还原为文本。切换 `urlSafe` 可在两种格式之间转换。', + }, + }, + }, + render: function Render(args: Base64StoryArgs) { + return <Base64StoryDemo {...args} />; + }, +}; + +function Base64StoryDemo({ urlSafe }: Base64StoryArgs) { + const t = useStoryT(); + const [input, setInput] = useState('hello, react-easy'); + const [encoded, setEncoded] = useState(''); + const [decoded, setDecoded] = useState(''); + + const bufferBytes = useMemo(() => { + if (!encoded) return null; + try { + return base64ToArrayBuffer(encoded, { urlSafe }).byteLength; + } catch { + return null; + } + }, [encoded, urlSafe]); + + const handleEncode = () => { + setEncoded(stringToBase64(input, { urlSafe })); + setDecoded(''); + }; + + const handleDecode = () => { + setDecoded(base64ToString(input, { urlSafe })); + }; + + return ( + <Card variant="outlined" style={{ maxWidth: 920 }} title={t('storybook.stories.base64.cardTitle')}> + <Space orientation="vertical" size="large" style={{ width: '100%' }}> + <Typography.Paragraph style={{ marginBottom: 0 }}> + {t('storybook.stories.base64.description')} + </Typography.Paragraph> + + <Space wrap> + <Input + style={{ maxWidth: 420 }} + placeholder={t('storybook.stories.base64.inputPlaceholder')} + value={input} + onChange={(e) => setInput(e.target.value)} + /> + <Button type="primary" onClick={handleEncode}> + {t('storybook.stories.base64.actions.encode')} + </Button> + <Button onClick={handleDecode}>{t('storybook.stories.base64.actions.decode')}</Button> + </Space> + + <Space wrap> + <Typography.Text strong>{t('storybook.stories.base64.urlSafeLabel')}</Typography.Text> + <Switch checked={urlSafe} /> + </Space> + + <Space orientation="vertical" size="small" style={{ width: '100%' }}> + <Typography.Text strong>{t('storybook.stories.base64.encodedLabel')}</Typography.Text> + <Typography.Text code>{encoded || '—'}</Typography.Text> + <Typography.Text strong>{t('storybook.stories.base64.decodedLabel')}</Typography.Text> + <Typography.Text code>{decoded || '—'}</Typography.Text> + <Typography.Text strong>{t('storybook.stories.base64.bufferLabel')}</Typography.Text> + <Typography.Text code>{bufferBytes == null ? '—' : bufferBytes}</Typography.Text> + </Space> + + <Alert type="info" title={t('storybook.stories.base64.tip')} showIcon /> + </Space> + </Card> + ); +} diff --git a/.storybook/stories/utils/base64/introduce.en-US.md b/.storybook/stories/utils/base64/introduce.en-US.md new file mode 100644 index 0000000..eecdbe1 --- /dev/null +++ b/.storybook/stories/utils/base64/introduce.en-US.md @@ -0,0 +1,36 @@ +The Base64 utilities convert between UTF-8 strings and Base64 in standard or URL-safe format, and bridge `ArrayBuffer` and Base64 so binary payloads can travel as plain strings. They are environment-agnostic: Node's `Buffer` is used when available, otherwise the browser `btoa`/`atob` pair combined with `TextEncoder`/`TextDecoder`. + +## When to use + +- Encoding user-generated UTF-8 text (emoji, CJK, accented characters) into Base64 for safe transmission or storage. +- Exchanging binary data (for example file bytes) as a Base64 string between the browser, a server, or a Web Worker. +- When a URL-safe variant is required so the encoded value can appear in query strings, path segments, or tokens. + +## Key features + +- **UTF-8 aware** — non-ASCII text is encoded and decoded losslessly. +- **Dual formats** — standard Base64 by default; pass `{ urlSafe: true }` to switch to the URL-safe alphabet (`+`/`/` → `-`/`_`, padding stripped). +- **String ↔ ArrayBuffer** — four functions cover text and binary round-trips in both directions. +- **Environment agnostic** — uses `Buffer` when present and falls back to browser APIs. + +## Sample code + +```ts +import { base64ToString, stringToBase64 } from '@tiny-codes/react-easy'; + +const encoded = stringToBase64('hello, react-easy'); +// 'aGVsbG8sIHJlYWN0LWVhc3k=' + +const decoded = base64ToString(encoded); +// 'hello, react-easy' + +// URL-safe variant: '+'/'/' become '-'/'_' and padding is stripped +const urlSafe = stringToBase64('https://example.com/a?b=1&c=2', { urlSafe: true }); +``` + +## Usage notes + +- Encode functions treat empty or `null`/`undefined` input as an empty string. +- Decoding requires a valid Base64 string; malformed input (for example a length congruent to 1 mod 4) throws an error such as `Failed to decode Base64: ...`. +- The `urlSafe` flag must match on both encode and decode — `-`/`_` are only normalized back to `+`/`/` when `urlSafe: true`. +- `arrayBufferToBase64` always emits standard Base64 with padding; `base64ToArrayBuffer` returns an empty `ArrayBuffer(0)` for empty input. diff --git a/.storybook/stories/utils/base64/introduce.zh-CN.md b/.storybook/stories/utils/base64/introduce.zh-CN.md new file mode 100644 index 0000000..7b827ee --- /dev/null +++ b/.storybook/stories/utils/base64/introduce.zh-CN.md @@ -0,0 +1,36 @@ +Base64 工具函数在标准或 URL 安全格式下完成 UTF-8 字符串与 Base64 之间的转换,并打通 `ArrayBuffer` 与 Base64,让二进制数据可以以纯字符串的形式传输。它们与环境无关:Node 环境优先使用 `Buffer`,浏览器环境回退到 `btoa`/`atob` 配合 `TextEncoder`/`TextDecoder`。 + +## 适用场景 + +- 将用户生成的 UTF-8 文本(emoji、中文、带重音字符)编码为 Base64,以便安全传输或存储。 +- 在浏览器、服务器或 Web Worker 之间以 Base64 字符串的形式交换二进制数据(例如文件字节)。 +- 需要 URL 安全变体,让编码结果可以出现在查询参数、路径片段或令牌中。 + +## 核心特性 + +- **UTF-8 感知** —— 非 ASCII 文本可无损编解码。 +- **双格式** —— 默认输出标准 Base64;传入 `{ urlSafe: true }` 切换到 URL 安全字母表(`+`/`/` → `-`/`_`,并去掉 `=` 填充)。 +- **字符串 ↔ ArrayBuffer** —— 四个函数覆盖文本与二进制两个方向的往返转换。 +- **环境无关** —— 存在 `Buffer` 时优先使用,否则回退到浏览器 API。 + +## 示例代码 + +```ts +import { base64ToString, stringToBase64 } from '@tiny-codes/react-easy'; + +const encoded = stringToBase64('hello, react-easy'); +// 'aGVsbG8sIHJlYWN0LWVhc3k=' + +const decoded = base64ToString(encoded); +// 'hello, react-easy' + +// URL 安全变体:'+'/'/' 变为 '-'/'_' 并去掉 '=' 填充 +const urlSafe = stringToBase64('https://example.com/a?b=1&c=2', { urlSafe: true }); +``` + +## 使用注意 + +- 编码函数将空字符串或 `null`/`undefined` 输入视为空字符串。 +- 解码要求输入是合法的 Base64 字符串;格式非法的输入(例如长度对 4 取模为 1)会抛出如 `Failed to decode Base64: ...` 的错误。 +- 编码与解码的 `urlSafe` 标志必须一致 —— 只有 `urlSafe: true` 时 `-`/`_` 才会被还原为 `+`/`/`。 +- `arrayBufferToBase64` 始终输出带填充的标准 Base64;`base64ToArrayBuffer` 对空输入返回空的 `ArrayBuffer(0)`。 diff --git a/.storybook/stories/utils/color/api-doc.en-US.md b/.storybook/stories/utils/color/api-doc.en-US.md new file mode 100644 index 0000000..8611844 --- /dev/null +++ b/.storybook/stories/utils/color/api-doc.en-US.md @@ -0,0 +1,19 @@ +## API + +### Functions + +| Function | Description | Signature | +| ------------------- | ----------------------------------------- | --------------------------- | +| `getColorLuminance` | Compute the relative luminance of a color | `(color: string) => number` | + +### getColorLuminance(color) + +| Name | Description | Type | (Default) | +| ------- | ----------------------------------------------------------- | -------- | --------- | +| `color` | Color string in `#rgb`, `#rrggbb`, or `rgb(r, g, b)` format | `string` | - | + +### Return + +| Member | Description | Signature | +| -------- | ----------------------------------------------------------------------------------------------- | --------- | +| (result) | WCAG relative luminance in the range `(0, 1)` — below `0.5` is dark, at or above `0.5` is light | `number` | diff --git a/.storybook/stories/utils/color/api-doc.zh-CN.md b/.storybook/stories/utils/color/api-doc.zh-CN.md new file mode 100644 index 0000000..0ba4fae --- /dev/null +++ b/.storybook/stories/utils/color/api-doc.zh-CN.md @@ -0,0 +1,19 @@ +## API + +### 函数 + +| 函数 | 说明 | 签名 | +| ------------------- | ------------------ | --------------------------- | +| `getColorLuminance` | 计算颜色的相对亮度 | `(color: string) => number` | + +### getColorLuminance(color) + +| 参数 | 说明 | 类型 | 默认值 | +| ------- | --------------------------------------------------------- | -------- | ------ | +| `color` | 颜色字符串,支持 `#rgb`、`#rrggbb` 或 `rgb(r, g, b)` 格式 | `string` | - | + +### 返回值 + +| 成员 | 说明 | 签名 | +| ------ | ------------------------------------------------------------------------ | -------- | +| (结果) | WCAG 相对亮度,范围 `(0, 1)` —— 低于 `0.5` 为深色,高于等于 `0.5` 为浅色 | `number` | diff --git a/.storybook/stories/utils/color/index.stories.tsx b/.storybook/stories/utils/color/index.stories.tsx new file mode 100644 index 0000000..e2b0dbc --- /dev/null +++ b/.storybook/stories/utils/color/index.stories.tsx @@ -0,0 +1,102 @@ +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { Alert, Button, Card, Input, Space, Tag, Typography } from 'antd'; +import { getColorLuminance } from '../../../../src/utils/color'; +import storyI18n, { storyT, useStoryT } from '../../../locales'; +import apiDocEN from './api-doc.en-US.md?raw'; +import apiDocCN from './api-doc.zh-CN.md?raw'; +import introduceEN from './introduce.en-US.md?raw'; +import introduceCN from './introduce.zh-CN.md?raw'; + +interface ColorStoryArgs { + color: string; +} + +const PRESETS = ['#1677ff', '#000000', '#ffffff', '#ff5722', '#4caf50', '#f5f5f5']; + +const meta: Meta<ColorStoryArgs> = { + title: 'Utils/color', + parameters: { + docs: { + description: { + component: storyI18n.language === 'zh-CN' ? `${introduceCN}\n${apiDocCN}` : `${introduceEN}\n${apiDocEN}`, + }, + }, + }, + args: { + color: '#1677ff', + }, + argTypes: { + color: { + control: 'color', + description: storyT('storybook.stories.color.argTypes.color.description'), + }, + }, +}; + +export default meta; +type Story = StoryObj<ColorStoryArgs>; + +export const Playground: Story = { + parameters: { + docs: { + description: { + story: + '- **EN:** Type a color (hex `#rrggbb`, `#rgb`, or `rgb(r, g, b)`) or pick a preset, then read the relative luminance and whether it counts as a light or dark color.\n- **CN:** 输入颜色(`#rrggbb`、`#rgb` 或 `rgb(r, g, b)` 格式),或选择预设色,即可查看其相对亮度以及深浅判断。', + }, + }, + }, + render: function Render(args: ColorStoryArgs) { + return <ColorStoryDemo {...args} />; + }, +}; + +function ColorStoryDemo({ color }: ColorStoryArgs) { + const t = useStoryT(); + const [current, setCurrent] = useState(color); + const luminance = getColorLuminance(current); + const isDark = luminance < 0.5; + + return ( + <Card variant="outlined" style={{ maxWidth: 920 }} title={t('storybook.stories.color.cardTitle')}> + <Space orientation="vertical" size="large" style={{ width: '100%' }}> + <Typography.Paragraph style={{ marginBottom: 0 }}> + {t('storybook.stories.color.description')} + </Typography.Paragraph> + + <Space wrap> + <Input + style={{ maxWidth: 200 }} + placeholder={t('storybook.stories.color.inputPlaceholder')} + value={current} + onChange={(e) => setCurrent(e.target.value)} + /> + {PRESETS.map((preset) => ( + <Button key={preset} onClick={() => setCurrent(preset)}> + {preset} + </Button> + ))} + </Space> + + <Space wrap> + <div + style={{ + width: 40, + height: 40, + borderRadius: 6, + border: '1px solid rgba(0,0,0,0.15)', + backgroundColor: /^(#[0-9a-fA-F]{3,8}|rgb\(.*\)$)/.test(current) ? current : 'transparent', + }} + /> + <Typography.Text strong>{t('storybook.stories.color.luminanceLabel')}</Typography.Text> + <Typography.Text code>{luminance.toFixed(4)}</Typography.Text> + <Tag color={isDark ? 'purple' : 'gold'}> + {isDark ? t('storybook.stories.color.darkTag') : t('storybook.stories.color.lightTag')} + </Tag> + </Space> + + <Alert type="info" title={t('storybook.stories.color.tip')} showIcon /> + </Space> + </Card> + ); +} diff --git a/.storybook/stories/utils/color/introduce.en-US.md b/.storybook/stories/utils/color/introduce.en-US.md new file mode 100644 index 0000000..c3cb43a --- /dev/null +++ b/.storybook/stories/utils/color/introduce.en-US.md @@ -0,0 +1,33 @@ +The color utility computes a single color's luminance so you can decide whether it reads as light or dark. `getColorLuminance` returns the WCAG relative luminance in the range `(0, 1)` from a `#hex` (three or six digits) or `rgb()` color string. + +## When to use + +- Assigning readable text or background colors automatically from a background color (light backgrounds use dark text, dark backgrounds use light text). +- Making contrast-sensitive decisions, such as which icon or badge variant to show. +- Normalizing a mix of color inputs (hex and `rgb()`) to one light/dark verdict before further processing. + +## Key features + +- **Light/dark verdict** — A luminance below `0.5` reads as dark; at or above `0.5` reads as light. +- **Multiple formats** — Accepts `#rrggbb`, `#rgb`, and `rgb(r, g, b)` strings. +- **Standard formula** — Follows the WCAG relative-luminance computation with sRGB gamma correction. +- **Returns `(0, 1)`** — Ready for thresholding or further contrast math. + +## Sample code + +```ts +import { getColorLuminance } from '@tiny-codes/react-easy'; + +getColorLuminance('#ffffff'); // 1 (light) +getColorLuminance('#000000'); // 0 (dark) +getColorLuminance('rgb(255, 87, 34)'); // ~0.21 (dark) + +const luminance = getColorLuminance(candidate); +const textColor = luminance < 0.5 ? '#fff' : '#000'; +``` + +## Usage notes + +- Only `#hex` (with or without the leading `#`) and `rgb(r, g, b)` strings are recognized; other inputs fall back to `(0, 0, 0)`. +- Alpha channels are ignored — `rgba()` uses only its RGB components. +- The result is a float in `[0, 1]`; treat `0.5` as the light-color baseline when choosing contrast colors. diff --git a/.storybook/stories/utils/color/introduce.zh-CN.md b/.storybook/stories/utils/color/introduce.zh-CN.md new file mode 100644 index 0000000..7391362 --- /dev/null +++ b/.storybook/stories/utils/color/introduce.zh-CN.md @@ -0,0 +1,33 @@ +颜色工具函数用于计算单一颜色的亮度,从而判断其深浅。`getColorLuminance` 从 `#hex`(三位或六位)或 `rgb()` 颜色字符串返回 `(0, 1)` 范围内的 WCAG 相对亮度。 + +## 适用场景 + +- 根据背景色自动确定文字颜色,使浅色背景使用深色文字、深色背景使用浅色文字。 +- 基于对比度做动态决策,例如决定图标或徽章使用哪种变体。 +- 将多种颜色输入(hex 与 `rgb()`)统一归一为一个"深/浅"判断,再做进一步处理。 + +## 核心特性 + +- **深浅判断** —— 亮度低于 `0.5` 视为深色,高于等于 `0.5` 视为浅色。 +- **多格式支持** —— 兼容 `#rrggbb`、`#rgb` 与 `rgb(r, g, b)` 字符串。 +- **标准算法** —— 遵循带 sRGB 伽马校正的 WCAG 相对亮度计算公式。 +- **返回 `(0, 1)` 范围** —— 便于直接做阈值判断或参与进一步的对比度计算。 + +## 示例代码 + +```ts +import { getColorLuminance } from '@tiny-codes/react-easy'; + +getColorLuminance('#ffffff'); // 约 1(浅色) +getColorLuminance('#000000'); // 0(深色) +getColorLuminance('rgb(255, 87, 34)'); // 约 0.21(深色) + +const luminance = getColorLuminance(candidate); +const textColor = luminance < 0.5 ? '#fff' : '#000'; +``` + +## 使用注意 + +- 仅识别 `#hex`(带或不带前导 `#`)与 `rgb(r, g, b)` 字符串;其他格式回退为 `(0, 0, 0)`。 +- 忽略 alpha 通道 —— `rgba()` 只会取其 RGB 分量。 +- 返回值为 `[0, 1]`;选取文字颜色时可将 `0.5` 作为浅色基准。 diff --git a/.storybook/stories/utils/crypto/api-doc.en-US.md b/.storybook/stories/utils/crypto/api-doc.en-US.md new file mode 100644 index 0000000..9507586 --- /dev/null +++ b/.storybook/stories/utils/crypto/api-doc.en-US.md @@ -0,0 +1,22 @@ +## API + +### Functions + +| Member | Description | Signature | +| --------------------- | -------------------------------------------------------------------------- | --------------------------------------------------------- | +| `encryptAES` | Generic AES-256-CBC encryption; works in both Node.js and browsers | `(text: string, key: string) => Promise<string>` | +| `decryptAES` | Decrypt the output of `encryptAES`; returns `''` when decryption fails | `(encryptedText: string, key: string) => Promise<string>` | +| `encryptWithCryptoJS` | AES encryption implemented with crypto-js (same `iv:encrypted` output) | `(text: string, key: string) => Promise<string>` | +| `decryptWithCryptoJS` | crypto-js based decryption for `encryptWithCryptoJS` output | `(encryptedText: string, key: string) => Promise<string>` | +| `advancedEncrypt` | Advanced encryption: embeds a random one-time key and re-encrypts with AES | `(plainText: string, key: string) => Promise<string>` | +| `advancedDecrypt` | Inverse operation of `advancedEncrypt` | `(encryptedText: string, key: string) => Promise<string>` | + +### Parameters + +| Name | Description | Type | (Default) | +| ------------------ | ---------------------------------------------- | -------- | --------- | +| `text`/`plainText` | The plain text to encrypt | `string` | - | +| `encryptedText` | The encrypted text (`iv:encrypted`) to decrypt | `string` | - | +| `key` | The encryption / decryption key | `string` | - | + +All functions return `Promise<string>`. The encrypted output uses the `ivBase64:encryptedBase64` format (`iv:encrypted` for the CryptoJS pair). `decryptAES` resolves to an empty string when decryption fails. diff --git a/.storybook/stories/utils/crypto/api-doc.zh-CN.md b/.storybook/stories/utils/crypto/api-doc.zh-CN.md new file mode 100644 index 0000000..e5f8671 --- /dev/null +++ b/.storybook/stories/utils/crypto/api-doc.zh-CN.md @@ -0,0 +1,22 @@ +## API + +### 函数 + +| 成员 | 说明 | 签名 | +| --------------------- | ------------------------------------------------------------ | --------------------------------------------------------- | +| `encryptAES` | 通用 AES-256-CBC 加密;同时支持 Node.js 与浏览器环境 | `(text: string, key: string) => Promise<string>` | +| `decryptAES` | 解密 `encryptAES` 的输出;解密失败时返回 `''` | `(encryptedText: string, key: string) => Promise<string>` | +| `encryptWithCryptoJS` | 基于 crypto-js 的 AES 加密(同样输出 `iv:encrypted`) | `(text: string, key: string) => Promise<string>` | +| `decryptWithCryptoJS` | 基于 crypto-js 的解密,用于还原 `encryptWithCryptoJS` 的输出 | `(encryptedText: string, key: string) => Promise<string>` | +| `advancedEncrypt` | 高级加密:内部随机插入一次性密钥并二次 AES 加密 | `(plainText: string, key: string) => Promise<string>` | +| `advancedDecrypt` | `advancedEncrypt` 的逆操作 | `(encryptedText: string, key: string) => Promise<string>` | + +### 参数 + +| 参数 | 说明 | 类型 | 默认值 | +| ------------------ | ------------------------------ | -------- | ------ | +| `text`/`plainText` | 待加密的明文 | `string` | - | +| `encryptedText` | 待解密的密文(`iv:encrypted`) | `string` | - | +| `key` | 加密 / 解密密钥 | `string` | - | + +所有函数均返回 `Promise<string>`。加密输出格式为 `ivBase64:encryptedBase64`(CryptoJS 一对为 `iv:encrypted`)。`decryptAES` 在解密失败时返回空字符串。 diff --git a/.storybook/stories/utils/crypto/index.stories.tsx b/.storybook/stories/utils/crypto/index.stories.tsx new file mode 100644 index 0000000..bf3d478 --- /dev/null +++ b/.storybook/stories/utils/crypto/index.stories.tsx @@ -0,0 +1,141 @@ +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { Alert, Button, Card, Input, Space, Switch, Typography } from 'antd'; +import { advancedDecrypt, advancedEncrypt, decryptAES, encryptAES } from '../../../../src/utils/crypto'; +import storyI18n, { storyT, useStoryT } from '../../../locales'; +import apiDocEN from './api-doc.en-US.md?raw'; +import apiDocCN from './api-doc.zh-CN.md?raw'; +import introduceEN from './introduce.en-US.md?raw'; +import introduceCN from './introduce.zh-CN.md?raw'; + +interface CryptoStoryArgs { + text: string; + key: string; + advanced: boolean; +} + +const meta: Meta<CryptoStoryArgs> = { + title: 'Utils/crypto', + parameters: { + docs: { + description: { + component: storyI18n.language === 'zh-CN' ? `${introduceCN}\n${apiDocCN}` : `${introduceEN}\n${apiDocEN}`, + }, + }, + }, + args: { + text: 'Hello, React Easy!', + key: 'my-secret-key', + advanced: false, + }, + argTypes: { + text: { + control: 'text', + description: storyT('storybook.stories.crypto.argTypes.text.description'), + }, + key: { + control: 'text', + description: storyT('storybook.stories.crypto.argTypes.key.description'), + }, + advanced: { + control: 'boolean', + description: storyT('storybook.stories.crypto.argTypes.advanced.description'), + }, + }, +}; + +export default meta; +type Story = StoryObj<CryptoStoryArgs>; + +export const Playground: Story = { + parameters: { + docs: { + description: { + story: + '- **EN:** Enter plain text and a key, then encrypt and decrypt with the standard AES flow, or toggle `advanced` to use the double-pass flow.\n- **CN:** 输入明文与密钥,使用标准 AES 流程加密/解密,或切换 `advanced` 使用双重加密流程。', + }, + }, + }, + render: function Render(args: CryptoStoryArgs) { + return <CryptoStoryDemo {...args} />; + }, +}; + +function CryptoStoryDemo({ text, key: secretKey, advanced }: CryptoStoryArgs) { + const t = useStoryT(); + const [plainText, setPlainText] = useState(text); + const [secret, setSecret] = useState(secretKey); + const [cipherText, setCipherText] = useState(''); + const [decrypted, setDecrypted] = useState(''); + const [busy, setBusy] = useState(false); + + const handleEncrypt = async () => { + setBusy(true); + try { + const result = advanced ? await advancedEncrypt(plainText, secret) : await encryptAES(plainText, secret); + setCipherText(result); + setDecrypted(''); + } finally { + setBusy(false); + } + }; + + const handleDecrypt = async () => { + if (!cipherText) return; + setBusy(true); + try { + const result = advanced ? await advancedDecrypt(cipherText, secret) : await decryptAES(cipherText, secret); + setDecrypted(result); + } finally { + setBusy(false); + } + }; + + return ( + <Card variant="outlined" style={{ maxWidth: 920 }} title={t('storybook.stories.crypto.cardTitle')}> + <Space orientation="vertical" size="large" style={{ width: '100%' }}> + <Typography.Paragraph style={{ marginBottom: 0 }}> + {t('storybook.stories.crypto.description')} + </Typography.Paragraph> + + <Space wrap> + <Typography.Text strong>{t('storybook.stories.crypto.plainTextLabel')}</Typography.Text> + <Input style={{ maxWidth: 280 }} value={plainText} onChange={(e) => setPlainText(e.target.value)} /> + </Space> + + <Space wrap> + <Typography.Text strong>{t('storybook.stories.crypto.keyLabel')}</Typography.Text> + <Input.Password style={{ maxWidth: 280 }} value={secret} onChange={(e) => setSecret(e.target.value)} /> + </Space> + + <Space wrap> + <Typography.Text strong>{t('storybook.stories.crypto.advancedLabel')}</Typography.Text> + <Switch checked={advanced} disabled /> + </Space> + + <Space wrap> + <Button type="primary" loading={busy} onClick={() => void handleEncrypt()}> + {t('storybook.stories.crypto.actions.encrypt')} + </Button> + <Button loading={busy} onClick={() => void handleDecrypt()}> + {t('storybook.stories.crypto.actions.decrypt')} + </Button> + </Space> + + <Space orientation="vertical" size={4} style={{ width: '100%' }}> + <Typography.Text strong>{t('storybook.stories.crypto.cipherLabel')}</Typography.Text> + <Typography.Text code copyable style={{ wordBreak: 'break-all' }}> + {cipherText || '—'} + </Typography.Text> + </Space> + + <Space orientation="vertical" size={4} style={{ width: '100%' }}> + <Typography.Text strong>{t('storybook.stories.crypto.resultLabel')}</Typography.Text> + <Typography.Text code>{decrypted || '—'}</Typography.Text> + </Space> + + <Alert type="info" title={t('storybook.stories.crypto.tip')} showIcon /> + </Space> + </Card> + ); +} diff --git a/.storybook/stories/utils/crypto/introduce.en-US.md b/.storybook/stories/utils/crypto/introduce.en-US.md new file mode 100644 index 0000000..62d7866 --- /dev/null +++ b/.storybook/stories/utils/crypto/introduce.en-US.md @@ -0,0 +1,41 @@ +AES encryption utility module for Node.js and browser environments. `encryptAES` / `decryptAES` provide standard AES-256-CBC encryption backed by the Web Crypto API (or Node `crypto`), with a CryptoJS fallback for non-secure contexts. `advancedEncrypt` / `advancedDecrypt` add a random one-time inner key and a second AES pass for extra obfuscation. + +## When to use + +- Encrypting sensitive strings (tokens, drafts, config values) before persisting or transmitting them. +- Decrypting data previously produced by these functions — or by any AES-256-CBC producer sharing the same `iv:encrypted` (base64) output format. +- Adding an extra layer of obfuscation on top of standard AES when the key might be exposed (advanced functions). + +## Key features + +- **Cross-platform** — AES-256-CBC via the Web Crypto API in browsers and `crypto` in Node.js, with a unified `ivBase64:encryptedBase64` output format. +- **CryptoJS fallback** — `encryptWithCryptoJS` / `decryptWithCryptoJS` keep encryption working in insecure (HTTP) contexts. +- **Random IV per run** — every encryption embeds a fresh random IV, so the same input produces different ciphertext each time. +- **Advanced double pass** — `advancedEncrypt` splices a random one-time key into the ciphertext and re-encrypts it with the user key. + +## Sample code + +```tsx +import { advancedDecrypt, advancedEncrypt, decryptAES, encryptAES } from '@tiny-codes/react-easy'; + +async function demo() { + const key = 'my-secret-key'; + + const cipherText = await encryptAES('Hello, React Easy!', key); + // e.g. "hGq9...U8w==:a8Z1...==" (ivBase64:encryptedBase64) + + const plainText = await decryptAES(cipherText, key); + console.log(plainText); // "Hello, React Easy!" + + const wrapped = await advancedEncrypt('Hello, React Easy!', key); + const unwrapped = await advancedDecrypt(wrapped, key); + console.log(unwrapped); // "Hello, React Easy!" +} +``` + +## Usage notes + +- The output format is `ivBase64:encryptedBase64` (or `iv:encrypted` for the CryptoJS pair); keep the two parts intact when decrypting. +- `decryptAES` resolves to an empty string when the input is malformed, the key is wrong, or the ciphertext is invalid. +- `advancedDecrypt` is the inverse of `advancedEncrypt` only — do not mix it with the standard functions. +- The same `key` must be used for encryption and decryption; keys are hashed with SHA-256 before use. diff --git a/.storybook/stories/utils/crypto/introduce.zh-CN.md b/.storybook/stories/utils/crypto/introduce.zh-CN.md new file mode 100644 index 0000000..653d94a --- /dev/null +++ b/.storybook/stories/utils/crypto/introduce.zh-CN.md @@ -0,0 +1,41 @@ +面向 Node.js 与浏览器环境的 AES 加密工具模块。`encryptAES` / `decryptAES` 提供基于 Web Crypto API(或 Node `crypto`)的标准 AES-256-CBC 加密,并在非安全上下文中回退到 CryptoJS。`advancedEncrypt` / `advancedDecrypt` 通过随机一次性内部密钥与二次 AES 加密增加额外混淆。 + +## 适用场景 + +- 在持久化或传输前加密敏感字符串(如令牌、草稿、配置值)。 +- 解密由这些函数——或任何共享 `iv:encrypted`(base64)输出格式的 AES-256-CBC 生产者——加密的数据。 +- 在标准 AES 之上叠加额外混淆层(高级函数),以应对密钥可能暴露的场景。 + +## 核心特性 + +- **跨平台** —— 浏览器端使用 Web Crypto API、Node.js 端使用 `crypto` 模块实现 AES-256-CBC,输出格式统一为 `ivBase64:encryptedBase64`。 +- **CryptoJS 回退** —— `encryptWithCryptoJS` / `decryptWithCryptoJS` 在非安全(HTTP)上下文中仍可正常工作。 +- **每次加密随机 IV** —— 每次加密都嵌入全新的随机 IV,因此相同输入每次产生的密文都不同。 +- **高级双重加密** —— `advancedEncrypt` 将随机一次性密钥拼入密文,再用用户密钥二次加密。 + +## 示例代码 + +```tsx +import { advancedDecrypt, advancedEncrypt, decryptAES, encryptAES } from '@tiny-codes/react-easy'; + +async function demo() { + const key = 'my-secret-key'; + + const cipherText = await encryptAES('Hello, React Easy!', key); + // 例如 "hGq9...U8w==:a8Z1...==" (ivBase64:encryptedBase64) + + const plainText = await decryptAES(cipherText, key); + console.log(plainText); // "Hello, React Easy!" + + const wrapped = await advancedEncrypt('Hello, React Easy!', key); + const unwrapped = await advancedDecrypt(wrapped, key); + console.log(unwrapped); // "Hello, React Easy!" +} +``` + +## 使用注意 + +- 输出格式为 `ivBase64:encryptedBase64`(CryptoJS 一对为 `iv:encrypted`);解密时必须保持两部分完整。 +- 当输入格式错误、密钥不正确或密文无效时,`decryptAES` 返回空字符串。 +- `advancedDecrypt` 只能作为 `advancedEncrypt` 的逆操作,不要与标准函数混用。 +- 加解密必须使用相同的 `key`;密钥在使用前会经过 SHA-256 哈希。 diff --git a/.storybook/stories/utils/math/api-doc.en-US.md b/.storybook/stories/utils/math/api-doc.en-US.md new file mode 100644 index 0000000..764eaa7 --- /dev/null +++ b/.storybook/stories/utils/math/api-doc.en-US.md @@ -0,0 +1,26 @@ +## API + +### Functions + +| Function | Description | Signature | +| -------- | ------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | +| `random` | Generate a random decimal in `[0, 1)`, or an inclusive random integer in `[min, max]` | `random(): number`<br>`random(min: number, max: number): number` | + +### random() — no arguments + +| Name | Description | Type | (Default) | +| ---- | ------------- | ---- | --------- | +| - | No parameters | - | - | + +### random(min, max) + +| Name | Description | Type | (Default) | +| ----- | ------------------------------------------------------- | -------- | --------- | +| `min` | The minimum value (inclusive); must be a finite integer | `number` | - | +| `max` | The maximum value (inclusive); must be a finite integer | `number` | - | + +### Return + +| Member | Description | Signature | +| -------- | --------------------------------------------------------------------------------------------------- | --------- | +| (result) | Decimal in `[0, 1)` for `random()`; integer in `[min, max]` (both inclusive) for `random(min, max)` | `number` | diff --git a/.storybook/stories/utils/math/api-doc.zh-CN.md b/.storybook/stories/utils/math/api-doc.zh-CN.md new file mode 100644 index 0000000..f3adfe0 --- /dev/null +++ b/.storybook/stories/utils/math/api-doc.zh-CN.md @@ -0,0 +1,26 @@ +## API + +### 函数 + +| 函数 | 说明 | 签名 | +| -------- | ------------------------------------------------------------ | ---------------------------------------------------------------- | +| `random` | 生成 `[0, 1)` 之间的小数,或 `[min, max]` 包含两端的随机整数 | `random(): number`<br>`random(min: number, max: number): number` | + +### random() —— 无参形式 + +| 参数 | 说明 | 类型 | 默认值 | +| ---- | -------- | ---- | ------ | +| - | 无需参数 | - | - | + +### random(min, max) + +| 参数 | 说明 | 类型 | 默认值 | +| ----- | ------------------------------ | -------- | ------ | +| `min` | 最小值(包含);必须为有限整数 | `number` | - | +| `max` | 最大值(包含);必须为有限整数 | `number` | - | + +### 返回值 + +| 成员 | 说明 | 签名 | +| ------ | --------------------------------------------------------------------------------------- | -------- | +| (结果) | `random()` 返回 `[0, 1)` 的小数;`random(min, max)` 返回 `[min, max]`(两端包含)的整数 | `number` | diff --git a/.storybook/stories/utils/math/index.stories.tsx b/.storybook/stories/utils/math/index.stories.tsx new file mode 100644 index 0000000..1e0f387 --- /dev/null +++ b/.storybook/stories/utils/math/index.stories.tsx @@ -0,0 +1,94 @@ +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { Alert, Button, Card, InputNumber, Space, Typography } from 'antd'; +import { random } from '../../../../src/utils/math'; +import storyI18n, { storyT, useStoryT } from '../../../locales'; +import apiDocEN from './api-doc.en-US.md?raw'; +import apiDocCN from './api-doc.zh-CN.md?raw'; +import introduceEN from './introduce.en-US.md?raw'; +import introduceCN from './introduce.zh-CN.md?raw'; + +interface MathStoryArgs { + min: number; + max: number; +} + +const meta: Meta<MathStoryArgs> = { + title: 'Utils/math', + parameters: { + docs: { + description: { + component: storyI18n.language === 'zh-CN' ? `${introduceCN}\n${apiDocCN}` : `${introduceEN}\n${apiDocEN}`, + }, + }, + }, + args: { + min: 1, + max: 100, + }, + argTypes: { + min: { + control: { type: 'number' }, + description: storyT('storybook.stories.math.argTypes.min.description'), + }, + max: { + control: { type: 'number' }, + description: storyT('storybook.stories.math.argTypes.max.description'), + }, + }, +}; + +export default meta; +type Story = StoryObj<MathStoryArgs>; + +export const Playground: Story = { + parameters: { + docs: { + description: { + story: + '- **EN:** Click **random()** to generate a decimal in `[0, 1)`. Set `min`/`max` and click **random(min, max)** to generate an inclusive random integer.\n- **CN:** 点击 **random()** 生成 `[0, 1)` 之间的小数。设置 `min`/`max` 后点击 **random(min, max)** 生成包含两端在内的随机整数。', + }, + }, + }, + render: function Render(args: MathStoryArgs) { + return <MathStoryDemo {...args} />; + }, +}; + +function MathStoryDemo({ min, max }: MathStoryArgs) { + const t = useStoryT(); + const [decimal, setDecimal] = useState<number | null>(null); + const [integer, setInteger] = useState<number | null>(null); + + return ( + <Card variant="outlined" style={{ maxWidth: 920 }} title={t('storybook.stories.math.cardTitle')}> + <Space orientation="vertical" size="large" style={{ width: '100%' }}> + <Typography.Paragraph style={{ marginBottom: 0 }}> + {t('storybook.stories.math.description')} + </Typography.Paragraph> + + <Space wrap> + <Button type="primary" onClick={() => setDecimal(random())}> + {t('storybook.stories.math.actions.randomDecimal')} + </Button> + <Typography.Text strong>{t('storybook.stories.math.decimalResultLabel')}</Typography.Text> + <Typography.Text code>{decimal == null ? '—' : decimal.toFixed(8)}</Typography.Text> + </Space> + + <Space wrap> + <Typography.Text strong>{t('storybook.stories.math.minLabel')}</Typography.Text> + <InputNumber value={min} style={{ width: 120 }} /> + <Typography.Text strong>{t('storybook.stories.math.maxLabel')}</Typography.Text> + <InputNumber value={max} style={{ width: 120 }} /> + <Button onClick={() => setInteger(random(min, max))}> + {t('storybook.stories.math.actions.randomInteger')} + </Button> + <Typography.Text strong>{t('storybook.stories.math.integerResultLabel')}</Typography.Text> + <Typography.Text code>{integer == null ? '—' : integer}</Typography.Text> + </Space> + + <Alert type="info" title={t('storybook.stories.math.tip')} showIcon /> + </Space> + </Card> + ); +} diff --git a/.storybook/stories/utils/math/introduce.en-US.md b/.storybook/stories/utils/math/introduce.en-US.md new file mode 100644 index 0000000..1965e4a --- /dev/null +++ b/.storybook/stories/utils/math/introduce.en-US.md @@ -0,0 +1,36 @@ +The math utility offers a cryptographically strong `random` helper with two overloads: `random()` yields a decimal in `[0, 1)`, and `random(min, max)` yields a uniform random integer inclusive of both bounds. It prefers `crypto.randomInt`/`getRandomValues` and falls back to a rejection-sampling algorithm so the distribution stays uniform. + +## When to use + +- Generating a non-predictable random decimal for probability or sampling logic. +- Producing a random integer within a range, for example picking an array index or a lottery number. +- Anywhere a stronger generator than built-in `Math.random` is preferred. + +## Key features + +- **Two overloads** — `random()` for a `[0, 1)` decimal, `random(min, max)` for an inclusive integer. +- **Cryptographically secure** — Backed by Node `crypto.randomInt` / `webcrypto` when available, with an unbiased rejection-sampling fallback. +- **Safe validation** — Non-finite or non-integer bounds throw a `TypeError`. +- **Tolerant ordering** — If `min > max` the two are swapped automatically, so the range stays valid. + +## Sample code + +```ts +import { random } from '@tiny-codes/react-easy'; + +// Decimal in [0, 1) +const ratio = random(); // e.g. 0.5123 + +// Inclusive integer in [1, 6], like rolling a die +const dice = random(1, 6); + +// Range is auto-swapped when min > max +const clamped = random(10, 1); // same as random(1, 10) +``` + +## Usage notes + +- The no-arg form returns a float in `[0, 1)`; the two-arg form returns an integer in `[min, max]`, both inclusive. +- Both `min` and `max` must be provided together — passing only one, a non-finite number, or a non-integer throws a `TypeError`. +- When `min` is greater than `max` they are swapped before sampling, so no `RangeError` is raised. +- The integer range is uniform (no modulo bias) thanks to rejection sampling; performance is not a concern for typical ranges. diff --git a/.storybook/stories/utils/math/introduce.zh-CN.md b/.storybook/stories/utils/math/introduce.zh-CN.md new file mode 100644 index 0000000..5116436 --- /dev/null +++ b/.storybook/stories/utils/math/introduce.zh-CN.md @@ -0,0 +1,36 @@ +数学工具函数 `random` 提供密码学强度的随机数,包含两个重载:`random()` 生成 `[0, 1)` 之间的小数,`random(min, max)` 生成包含两端在内的均匀随机整数。它优先使用 `crypto.randomInt`/`getRandomValues`,并通过无偏的拒绝采样算法保证分布公平。 + +## 适用场景 + +- 需要非可预测的随机小数,用于概率判断或主题切换。 +- 在某个范围内生成随机整数,例如随机选取数组下标或抽取号码。 +- 希望比内置 `Math.random` 生成器更均匀、更安全的场景。 + +## 核心特性 + +- **双重载** —— `random()` 生成 `[0, 1)` 小数,`random(min, max)` 生成包含两端的整数。 +- **密码学安全** —— 优先使用 Node `crypto.randomInt` / `webcrypto`,并提供无偏的拒绝采样回退。 +- **参数校验** —— min/max 非有限数或非整数时抛出 `TypeError`。 +- **容错排序** —— 当 `min` 大于 `max` 时自动交换,始终保证范围合法。 + +## 示例代码 + +```ts +import { random } from '@tiny-codes/react-easy'; + +// [0, 1) 之间的小数 +const ratio = random(); // 例如 0.5123 + +// [min, max] 包含两端的整数,例如掷骰子 +const die = random(1, 6); + +// min 大于 max 时自动交换 +const safe = random(5, 1); // 等价于 random(1, 5) +``` + +## 使用注意 + +- 无参形式返回 `[0, 1)` 的小数;两参形式返回 `[min, max]`(两端包含)的整数。 +- `min` 与 `max` 必须同时提供 —— 只传一个、传入非有限数或非整数都会抛出 `TypeError`。 +- 当 `min` 大于 `max` 时会在计算前交换,因此不会抛出范围错误。 +- 借助拒绝采样,整数范围保持均匀无偏差;对常规范围性能影响很小。 diff --git a/.storybook/stories/utils/stream/api-doc.en-US.md b/.storybook/stories/utils/stream/api-doc.en-US.md new file mode 100644 index 0000000..007c774 --- /dev/null +++ b/.storybook/stories/utils/stream/api-doc.en-US.md @@ -0,0 +1,32 @@ +## API + +### Class: `StreamTimeSlicerClass` + +| Member | Description | Signature | +| ------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| `constructor` | Create a stream time slicer | `new StreamTimeSlicerClass(options: StreamTimeSlicerOptions): StreamTimeSlicerClass` | +| `sliceMode` | Mode of slicing (`'time'` by milliseconds, `'size'` by sample count) | `StreamTimeSlicerOptions['sliceMode']` | +| `value` | Value for slicing (ms or bytes) | `StreamTimeSlicerOptions['value']` | +| `push` | Push a frame (multi-channel data from the same callback) | `push(channels: Float32Array[]): void` | +| `flush` | Force output the current accumulation (even if below the threshold) | `flush(): void` | +| `reset` | Clear the cache (do not output) | `reset(): void` | +| `duration` | Get accumulated duration from start to current (ms) | `duration(): number` | + +### Interface: `StreamTimeSlicerOptions` + +| Name | Description | Type | (Default) | +| ----------- | -------------------------------------------------------------------- | ----------------------------------------------------------- | --------- | +| `sliceMode` | Mode of slicing: `'time'` (by time, ms) or `'size'` (by size, bytes) | `'time' \| 'size'` | - | +| `value` | Value for slicing (ms or bytes); `<= 0` means immediate output | `number` | - | +| `onSlice` | Callback when a slice is reached | `(channels: Float32Array[], sliceDuration: number) => void` | - | + +### Interface: `StreamTimeSlicer` + +| Member | Description | Signature | +| ----------- | ----------------------------------------------------------------- | ------------------------------------ | +| `sliceMode` | Mode of slicing (read-only) | `'time' \| 'size'` | +| `value` | Value for slicing (ms or bytes) | `number` | +| `push` | Push a frame (multi-channel data from the same callback) | `(channels: Float32Array[]) => void` | +| `flush` | Force output the current accumulation (output even if not enough) | `() => void` | +| `reset` | Clear the cache (do not output) | `() => void` | +| `duration` | Get accumulated duration from start to current (ms) | `() => number` | diff --git a/.storybook/stories/utils/stream/api-doc.zh-CN.md b/.storybook/stories/utils/stream/api-doc.zh-CN.md new file mode 100644 index 0000000..bb00dae --- /dev/null +++ b/.storybook/stories/utils/stream/api-doc.zh-CN.md @@ -0,0 +1,32 @@ +## API + +### 类:`StreamTimeSlicerClass` + +| 成员 | 描述 | 签名 | +| ------------- | ------------------------------------------- | ------------------------------------------------------------------------------------ | +| `constructor` | 创建数据流时间切片器 | `new StreamTimeSlicerClass(options: StreamTimeSlicerOptions): StreamTimeSlicerClass` | +| `sliceMode` | 切片模式(`'time'` 毫秒 / `'size'` 采样数) | `StreamTimeSlicerOptions['sliceMode']` | +| `value` | 切片值(毫秒或字节) | `StreamTimeSlicerOptions['value']` | +| `push` | 推入一帧(同一次回调得到的多声道数据) | `push(channels: Float32Array[]): void` | +| `flush` | 强制输出当前累积(不足阈值也输出) | `flush(): void` | +| `reset` | 清空缓存(不输出) | `reset(): void` | +| `duration` | 获取从开始到当前已累计的时长(ms) | `duration(): number` | + +### 接口:`StreamTimeSlicerOptions` + +| 名称 | 描述 | 类型 | (默认值) | +| ----------- | ----------------------------------------------------------- | ----------------------------------------------------------- | -------- | +| `sliceMode` | 切片模式:`'time'`(按时间,ms)或 `'size'`(按大小,字节) | `'time' \| 'size'` | - | +| `value` | 切片值(ms 或字节数);`<= 0` 表示立即输出 | `number` | - | +| `onSlice` | 达到分片时回调 | `(channels: Float32Array[], sliceDuration: number) => void` | - | + +### 接口:`StreamTimeSlicer` + +| 成员 | 描述 | 签名 | +| ----------- | ----------------------------------------- | ------------------------------------ | +| `sliceMode` | 切片模式(只读) | `'time' \| 'size'` | +| `value` | 切片值(ms 或字节数) | `number` | +| `push` | 推入一帧(同一次回调得到的多通道数据) | `(channels: Float32Array[]) => void` | +| `flush` | 强制输出当前累积(不足 timeSlice 也输出) | `() => void` | +| `reset` | 清空缓存(不输出) | `() => void` | +| `duration` | 获取从开始到当前已累计的时长(ms) | `() => number` | diff --git a/.storybook/stories/utils/stream/index.stories.tsx b/.storybook/stories/utils/stream/index.stories.tsx new file mode 100644 index 0000000..de48c26 --- /dev/null +++ b/.storybook/stories/utils/stream/index.stories.tsx @@ -0,0 +1,190 @@ +import { useMemo, useRef, useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { Alert, Button, Card, InputNumber, List, Space, Tag, Typography } from 'antd'; +import { StreamTimeSlicerClass } from '../../../../src/utils/stream'; +import storyI18n, { storyT, useStoryT } from '../../../locales'; +import apiDocEN from './api-doc.en-US.md?raw'; +import apiDocCN from './api-doc.zh-CN.md?raw'; +import introduceEN from './introduce.en-US.md?raw'; +import introduceCN from './introduce.zh-CN.md?raw'; + +interface StreamStoryArgs { + sliceSize: number; + chunkLength: number; +} + +interface SliceLog { + id: number; + channels: number; + samples: number; + durationMs: number; + time: string; +} + +const meta: Meta<StreamStoryArgs> = { + title: 'Utils/stream', + parameters: { + docs: { + description: { + component: storyI18n.language === 'zh-CN' ? `${introduceCN}\n${apiDocCN}` : `${introduceEN}\n${apiDocEN}`, + }, + }, + }, + args: { + sliceSize: 1024, + chunkLength: 256, + }, + argTypes: { + sliceSize: { + control: { type: 'number', min: 128, max: 8192, step: 128 }, + description: storyT('storybook.stories.stream.argTypes.sliceSize.description'), + }, + chunkLength: { + control: { type: 'number', min: 32, max: 2048, step: 32 }, + description: storyT('storybook.stories.stream.argTypes.chunkLength.description'), + }, + }, +}; + +export default meta; +type Story = StoryObj<StreamStoryArgs>; + +export const Playground: Story = { + parameters: { + docs: { + description: { + story: + "- **EN:** This demo pushes simulated mono-channel PCM frames into a `StreamTimeSlicerClass` configured with `sliceMode: 'size'`. Once the accumulated samples reach `sliceSize`, the slicer emits a merged `Float32Array` through `onSlice` and resets.\n- **CN:** 本 demo 向配置了 `sliceMode: 'size'` 的 `StreamTimeSlicerClass` 推入模拟的单声道 PCM 帧。当累积采样数达到 `sliceSize` 时,切片器通过 `onSlice` 输出合并后的 `Float32Array` 并重置。", + }, + }, + }, + render: function Render(args: StreamStoryArgs) { + return <StreamStoryDemo {...args} />; + }, +}; + +function StreamStoryDemo({ sliceSize, chunkLength }: StreamStoryArgs) { + const t = useStoryT(); + const slicerRef = useRef<StreamTimeSlicerClass | null>(null); + const eventIdRef = useRef(0); + const [logs, setLogs] = useState<SliceLog[]>([]); + const [draftSliceSize, setDraftSliceSize] = useState(sliceSize); + const [draftChunkLength, setDraftChunkLength] = useState(chunkLength); + + if (!slicerRef.current) { + slicerRef.current = new StreamTimeSlicerClass({ + sliceMode: 'size', + value: sliceSize, + onSlice: (channels, sliceDuration) => { + setLogs((prev) => { + const next = [ + { + id: ++eventIdRef.current, + channels: channels.length, + samples: channels.reduce((sum, ch) => sum + ch.length, 0), + durationMs: Math.round(sliceDuration), + time: new Date().toLocaleTimeString(), + }, + ...prev, + ]; + return next.slice(0, 6); + }); + }, + }); + } + const slicer = slicerRef.current; + + // Keep `value` in sync with the arg so re-renders respect the latest sliceSize. + useMemo(() => { + slicer.value = sliceSize; + return sliceSize; + }, [sliceSize, slicer]); + + const pushChunk = () => { + // Push one frame of mono PCM samples (values in [-1, 1]) for the slicer to accumulate. + const frame = new Float32Array(draftChunkLength); + for (let i = 0; i < draftChunkLength; i++) { + frame[i] = Math.sin((i + (Date.now() % 1000)) / 16) * 0.5; + } + slicer.push([frame]); + }; + + const flushNow = () => { + slicer.flush(); + }; + + const resetNow = () => { + slicer.reset(); + setLogs([]); + }; + + return ( + <Card variant="outlined" style={{ maxWidth: 920 }} title={t('storybook.stories.stream.cardTitle')}> + <Space orientation="vertical" size="large" style={{ width: '100%' }}> + <Typography.Paragraph style={{ marginBottom: 0 }}> + {t('storybook.stories.stream.description')} + </Typography.Paragraph> + + <Space wrap> + <Space size="small"> + <Typography.Text>{t('storybook.stories.stream.sliceSizeLabel')}</Typography.Text> + <InputNumber + min={128} + max={8192} + step={128} + value={draftSliceSize} + onChange={(value) => setDraftSliceSize(value ?? sliceSize)} + /> + </Space> + <Space size="small"> + <Typography.Text>{t('storybook.stories.stream.chunkLengthLabel')}</Typography.Text> + <InputNumber + min={32} + max={2048} + step={32} + value={draftChunkLength} + onChange={(value) => setDraftChunkLength(value ?? chunkLength)} + /> + </Space> + </Space> + + <Space wrap> + <Button type="primary" onClick={pushChunk}> + {t('storybook.stories.stream.actions.push')} + </Button> + <Button onClick={flushNow}>{t('storybook.stories.stream.actions.flush')}</Button> + <Button onClick={resetNow}>{t('storybook.stories.stream.actions.reset')}</Button> + <Tag color="blue">{t('storybook.stories.stream.modeTag', { value: 'size' })}</Tag> + </Space> + + <div> + <Typography.Text strong>{t('storybook.stories.stream.logTitle')}</Typography.Text> + <List + bordered + size="small" + rowKey="id" + dataSource={logs} + locale={{ emptyText: t('storybook.stories.stream.emptyLog') }} + renderItem={(item) => ( + <List.Item> + <Space split={<Tag />} size="small" wrap> + <Typography.Text code>{`#${item.id}`}</Typography.Text> + <Typography.Text> + {t('storybook.stories.stream.logChannels', { value: item.channels })} + </Typography.Text> + <Typography.Text>{t('storybook.stories.stream.logSamples', { value: item.samples })}</Typography.Text> + <Typography.Text type="secondary"> + {t('storybook.stories.stream.logDuration', { value: item.durationMs })} + </Typography.Text> + <Typography.Text type="secondary">{item.time}</Typography.Text> + </Space> + </List.Item> + )} + /> + </div> + + <Alert type="info" title={t('storybook.stories.stream.tip')} showIcon /> + </Space> + </Card> + ); +} diff --git a/.storybook/stories/utils/stream/introduce.en-US.md b/.storybook/stories/utils/stream/introduce.en-US.md new file mode 100644 index 0000000..53b20df --- /dev/null +++ b/.storybook/stories/utils/stream/introduce.en-US.md @@ -0,0 +1,48 @@ +`StreamTimeSlicerClass` splits an incoming PCM audio stream into fixed-size slices. You push multi-channel frames into it, and it accumulates the samples until the configured threshold (either elapsed time in milliseconds or total sample count) is reached, then calls `onSlice` with the merged `Float32Array[]` and the actual duration of that slice. It is designed for scenarios where audio data arrives in small chunks and downstream stages (speech recognition, audio analysis, or network upload) need consolidated segments. + +## When to use + +- You receive audio frames in small chunks (for example from `getUserMedia`, WebSocket, or a `ReadableStream`) and need to batch them into fixed segments. +- You need to slice by elapsed time (`sliceMode: 'time'`) or by accumulated sample count (`sliceMode: 'size'`). +- You want to force out a partial segment on demand with `flush()` — for example at the end of a session. +- You want to reset the accumulator without emitting anything (`reset()`). + +## Key features + +- **Two slice modes** — `'time'` slices by milliseconds; `'size'` slices by total sample count. +- **Multi-channel aware** — each frame is an array of `Float32Array` (one per channel); slices preserve channel structure. +- **Merged output** — accumulated chunks per channel are concatenated into a single `Float32Array` before `onSlice` fires. +- **Immediate mode** — when `value <= 0`, every `push()` emits immediately. +- **On-demand control** — `flush()` force-emits the current accumulation; `reset()` clears the cache without emitting; `duration()` reports accumulated time. + +## Sample code + +```ts +import { StreamTimeSlicerClass } from '@tiny-codes/react-easy'; + +const slicer = new StreamTimeSlicerClass({ + sliceMode: 'size', + value: 2048, // emit when 2048 samples have accumulated per channel + onSlice: (channels, sliceDurationMs) => { + // channels: Float32Array[] — one merged array per channel + // sliceDurationMs: number — actual duration of this slice + sendToServer(channels, sliceDurationMs); + }, +}); + +// On every audio frame from getUserMedia / WebSocket / stream: +function onAudioFrame(frame: Float32Array[]) { + slicer.push(frame); +} + +// When the session ends, force out whatever is left: +slicer.flush(); +``` + +## Usage notes + +- `sliceMode: 'time'` compares against `performance.now()` elapsed time since the first `push()` of the current slice; `'size'` compares the accumulated sample count. +- `value <= 0` enables immediate output mode: every `push()` emits a slice right away. +- `flush()` outputs even when the threshold is not reached, and clears the accumulator; `reset()` clears without output. +- The slice duration reported to `onSlice` is measured from the first `push()` of that slice to the moment it is emitted (milliseconds). +- Frames are treated as complete units: a single frame larger than the remaining budget is kept whole and emitted together with the next frame when the threshold is exceeded. diff --git a/.storybook/stories/utils/stream/introduce.zh-CN.md b/.storybook/stories/utils/stream/introduce.zh-CN.md new file mode 100644 index 0000000..94ce316 --- /dev/null +++ b/.storybook/stories/utils/stream/introduce.zh-CN.md @@ -0,0 +1,48 @@ +`StreamTimeSlicerClass` 将输入的 PCM 音频流按固定条件切分为片段。向其中推入多声道帧后,它会累积采样,直到达到配置的阈值(按时间毫秒或按采样总数),然后调用 `onSlice`,传入合并后的 `Float32Array[]` 与该片段实际时长。它适用于音频数据以小分片到达、下游阶段(语音识别、音频分析或网络上传)需要合并片段的场景。 + +## 适用场景 + +- 以小分片接收音频帧(例如来自 `getUserMedia`、WebSocket 或 `ReadableStream`),需要将其合并为固定片段。 +- 需要按经过时间(`sliceMode: 'time'`)或按累计采样数(`sliceMode: 'size'`)切片。 +- 需要在会话结束时用 `flush()` 强制输出当前累积的不足片段。 +- 需要清空累积且不输出任何内容(`reset()`)。 + +## 核心特性 + +- **两种切片模式** — `'time'` 按毫秒切片;`'size'` 按累计采样总数切片。 +- **多声道感知** — 每帧是 `Float32Array` 数组(每声道一个);切片保留声道结构。 +- **合并输出** — 触发 `onSlice` 前,各声道累积的分片会拼接为单个 `Float32Array`。 +- **立即模式** — 当 `value <= 0` 时,每次 `push()` 立即输出一个切片。 +- **按需控制** — `flush()` 强制输出当前累积;`reset()` 清空缓存不输出;`duration()` 报告已累积时长。 + +## 示例代码 + +```ts +import { StreamTimeSlicerClass } from '@tiny-codes/react-easy'; + +const slicer = new StreamTimeSlicerClass({ + sliceMode: 'size', + value: 2048, // 每声道累计 2048 个采样时输出 + onSlice: (channels, sliceDurationMs) => { + // channels: Float32Array[] — 每声道一个合并后的数组 + // sliceDurationMs: number — 该切片的实际时长 + sendToServer(channels, sliceDurationMs); + }, +}); + +// 在每次来自 getUserMedia / WebSocket / 流的音频帧上: +function onAudioFrame(frame: Float32Array[]) { + slicer.push(frame); +} + +// 会话结束时,强制输出剩余数据: +slicer.flush(); +``` + +## 使用注意 + +- `sliceMode: 'time'` 比较的是当前切片首次 `push()` 以来 `performance.now()` 的经过时间;`'size'` 比较累计采样数。 +- `value <= 0` 为立即输出模式:每次 `push()` 都会立刻输出一个切片。 +- `flush()` 在未达阈值时也会输出,并清空累积;`reset()` 清空但不输出。 +- 传给 `onSlice` 的切片时长是从该切片首次 `push()` 到输出时刻的毫秒数。 +- 帧是完整单元:单帧大于剩余额度时会整体保留,待下一帧一起在超阈值时输出。 diff --git a/.storybook/stories/utils/string/api-doc.en-US.md b/.storybook/stories/utils/string/api-doc.en-US.md new file mode 100644 index 0000000..67ace22 --- /dev/null +++ b/.storybook/stories/utils/string/api-doc.en-US.md @@ -0,0 +1,17 @@ +## API + +### Functions + +| Member | Description | Signature | +| --------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | +| `randomChars` | Generate a random alphanumeric string (`A-Za-z0-9`) of the given length | `(length: number) => string` | +| `readTextAnyEncoding` | Read text from a `Blob` / `ArrayBuffer` / `Uint8Array` with automatic encoding detection | `(blob: Blob \| ArrayBuffer \| Uint8Array \| undefined) => Promise<string>` | + +### Parameters + +| Name | Description | Type | (Default) | +| -------- | ------------------------------------------------- | ------------------------------------------------ | --------- | +| `length` | Length of the random string | `number` | - | +| `blob` | Text source; passing `undefined` resolves to `''` | `Blob \| ArrayBuffer \| Uint8Array \| undefined` | - | + +Encoding detection order: BOM (`utf-8` / `utf-16le` / `utf-16be`) → valid UTF-8 → fallback `gb18030`. diff --git a/.storybook/stories/utils/string/api-doc.zh-CN.md b/.storybook/stories/utils/string/api-doc.zh-CN.md new file mode 100644 index 0000000..0613e45 --- /dev/null +++ b/.storybook/stories/utils/string/api-doc.zh-CN.md @@ -0,0 +1,17 @@ +## API + +### 函数 + +| 成员 | 说明 | 签名 | +| --------------------- | --------------------------------------------------------------- | --------------------------------------------------------------------------- | +| `randomChars` | 生成指定长度的随机字母数字字符串(`A-Za-z0-9`) | `(length: number) => string` | +| `readTextAnyEncoding` | 从 `Blob` / `ArrayBuffer` / `Uint8Array` 读取文本并自动检测编码 | `(blob: Blob \| ArrayBuffer \| Uint8Array \| undefined) => Promise<string>` | + +### 参数 + +| 参数 | 说明 | 类型 | 默认值 | +| -------- | ------------------------------------ | ------------------------------------------------ | ------ | +| `length` | 随机字符串的长度 | `number` | - | +| `blob` | 文本来源;传入 `undefined` 返回 `''` | `Blob \| ArrayBuffer \| Uint8Array \| undefined` | - | + +编码检测顺序:BOM(`utf-8` / `utf-16le` / `utf-16be`)→ 合法 UTF-8 → 回退 `gb18030`。 diff --git a/.storybook/stories/utils/string/index.stories.tsx b/.storybook/stories/utils/string/index.stories.tsx new file mode 100644 index 0000000..3bca754 --- /dev/null +++ b/.storybook/stories/utils/string/index.stories.tsx @@ -0,0 +1,120 @@ +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { Alert, Button, Card, Input, InputNumber, Space, Typography } from 'antd'; +import { randomChars, readTextAnyEncoding } from '../../../../src/utils/string'; +import storyI18n, { storyT, useStoryT } from '../../../locales'; +import apiDocEN from './api-doc.en-US.md?raw'; +import apiDocCN from './api-doc.zh-CN.md?raw'; +import introduceEN from './introduce.en-US.md?raw'; +import introduceCN from './introduce.zh-CN.md?raw'; + +interface StringStoryArgs { + length: number; + sourceText: string; +} + +const meta: Meta<StringStoryArgs> = { + title: 'Utils/string', + parameters: { + docs: { + description: { + component: storyI18n.language === 'zh-CN' ? `${introduceCN}\n${apiDocCN}` : `${introduceEN}\n${apiDocEN}`, + }, + }, + }, + args: { + length: 12, + sourceText: 'Hello, React Easy!', + }, + argTypes: { + length: { + control: { type: 'number', min: 1, max: 128 }, + description: storyT('storybook.stories.string.argTypes.length.description'), + }, + sourceText: { + control: 'text', + description: storyT('storybook.stories.string.argTypes.sourceText.description'), + }, + }, +}; + +export default meta; +type Story = StoryObj<StringStoryArgs>; + +export const Playground: Story = { + parameters: { + docs: { + description: { + story: + '- **EN:** Generate a random alphanumeric string with `randomChars`, then read text back from a `Blob` with `readTextAnyEncoding`.\n- **CN:** 使用 `randomChars` 生成随机字母数字字符串,再用 `readTextAnyEncoding` 从 `Blob` 读取文本。', + }, + }, + }, + render: function Render(args: StringStoryArgs) { + return <StringStoryDemo {...args} />; + }, +}; + +function StringStoryDemo({ length, sourceText }: StringStoryArgs) { + const t = useStoryT(); + const [charsLength, setCharsLength] = useState(length); + const [randomResult, setRandomResult] = useState(''); + const [textSource, setTextSource] = useState(sourceText); + const [readResult, setReadResult] = useState(''); + const [busy, setBusy] = useState(false); + + const handleGenerate = () => { + setRandomResult(randomChars(charsLength)); + }; + + const handleRead = async () => { + setBusy(true); + try { + const blob = new Blob([textSource], { type: 'text/plain' }); + const result = await readTextAnyEncoding(blob); + setReadResult(result); + } finally { + setBusy(false); + } + }; + + return ( + <Card variant="outlined" style={{ maxWidth: 920 }} title={t('storybook.stories.string.cardTitle')}> + <Space orientation="vertical" size="large" style={{ width: '100%' }}> + <Typography.Paragraph style={{ marginBottom: 0 }}> + {t('storybook.stories.string.description')} + </Typography.Paragraph> + + <Space orientation="vertical" size={8} style={{ width: '100%' }}> + <Typography.Text strong>{t('storybook.stories.string.randomCharsTitle')}</Typography.Text> + <Space wrap> + <InputNumber min={1} max={128} value={charsLength} onChange={(value) => setCharsLength(value ?? 0)} /> + <Button type="primary" onClick={handleGenerate}> + {t('storybook.stories.string.actions.generate')} + </Button> + </Space> + <Space wrap> + <Typography.Text strong>{t('storybook.stories.string.resultLabel')}</Typography.Text> + <Typography.Text code>{randomResult || '—'}</Typography.Text> + </Space> + </Space> + + <Space orientation="vertical" size={8} style={{ width: '100%' }}> + <Typography.Text strong>{t('storybook.stories.string.readTextTitle')}</Typography.Text> + <Space wrap> + <Input style={{ maxWidth: 320 }} value={textSource} onChange={(e) => setTextSource(e.target.value)} /> + <Button loading={busy} onClick={() => void handleRead()}> + {t('storybook.stories.string.actions.read')} + </Button> + </Space> + <Space wrap> + <Typography.Text strong>{t('storybook.stories.string.resultLabel')}</Typography.Text> + <Typography.Text code>{readResult || '—'}</Typography.Text> + </Space> + </Space> + + <Alert type="info" title={t('storybook.stories.string.tip')} showIcon /> + </Space> + </Card> + ); +} diff --git a/.storybook/stories/utils/string/introduce.en-US.md b/.storybook/stories/utils/string/introduce.en-US.md new file mode 100644 index 0000000..b44f5ba --- /dev/null +++ b/.storybook/stories/utils/string/introduce.en-US.md @@ -0,0 +1,32 @@ +String utility functions for random generation and encoding-tolerant text reading. `randomChars` produces random alphanumeric strings (`A-Za-z0-9`) of a given length, and `readTextAnyEncoding` reads text from a `Blob` / `ArrayBuffer` / `Uint8Array` while auto-detecting the encoding — BOM first (utf-8 / utf-16le / utf-16be), then utf-8 validation, falling back to gb18030. + +## When to use + +- Generating random codes, tokens, or test strings of a fixed length. +- Reading uploaded files or fetched binary payloads whose encoding is unknown. +- Reading text that may be encoded as UTF-8, UTF-16 (with BOM), or GB18030 (e.g. legacy Chinese files). + +## Key features + +- **Pure and simple** — `randomChars(length)` returns a string of `A-Za-z0-9`; no dependencies, no side effects. +- **Multi-source input** — `readTextAnyEncoding` accepts a `Blob`, `ArrayBuffer`, or `Uint8Array`; passing `undefined` resolves to `''`. +- **Encoding auto-detection** — BOM detection (utf-8 / utf-16le / utf-16be) → utf-8 validation → gb18030 fallback. +- **Framework-agnostic** — works in the browser, workers, and any JS runtime that provides `TextDecoder`. + +## Sample code + +```ts +import { randomChars, readTextAnyEncoding } from '@tiny-codes/react-easy'; + +const token = randomChars(16); // e.g. "K3fA9xQ2mZ7pL5wR" + +const text = await readTextAnyEncoding(new Blob(['Hello, React Easy!'], { type: 'text/plain' })); +console.log(text); // "Hello, React Easy!" +``` + +## Usage notes + +- `randomChars` is based on `Math.random()` and is not suitable for cryptographic use — use `encryptAES` or a dedicated secure random source for secrets. +- `readTextAnyEncoding` honors a BOM first; otherwise the bytes must be valid UTF-8 or they fall back to `gb18030`. +- Passing `undefined` resolves to an empty string instead of throwing. +- The `gb18030` fallback relies on runtime ICU support; verify your target environment if it is a hard requirement. diff --git a/.storybook/stories/utils/string/introduce.zh-CN.md b/.storybook/stories/utils/string/introduce.zh-CN.md new file mode 100644 index 0000000..198611e --- /dev/null +++ b/.storybook/stories/utils/string/introduce.zh-CN.md @@ -0,0 +1,32 @@ +字符串工具函数,提供随机生成与编码宽容的文本读取能力。`randomChars` 生成指定长度的随机字母数字字符串(`A-Za-z0-9`);`readTextAnyEncoding` 从 `Blob` / `ArrayBuffer` / `Uint8Array` 中读取文本并自动检测编码——先检测 BOM(utf-8 / utf-16le / utf-16be),再做 utf-8 校验,最后回退到 gb18030。 + +## 适用场景 + +- 生成固定长度的随机验证码、令牌或测试字符串。 +- 读取编码未知的上传文件或二进制响应数据。 +- 读取可能以 UTF-8、UTF-16(带 BOM)或 GB18030(如旧版中文文件)编码的文本。 + +## 核心特性 + +- **纯粹简单** —— `randomChars(length)` 返回 `A-Za-z0-9` 字符串;无依赖、无副作用。 +- **多种输入来源** —— `readTextAnyEncoding` 接受 `Blob`、`ArrayBuffer` 或 `Uint8Array`;传入 `undefined` 返回 `''`。 +- **编码自动检测** —— BOM 检测(utf-8 / utf-16le / utf-16be)→ utf-8 校验 → gb18030 回退。 +- **与框架无关** —— 可在浏览器、Worker 及任何提供 `TextDecoder` 的 JS 运行时中使用。 + +## 示例代码 + +```ts +import { randomChars, readTextAnyEncoding } from '@tiny-codes/react-easy'; + +const token = randomChars(16); // 例如 "K3fA9xQ2mZ7pL5wR" + +const text = await readTextAnyEncoding(new Blob(['你好,React Easy'], { type: 'text/plain' })); +console.log(text); // "你好,React Easy" +``` + +## 使用注意 + +- `randomChars` 基于 `Math.random()`,不适用于密码学场景;敏感信息请使用 `encryptAES` 或专门的加密随机源。 +- `readTextAnyEncoding` 优先识别 BOM;否则字节必须是合法 UTF-8,否则回退为 `gb18030`。 +- 传入 `undefined` 时返回空字符串而不是抛错。 +- `gb18030` 回退依赖运行时的 ICU 支持,如有硬性需求请先确认目标环境。 From e5a5bce688bf79e4ba3894a7aa2eff22b1ac2ea4 Mon Sep 17 00:00:00 2001 From: Fengbao Li <shijistar@gmail.com> Date: Fri, 7 Aug 2026 08:52:03 +0800 Subject: [PATCH 27/39] docs(storybook): clarify no-arg random() as secure Math.random() replacement --- .storybook/stories/utils/math/api-doc.en-US.md | 2 ++ .storybook/stories/utils/math/api-doc.zh-CN.md | 2 ++ .storybook/stories/utils/math/introduce.en-US.md | 4 +++- .storybook/stories/utils/math/introduce.zh-CN.md | 4 +++- 4 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.storybook/stories/utils/math/api-doc.en-US.md b/.storybook/stories/utils/math/api-doc.en-US.md index 764eaa7..f003c17 100644 --- a/.storybook/stories/utils/math/api-doc.en-US.md +++ b/.storybook/stories/utils/math/api-doc.en-US.md @@ -12,6 +12,8 @@ | ---- | ------------- | ---- | --------- | | - | No parameters | - | - | +**Note**: The no-argument `random()` is a secure replacement for `Math.random()`. `Math.random()` is not a cryptographically secure random number generator (CSPRNG), and static code scanners report it as a security warning; switching to `random()` keeps the `[0, 1)` return semantics while clearing those warnings. + ### random(min, max) | Name | Description | Type | (Default) | diff --git a/.storybook/stories/utils/math/api-doc.zh-CN.md b/.storybook/stories/utils/math/api-doc.zh-CN.md index f3adfe0..16f1995 100644 --- a/.storybook/stories/utils/math/api-doc.zh-CN.md +++ b/.storybook/stories/utils/math/api-doc.zh-CN.md @@ -12,6 +12,8 @@ | ---- | -------- | ---- | ------ | | - | 无需参数 | - | - | +**说明**:无参形式 `random()` 是 `Math.random()` 的安全替代。`Math.random()` 不是加密安全随机数生成器(CSPRNG),且会被静态代码扫描报告为安全告警;改用 `random()` 可在保持 `[0, 1)` 返回值语义不变的同时消除此类告警。 + ### random(min, max) | 参数 | 说明 | 类型 | 默认值 | diff --git a/.storybook/stories/utils/math/introduce.en-US.md b/.storybook/stories/utils/math/introduce.en-US.md index 1965e4a..aae8112 100644 --- a/.storybook/stories/utils/math/introduce.en-US.md +++ b/.storybook/stories/utils/math/introduce.en-US.md @@ -1,15 +1,17 @@ -The math utility offers a cryptographically strong `random` helper with two overloads: `random()` yields a decimal in `[0, 1)`, and `random(min, max)` yields a uniform random integer inclusive of both bounds. It prefers `crypto.randomInt`/`getRandomValues` and falls back to a rejection-sampling algorithm so the distribution stays uniform. +The math utility offers a cryptographically strong `random` helper with two overloads: `random()` yields a decimal in `[0, 1)`, and `random(min, max)` yields a uniform random integer inclusive of both bounds. It prefers `crypto.randomInt`/`getRandomValues` and falls back to a rejection-sampling algorithm so the distribution stays uniform. The no-argument `random()` is a secure replacement for `Math.random()`: `Math.random()` is not cryptographically secure and static code scanners flag it as a security warning; switching to `random()` removes that warning. ## When to use - Generating a non-predictable random decimal for probability or sampling logic. - Producing a random integer within a range, for example picking an array index or a lottery number. - Anywhere a stronger generator than built-in `Math.random` is preferred. +- Replacing `Math.random()` calls to clear security warnings reported by static code scanners. ## Key features - **Two overloads** — `random()` for a `[0, 1)` decimal, `random(min, max)` for an inclusive integer. - **Cryptographically secure** — Backed by Node `crypto.randomInt` / `webcrypto` when available, with an unbiased rejection-sampling fallback. +- **Secure `Math.random()` replacement** — The no-arg `random()` returns a float in `[0, 1)`, so it can be swapped in directly for `Math.random()` to avoid its cryptographic weakness and static-scan warnings. - **Safe validation** — Non-finite or non-integer bounds throw a `TypeError`. - **Tolerant ordering** — If `min > max` the two are swapped automatically, so the range stays valid. diff --git a/.storybook/stories/utils/math/introduce.zh-CN.md b/.storybook/stories/utils/math/introduce.zh-CN.md index 5116436..6713718 100644 --- a/.storybook/stories/utils/math/introduce.zh-CN.md +++ b/.storybook/stories/utils/math/introduce.zh-CN.md @@ -1,15 +1,17 @@ -数学工具函数 `random` 提供密码学强度的随机数,包含两个重载:`random()` 生成 `[0, 1)` 之间的小数,`random(min, max)` 生成包含两端在内的均匀随机整数。它优先使用 `crypto.randomInt`/`getRandomValues`,并通过无偏的拒绝采样算法保证分布公平。 +数学工具函数 `random` 提供密码学强度的随机数,包含两个重载:`random()` 生成 `[0, 1)` 之间的小数,`random(min, max)` 生成包含两端在内的均匀随机整数。它优先使用 `crypto.randomInt`/`getRandomValues`,并通过无偏的拒绝采样算法保证分布公平。其中无参形式 `random()` 是 `Math.random()` 的安全替代:`Math.random()` 加密不安全,且会被静态代码扫描报告为安全告警;改用 `random()` 可消除此类告警。 ## 适用场景 - 需要非可预测的随机小数,用于概率判断或主题切换。 - 在某个范围内生成随机整数,例如随机选取数组下标或抽取号码。 - 希望比内置 `Math.random` 生成器更均匀、更安全的场景。 +- 替换 `Math.random()` 调用,消除静态代码扫描中的安全告警。 ## 核心特性 - **双重载** —— `random()` 生成 `[0, 1)` 小数,`random(min, max)` 生成包含两端的整数。 - **密码学安全** —— 优先使用 Node `crypto.randomInt` / `webcrypto`,并提供无偏的拒绝采样回退。 +- **`Math.random()` 安全替代** —— 无参 `random()` 返回值域同为 `[0, 1)` 的浮点数,可直接替换 `Math.random()`,规避其加密不安全问题与静态扫描告警。 - **参数校验** —— min/max 非有限数或非整数时抛出 `TypeError`。 - **容错排序** —— 当 `min` 大于 `max` 时自动交换,始终保证范围合法。 From cc1574caeb15c59a4e2a135564b21d7458338899 Mon Sep 17 00:00:00 2001 From: Fengbao Li <shijistar@gmail.com> Date: Fri, 7 Aug 2026 10:19:24 +0800 Subject: [PATCH 28/39] feat(utils): split utils into modular implementations, and change FloatDrawer event names --- src/components/FloatDrawer/index.tsx | 18 +- src/components/FloatDrawer/style/index.ts | 8 +- src/utils/base64.ts | 175 ------------- src/utils/base64/arrayBufferToBase64.ts | 26 ++ src/utils/base64/base64ToArrayBuffer.ts | 55 ++++ src/utils/base64/base64ToString.ts | 54 ++++ src/utils/base64/index.ts | 4 + src/utils/base64/stringToBase64.ts | 45 ++++ .../{color.ts => color/getColorLuminance.ts} | 4 +- src/utils/color/index.ts | 1 + src/utils/crypto.ts | 245 ------------------ src/utils/crypto/advancedDecrypt.ts | 13 + src/utils/crypto/advancedEncrypt.ts | 19 ++ src/utils/crypto/decryptAES.ts | 71 +++++ src/utils/crypto/decryptWithCryptoJS.ts | 47 ++++ src/utils/crypto/encryptAES.ts | 64 +++++ src/utils/crypto/encryptWithCryptoJS.ts | 47 ++++ src/utils/crypto/index.ts | 6 + src/utils/math/index.ts | 1 + src/utils/{math.ts => math/random.ts} | 8 +- .../StreamTimeSlicerClass.ts} | 5 +- src/utils/stream/index.ts | 2 + src/utils/string/index.ts | 2 + src/utils/string/randomChars.ts | 16 ++ .../readTextAnyEncoding.ts} | 19 +- 25 files changed, 501 insertions(+), 454 deletions(-) delete mode 100644 src/utils/base64.ts create mode 100644 src/utils/base64/arrayBufferToBase64.ts create mode 100644 src/utils/base64/base64ToArrayBuffer.ts create mode 100644 src/utils/base64/base64ToString.ts create mode 100644 src/utils/base64/index.ts create mode 100644 src/utils/base64/stringToBase64.ts rename src/utils/{color.ts => color/getColorLuminance.ts} (91%) create mode 100644 src/utils/color/index.ts delete mode 100644 src/utils/crypto.ts create mode 100644 src/utils/crypto/advancedDecrypt.ts create mode 100644 src/utils/crypto/advancedEncrypt.ts create mode 100644 src/utils/crypto/decryptAES.ts create mode 100644 src/utils/crypto/decryptWithCryptoJS.ts create mode 100644 src/utils/crypto/encryptAES.ts create mode 100644 src/utils/crypto/encryptWithCryptoJS.ts create mode 100644 src/utils/crypto/index.ts create mode 100644 src/utils/math/index.ts rename src/utils/{math.ts => math/random.ts} (96%) rename src/utils/{stream.ts => stream/StreamTimeSlicerClass.ts} (97%) create mode 100644 src/utils/stream/index.ts create mode 100644 src/utils/string/index.ts create mode 100644 src/utils/string/randomChars.ts rename src/utils/{string.ts => string/readTextAnyEncoding.ts} (78%) diff --git a/src/components/FloatDrawer/index.tsx b/src/components/FloatDrawer/index.tsx index 059a7ba..979c323 100644 --- a/src/components/FloatDrawer/index.tsx +++ b/src/components/FloatDrawer/index.tsx @@ -283,7 +283,7 @@ const FloatDrawer: FC<FloatDrawerProps> = (props) => { }; // Handle resize events - const handleResizeStart = useRefFunction((e: React.MouseEvent) => { + const handleResizeStart = useRefFunction((e: React.PointerEvent) => { setIsDragging(true); if (position === 'top' || position === 'bottom') { dragStartPos.current = e.clientY; @@ -293,7 +293,7 @@ const FloatDrawer: FC<FloatDrawerProps> = (props) => { dragStartSize.current = size; e.preventDefault(); }); - const handleResize = useRefFunction((e: MouseEvent) => { + const handleResize = useRefFunction((e: PointerEvent) => { if (isDragging && dragStartSize.current) { let newSize: number; if (position === 'left') { @@ -335,15 +335,15 @@ const FloatDrawer: FC<FloatDrawerProps> = (props) => { // Handle global events useEffect(() => { if (isDragging) { - window.addEventListener('mousemove', handleResize); - window.addEventListener('mouseup', handleResizeEnd); + window.addEventListener('pointermove', handleResize); + window.addEventListener('pointerup', handleResizeEnd); } else { - window.removeEventListener('mousemove', handleResize); - window.removeEventListener('mouseup', handleResizeEnd); + window.removeEventListener('pointermove', handleResize); + window.removeEventListener('pointerup', handleResizeEnd); } return () => { - window.removeEventListener('mousemove', handleResize); - window.removeEventListener('mouseup', handleResizeEnd); + window.removeEventListener('pointermove', handleResize); + window.removeEventListener('pointerup', handleResizeEnd); }; }, [isDragging]); @@ -370,7 +370,7 @@ const FloatDrawer: FC<FloatDrawerProps> = (props) => { classNamesInProps?.resizeHandle, )} style={styles?.resizeHandle} - onMouseDown={handleResizeStart} + onPointerDown={handleResizeStart} /> )} <div className={classNames(`${prefixCls}-content`, classNamesInProps?.content)} style={styles?.content}> diff --git a/src/components/FloatDrawer/style/index.ts b/src/components/FloatDrawer/style/index.ts index 63f4bba..b242f62 100644 --- a/src/components/FloatDrawer/style/index.ts +++ b/src/components/FloatDrawer/style/index.ts @@ -121,7 +121,7 @@ const genStyle: GenerateStyle<FloatDrawerToken> = (token): CSSObject => { height: 24, }, [`&${componentCls}-left ${componentCls}-toggle`]: { - right: 0, + right: 1, transform: 'translate(100%, -50%)', borderRadius: '0 8px 8px 0', // On the left side: the shadow casts to the right and upward/downward in half size @@ -135,7 +135,7 @@ const genStyle: GenerateStyle<FloatDrawerToken> = (token): CSSObject => { }, }, [`&${componentCls}-right ${componentCls}-toggle`]: { - left: 0, + left: 1, transform: 'translate(-100%, -50%)', borderRadius: '8px 0 0 8px', // On the right side: the shadow casts to the left and upward/downward in half size @@ -149,7 +149,7 @@ const genStyle: GenerateStyle<FloatDrawerToken> = (token): CSSObject => { }, }, [`&${componentCls}-top ${componentCls}-toggle`]: { - bottom: 0, + bottom: 1, transform: 'translate(-50%, 100%)', borderRadius: '0 0 8px 8px', // On the top side: the shadow casts downward and left/right in half size @@ -163,7 +163,7 @@ const genStyle: GenerateStyle<FloatDrawerToken> = (token): CSSObject => { }, }, [`&${componentCls}-bottom ${componentCls}-toggle`]: { - top: 0, + top: 1, transform: 'translate(-50%, -100%)', borderRadius: '8px 8px 0 0', // On the bottom side: the shadow casts upward and left/right in half size diff --git a/src/utils/base64.ts b/src/utils/base64.ts deleted file mode 100644 index eb197ae..0000000 --- a/src/utils/base64.ts +++ /dev/null @@ -1,175 +0,0 @@ -/** - * - **EN:** Encode a UTF-8 string into Base64 (standard or URL-safe). - * - **CN:** 将 UTF-8 字符串编码为 Base64(标准或 URL 安全格式)。 - * - * @param content Input text to encode | 要编码的输入文本 - * - * @returns Base64 encoded string | Base64 编码后的字符串 - */ -export function stringToBase64( - content: string, - opts: { - /** - * - **EN:** Use URL-safe Base64 if true (replace +/ with -_ and strip =) - * - **CN:** 为 true 时使用 URL 安全 Base64(将 +/ 替换为 -_ 并去掉 =) - */ - urlSafe?: boolean; - } = {}, -): string { - const { urlSafe = false } = opts; - if (content == null || content === '') return ''; - - let base64: string; - const hasBuffer = typeof Buffer !== 'undefined' && typeof Buffer.from === 'function'; - if (hasBuffer) { - // Node.js - base64 = Buffer.from(content, 'utf8').toString('base64'); - } else { - // Browser - const encoder = new TextEncoder(); - const bytes = encoder.encode(content); - let binary = ''; - for (const i of bytes) { - binary += String.fromCharCode(i); - } - base64 = btoa(binary); - } - - if (urlSafe) { - // Replace chars and strip padding for URL-safe variant - base64 = base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/u, ''); - } - return base64; -} - -/** - * - EN: Decode a Base64 (standard or URL-safe) string into UTF-8 text. - * - CN: 将(标准或 URL 安全)Base64 字符串解码为 UTF-8 文本。 - * - * @param content Base64 encoded string | Base64 编码字符串 - * - * @returns Decoded UTF-8 string | 解码后的 UTF-8 字符串 - */ -export function base64ToString( - content: string, - opts: { - /** - * - **EN:** Use URL-safe Base64 if true (replace +/ with -_ and strip =) - * - **CN:** 为 true 时使用 URL 安全 Base64(将 +/ 替换为 -_ 并去掉 =) - */ - urlSafe?: boolean; - } = {}, -): string { - const { urlSafe = false } = opts; - if (content == null || content === '') return ''; - - let normalized = content; - if (urlSafe) { - normalized = normalized.replace(/-/g, '+').replace(/_/g, '/'); - } - // Restore padding if stripped - const padNeeded = normalized.length % 4; - if (padNeeded === 2) normalized += '=='; - else if (padNeeded === 3) normalized += '='; - else if (padNeeded === 1) { - throw new Error('Invalid Base64 string length'); - } - - const hasBuffer = typeof Buffer !== 'undefined' && typeof Buffer.from === 'function'; - try { - if (hasBuffer) { - return Buffer.from(normalized, 'base64').toString('utf8'); - } else { - const binary = atob(normalized); - const len = binary.length; - const bytes = new Uint8Array(len); - for (let i = 0; i < len; i++) { - bytes[i] = binary.charCodeAt(i); - } - const decoder = new TextDecoder(); - return decoder.decode(bytes); - } - } catch (e) { - /* v8 ignore next -- rethrow path is covered in tests, but V8 line attribution is inconsistent across Buffer/atob decode failures */ - throw new Error('Failed to decode Base64: ' + (e instanceof Error ? e.message : String(e))); - } -} - -/** - * - **EN:** Convert an ArrayBuffer to a Base64 encoded string. - * - **CN:** 将 ArrayBuffer 转换为 Base64 编码的字符串。 - * - * @param buf The ArrayBuffer to convert | 要转换的 ArrayBuffer - * - * @returns The Base64 encoded string | Base64 编码的字符串 - */ -export function arrayBufferToBase64(buf: ArrayBuffer): string { - if (typeof Buffer !== 'undefined') { - return Buffer.from(buf).toString('base64'); - } - const bytes = new Uint8Array(buf); - const chunk = 0x8000; - let binary = ''; - for (let i = 0; i < bytes.length; i += chunk) { - binary += String.fromCharCode(...bytes.subarray(i, i + chunk)); - } - let base64 = btoa(binary); - // Add padding for Base64 - const padding = (4 - (base64.length % 4)) % 4; - if (padding) base64 += '='.repeat(padding); - return base64; -} - -/** - * - **EN:** Decode a Base64 (standard or URL-safe) string into an ArrayBuffer. - * - **CN:** 将(标准或 URL 安全)Base64 字符串解码为 ArrayBuffer。 - * - * @param base64 The Base64 encoded string | Base64 编码的字符串 - * - * @returns The decoded ArrayBuffer | 解码后的 ArrayBuffer - */ -export function base64ToArrayBuffer( - base64: string, - opts: { - /** - * - EN: Use URL-safe Base64 if true (replace -_ back to +/ and restore padding) - * - CN: 为 true 时按 URL 安全 Base64 进行规范化(将 -_ 还原为 +/ 并补齐 =) - */ - urlSafe?: boolean; - } = {}, -): ArrayBuffer { - const { urlSafe = false } = opts; - if (base64 == null || base64 === '') return new ArrayBuffer(0); - - // Normalize to standard Base64 (align with base64ToString) - let normalized = base64; - if (urlSafe) { - normalized = normalized.replace(/-/g, '+').replace(/_/g, '/'); - } - const padNeeded = normalized.length % 4; - if (padNeeded === 2) normalized += '=='; - else if (padNeeded === 3) normalized += '='; - else if (padNeeded === 1) { - throw new Error('Invalid Base64 string length'); - } - - try { - if (typeof Buffer !== 'undefined' && typeof Buffer.from === 'function') { - // Node.js / environments with Buffer - const buf = Buffer.from(normalized, 'base64'); - return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); - } else { - // Browser - const binary = atob(normalized); - const len = binary.length; - const bytes = new Uint8Array(len); - for (let i = 0; i < len; i++) { - bytes[i] = binary.charCodeAt(i); - } - return bytes.buffer; - } - } catch (e) { - /* v8 ignore next -- rethrow path is covered in tests, but V8 line attribution is inconsistent across Buffer/atob decode failures */ - throw new Error('Failed to decode Base64: ' + (e instanceof Error ? e.message : String(e))); - } -} diff --git a/src/utils/base64/arrayBufferToBase64.ts b/src/utils/base64/arrayBufferToBase64.ts new file mode 100644 index 0000000..1996521 --- /dev/null +++ b/src/utils/base64/arrayBufferToBase64.ts @@ -0,0 +1,26 @@ +/** + * - **EN:** Convert an ArrayBuffer to a Base64 encoded string. + * - **CN:** 将 ArrayBuffer 转换为 Base64 编码的字符串。 + * + * @param buf The ArrayBuffer to convert | 要转换的 ArrayBuffer + * + * @returns The Base64 encoded string | Base64 编码的字符串 + */ +function arrayBufferToBase64(buf: ArrayBuffer): string { + if (typeof Buffer !== 'undefined') { + return Buffer.from(buf).toString('base64'); + } + const bytes = new Uint8Array(buf); + const chunk = 0x8000; + let binary = ''; + for (let i = 0; i < bytes.length; i += chunk) { + binary += String.fromCharCode(...bytes.subarray(i, i + chunk)); + } + let base64 = btoa(binary); + // Add padding for Base64 + const padding = (4 - (base64.length % 4)) % 4; + if (padding) base64 += '='.repeat(padding); + return base64; +} + +export default arrayBufferToBase64; diff --git a/src/utils/base64/base64ToArrayBuffer.ts b/src/utils/base64/base64ToArrayBuffer.ts new file mode 100644 index 0000000..3a91cbd --- /dev/null +++ b/src/utils/base64/base64ToArrayBuffer.ts @@ -0,0 +1,55 @@ +/** + * - **EN:** Decode a Base64 (standard or URL-safe) string into an ArrayBuffer. + * - **CN:** 将(标准或 URL 安全)Base64 字符串解码为 ArrayBuffer。 + * + * @param base64 The Base64 encoded string | Base64 编码的字符串 + * + * @returns The decoded ArrayBuffer | 解码后的 ArrayBuffer + */ +function base64ToArrayBuffer( + base64: string, + opts: { + /** + * - EN: Use URL-safe Base64 if true (replace -_ back to +/ and restore padding) + * - CN: 为 true 时按 URL 安全 Base64 进行规范化(将 -_ 还原为 +/ 并补齐 =) + */ + urlSafe?: boolean; + } = {}, +): ArrayBuffer { + const { urlSafe = false } = opts; + if (base64 == null || base64 === '') return new ArrayBuffer(0); + + // Normalize to standard Base64 (align with base64ToString) + let normalized = base64; + if (urlSafe) { + normalized = normalized.replace(/-/g, '+').replace(/_/g, '/'); + } + const padNeeded = normalized.length % 4; + if (padNeeded === 2) normalized += '=='; + else if (padNeeded === 3) normalized += '='; + else if (padNeeded === 1) { + throw new Error('Invalid Base64 string length'); + } + + try { + if (typeof Buffer !== 'undefined' && typeof Buffer.from === 'function') { + // Node.js / environments with Buffer + const buf = Buffer.from(normalized, 'base64'); + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); + } else { + // Browser + const binary = atob(normalized); + const len = binary.length; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; + } + } catch (e) { + /* v8 ignore next -- rethrow path is covered in tests, but V8 line attribution is inconsistent across Buffer/atob decode failures */ + throw new Error('Failed to decode Base64: ' + (e instanceof Error ? e.message : String(e))); + } +} + +export default base64ToArrayBuffer; diff --git a/src/utils/base64/base64ToString.ts b/src/utils/base64/base64ToString.ts new file mode 100644 index 0000000..41143d1 --- /dev/null +++ b/src/utils/base64/base64ToString.ts @@ -0,0 +1,54 @@ +/** + * - EN: Decode a Base64 (standard or URL-safe) string into UTF-8 text. + * - CN: 将(标准或 URL 安全)Base64 字符串解码为 UTF-8 文本。 + * + * @param content Base64 encoded string | Base64 编码字符串 + * + * @returns Decoded UTF-8 string | 解码后的 UTF-8 字符串 + */ +function base64ToString( + content: string, + opts: { + /** + * - **EN:** Use URL-safe Base64 if true (replace +/ with -_ and strip =) + * - **CN:** 为 true 时使用 URL 安全 Base64(将 +/ 替换为 -_ 并去掉 =) + */ + urlSafe?: boolean; + } = {}, +): string { + const { urlSafe = false } = opts; + if (content == null || content === '') return ''; + + let normalized = content; + if (urlSafe) { + normalized = normalized.replace(/-/g, '+').replace(/_/g, '/'); + } + // Restore padding if stripped + const padNeeded = normalized.length % 4; + if (padNeeded === 2) normalized += '=='; + else if (padNeeded === 3) normalized += '='; + else if (padNeeded === 1) { + throw new Error('Invalid Base64 string length'); + } + + const hasBuffer = typeof Buffer !== 'undefined' && typeof Buffer.from === 'function'; + try { + if (hasBuffer) { + return Buffer.from(normalized, 'base64').toString('utf8'); + } else { + const binary = atob(normalized); + const len = binary.length; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) { + bytes[i] = binary.charCodeAt(i); + } + const decoder = new TextDecoder(); + return decoder.decode(bytes); + } + } catch (e) { + /* v8 ignore next -- rethrow path is covered in tests, but V8 line attribution is inconsistent across Buffer/atob decode failures */ + throw new Error('Failed to decode Base64: ' + (e instanceof Error ? e.message : String(e))); + } +} + +export default base64ToString; diff --git a/src/utils/base64/index.ts b/src/utils/base64/index.ts new file mode 100644 index 0000000..8618c4f --- /dev/null +++ b/src/utils/base64/index.ts @@ -0,0 +1,4 @@ +export { default as arrayBufferToBase64 } from './arrayBufferToBase64'; +export { default as base64ToArrayBuffer } from './base64ToArrayBuffer'; +export { default as base64ToString } from './base64ToString'; +export { default as stringToBase64 } from './stringToBase64'; diff --git a/src/utils/base64/stringToBase64.ts b/src/utils/base64/stringToBase64.ts new file mode 100644 index 0000000..eb2db65 --- /dev/null +++ b/src/utils/base64/stringToBase64.ts @@ -0,0 +1,45 @@ +/** + * - **EN:** Encode a UTF-8 string into Base64 (standard or URL-safe). + * - **CN:** 将 UTF-8 字符串编码为 Base64(标准或 URL 安全格式)。 + * + * @param content Input text to encode | 要编码的输入文本 + * + * @returns Base64 encoded string | Base64 编码后的字符串 + */ +function stringToBase64( + content: string, + opts: { + /** + * - **EN:** Use URL-safe Base64 if true (replace +/ with -_ and strip =) + * - **CN:** 为 true 时使用 URL 安全 Base64(将 +/ 替换为 -_ 并去掉 =) + */ + urlSafe?: boolean; + } = {}, +): string { + const { urlSafe = false } = opts; + if (content == null || content === '') return ''; + + let base64: string; + const hasBuffer = typeof Buffer !== 'undefined' && typeof Buffer.from === 'function'; + if (hasBuffer) { + // Node.js + base64 = Buffer.from(content, 'utf8').toString('base64'); + } else { + // Browser + const encoder = new TextEncoder(); + const bytes = encoder.encode(content); + let binary = ''; + for (const i of bytes) { + binary += String.fromCharCode(i); + } + base64 = btoa(binary); + } + + if (urlSafe) { + // Replace chars and strip padding for URL-safe variant + base64 = base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/u, ''); + } + return base64; +} + +export default stringToBase64; diff --git a/src/utils/color.ts b/src/utils/color/getColorLuminance.ts similarity index 91% rename from src/utils/color.ts rename to src/utils/color/getColorLuminance.ts index 71c919b..8314a8b 100644 --- a/src/utils/color.ts +++ b/src/utils/color/getColorLuminance.ts @@ -5,7 +5,7 @@ * * @returns The luminance of the color */ -export function getColorLuminance(color: string): number { +function getColorLuminance(color: string): number { let r = 0, g = 0, b = 0; @@ -37,3 +37,5 @@ export function getColorLuminance(color: string): number { return luminance; } + +export default getColorLuminance; diff --git a/src/utils/color/index.ts b/src/utils/color/index.ts new file mode 100644 index 0000000..6e117ee --- /dev/null +++ b/src/utils/color/index.ts @@ -0,0 +1 @@ +export { default as getColorLuminance } from './getColorLuminance'; diff --git a/src/utils/crypto.ts b/src/utils/crypto.ts deleted file mode 100644 index 4fac6d9..0000000 --- a/src/utils/crypto.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { base64ToString, stringToBase64 } from './base64'; -import { random } from './math'; -import { randomChars } from './string'; - -/** Secret. No description provided */ -export async function advancedEncrypt(plainText: string, key: string) { - const k1 = randomChars(36); - let e = await encryptAES(plainText, k1); - const b = stringToBase64(k1); - const l = b.length; - const s = random(0, e.length); - e = e.substring(0, s) + b + e.substring(s); - const r = stringToBase64(`${s}-${l}`); - const t = `${e}.${r}`; - return encryptAES(t, key); -} - -/** Secret. No description provided */ -export async function advancedDecrypt(encryptedText: string, key: string) { - const decrypted = await decryptAES(encryptedText, key); - const [e, r] = decrypted.split('.'); - const [s, l] = base64ToString(r).split('-').map(Number); - const k1 = base64ToString(e.substring(s, s + l)); - return decryptAES(e.substring(0, s) + e.substring(s + l), k1); -} - -/** - * **EN**: General AES encryption function - supports both Node.js and browser environments - * - * **CN**: 通用 AES 加密函数 - 同时支持 Node.js 和浏览器环境 - * - * @param {string} text The text to be encrypted | 要加密的文本 - * @param {string} key The encryption key | 加密密钥 - * - * @returns {Promise<string>} The encrypted text | 加密后的文本 - */ -export async function encryptAES(text: string, key: string): Promise<string> { - const isNode = typeof process !== 'undefined' && process.versions != null && process.versions.node != null; - - if (isNode) { - // Node.js - // eslint-disable-next-line @typescript-eslint/no-var-requires - const crypto = require('crypto'); - const derivedKey = crypto.createHash('sha256').update(key).digest(); - const iv = crypto.randomBytes(16); - const cipher = crypto.createCipheriv('aes-256-cbc', derivedKey, iv); - let encrypted = cipher.update(text, 'utf8', 'base64'); - encrypted += cipher.final('base64'); - return `${iv.toString('base64')}:${encrypted}`; - } else { - if (crypto.subtle) { - // Browsers - try { - const iv = crypto.getRandomValues(new Uint8Array(16)); - const encoder = new TextEncoder(); - const keyData = encoder.encode(key); - const hashBuffer = await crypto.subtle.digest('SHA-256', keyData); - const cryptoKey = await crypto.subtle.importKey('raw', hashBuffer, { name: 'AES-CBC' }, false, ['encrypt']); - const textBytes = encoder.encode(text); - const encryptedBuffer = await crypto.subtle.encrypt({ name: 'AES-CBC', iv }, cryptoKey, textBytes); - const ivBase64 = btoa(String.fromCharCode(...iv)); - const encryptedBase64 = btoa(String.fromCharCode(...new Uint8Array(encryptedBuffer))); - return `${ivBase64}:${encryptedBase64}`; - } catch (error) { - console.error('Encryption error:', error); - throw error; - } - } else { - // let encrypt: typeof CryptoJS.AES.encrypt; - // try { - // const aes = await import('crypto-js/aes'); - // encrypt = aes.encrypt; - // } catch (error) { - // console.error('Load "crypto-js/aes" error:', error); - // throw error; - // } - // try { - // return encrypt(text, key).toString(); - // } catch (error) { - // console.error('Encryption error:', error); - // throw error; - // } - return encryptWithCryptoJS(text, key); - } - } -} - -/** - * **EN**: General AES decryption function - supports both Node.js and browser environments - * - * **CN**: 通用 AES 解密函数 - 同时支持 Node.js 和浏览器环境 - * - * @param encryptedText The encrypted text (format: iv:encryptedContent, base64 encoded) - * @param key The decryption key - * - * @returns The decrypted text - */ -export async function decryptAES(encryptedText: string, key: string): Promise<string> { - const isNode = typeof process !== 'undefined' && process.versions != null && process.versions.node != null; - try { - const [ivBase64, encryptedBase64] = encryptedText.split(':'); - if (!ivBase64 || !encryptedBase64) { - throw new Error('Invalid encrypted format'); - } - if (isNode) { - // Node.js - // eslint-disable-next-line @typescript-eslint/no-var-requires - const crypto = require('crypto'); - const iv = Buffer.from(ivBase64, 'base64'); - const encryptedBuffer = Buffer.from(encryptedBase64, 'base64'); - const derivedKey = crypto.createHash('sha256').update(key).digest(); - const decipher = crypto.createDecipheriv('aes-256-cbc', derivedKey, iv); - let decrypted = decipher.update(encryptedBuffer); - decrypted = Buffer.concat([decrypted, decipher.final()]); - return decrypted.toString('utf8'); - } else { - if (crypto.subtle) { - // Browsers with Web Crypto API, in secure contexts (HTTPS) - const iv = Uint8Array.from(atob(ivBase64), (c) => c.charCodeAt(0)); - const encryptedData = Uint8Array.from(atob(encryptedBase64), (c) => c.charCodeAt(0)); - const encoder = new TextEncoder(); - const keyData = encoder.encode(key); - const hashBuffer = await crypto.subtle.digest('SHA-256', keyData); - const cryptoKey = await crypto.subtle.importKey('raw', hashBuffer, { name: 'AES-CBC' }, false, ['decrypt']); - const decryptedBuffer = await crypto.subtle.decrypt({ name: 'AES-CBC', iv }, cryptoKey, encryptedData); - const decoder = new TextDecoder(); - return decoder.decode(decryptedBuffer); - } else { - // Browsers without Web Crypto API, or insecure contexts (HTTP), fallback to CryptoJS - // let decrypt: typeof CryptoJS.AES.decrypt; - // let utf8: typeof CryptoJS.enc.Utf8; - // try { - // const [aes, encUtf8] = await Promise.all([import('crypto-js/aes'), import('crypto-js/enc-utf8')]); - // decrypt = aes.decrypt; - // utf8 = encUtf8.default; - // } catch (error) { - // console.error('Load "crypto-js/aes" error:', error); - // throw error; - // } - // try { - // const decrypted = decrypt(encryptedText, key); - // return decrypted.toString(utf8); - // } catch (error) { - // console.error('Decryption error:', error); - // throw error; - // } - return decryptWithCryptoJS(encryptedText, key); - } - } - } catch (error) { - console.error('Decryption error:', error); - return ''; - } -} -export async function encryptWithCryptoJS(text: string, key: string) { - const [ - { - default: { - mode: { CBC }, - lib: { WordArray }, - }, - }, - { default: Pkcs7 }, - { default: Base64 }, - { default: Utf8 }, - { default: SHA256 }, - { - default: { encrypt }, - }, - /* v8 ignore start -- Vite browser-mode CJS/ESM interop branches (unreachable) */ - ] = await Promise.all([ - import('crypto-js/core.js'), - import('crypto-js/pad-pkcs7.js'), - import('crypto-js/enc-base64.js'), - import('crypto-js/enc-utf8.js'), - import('crypto-js/sha256.js'), - import('crypto-js/aes.js'), - ]); - /* v8 ignore stop */ - - try { - // Convert text and key to WordArray objects - const wordArray = Utf8.parse(text); - const keyArray = SHA256(key); - const iv = WordArray.random(16); - - const encryptedBase64 = Base64.stringify( - encrypt(wordArray, keyArray, { - iv, - mode: CBC, - padding: Pkcs7, - }).ciphertext, - ); - return `${Base64.stringify(iv)}:${encryptedBase64}`; - } catch (error) { - console.error('Encryption error:', error); - throw error; - } -} - -export async function decryptWithCryptoJS(encryptedText: string, key: string) { - const [ivBase64, encryptedBase64] = encryptedText.split(':'); - - const [ - { default: Base64 }, - { default: Utf8 }, - { - default: { - mode: { CBC }, - lib: { CipherParams }, - }, - }, - { default: Pkcs7 }, - { default: SHA256 }, - { - default: { decrypt }, - }, - /* v8 ignore start -- Vite browser-mode CJS/ESM interop branches (unreachable) */ - ] = await Promise.all([ - import('crypto-js/enc-base64.js'), - import('crypto-js/enc-utf8.js'), - import('crypto-js/core.js'), - import('crypto-js/pad-pkcs7.js'), - import('crypto-js/sha256.js'), - import('crypto-js/aes.js'), - ]); - /* v8 ignore stop */ - - try { - // Convert base64 strings to WordArray objects - const iv = Base64.parse(ivBase64); - const cipherText = Base64.parse(encryptedBase64); - // Derive key using SHA-256 (matching native implementation) - const derivedKey = SHA256(key); - const decrypted = decrypt(CipherParams.create({ ciphertext: cipherText }), derivedKey, { - iv, - mode: CBC, - padding: Pkcs7, - }); - return decrypted.toString(Utf8); - } catch (error) { - console.error('Decryption error:', error); - throw error; - } -} diff --git a/src/utils/crypto/advancedDecrypt.ts b/src/utils/crypto/advancedDecrypt.ts new file mode 100644 index 0000000..ab19826 --- /dev/null +++ b/src/utils/crypto/advancedDecrypt.ts @@ -0,0 +1,13 @@ +import { base64ToString } from '../base64'; +import decryptAES from './decryptAES'; + +/** Secret. No description provided */ +async function advancedDecrypt(encryptedText: string, key: string) { + const decrypted = await decryptAES(encryptedText, key); + const [e, r] = decrypted.split('.'); + const [s, l] = base64ToString(r).split('-').map(Number); + const k1 = base64ToString(e.substring(s, s + l)); + return decryptAES(e.substring(0, s) + e.substring(s + l), k1); +} + +export default advancedDecrypt; diff --git a/src/utils/crypto/advancedEncrypt.ts b/src/utils/crypto/advancedEncrypt.ts new file mode 100644 index 0000000..f1632a7 --- /dev/null +++ b/src/utils/crypto/advancedEncrypt.ts @@ -0,0 +1,19 @@ +import { stringToBase64 } from '../base64'; +import { random } from '../math'; +import { randomChars } from '../string'; +import encryptAES from './encryptAES'; + +/** Secret. No description provided */ +async function advancedEncrypt(plainText: string, key: string) { + const k1 = randomChars(36); + let e = await encryptAES(plainText, k1); + const b = stringToBase64(k1); + const l = b.length; + const s = random(0, e.length); + e = e.substring(0, s) + b + e.substring(s); + const r = stringToBase64(`${s}-${l}`); + const t = `${e}.${r}`; + return encryptAES(t, key); +} + +export default advancedEncrypt; diff --git a/src/utils/crypto/decryptAES.ts b/src/utils/crypto/decryptAES.ts new file mode 100644 index 0000000..44d0a5c --- /dev/null +++ b/src/utils/crypto/decryptAES.ts @@ -0,0 +1,71 @@ +import decryptWithCryptoJS from './decryptWithCryptoJS'; + +/** + * **EN**: General AES decryption function - supports both Node.js and browser environments + * + * **CN**: 通用 AES 解密函数 - 同时支持 Node.js 和浏览器环境 + * + * @param encryptedText The encrypted text (format: iv:encryptedContent, base64 encoded) + * @param key The decryption key + * + * @returns The decrypted text + */ +async function decryptAES(encryptedText: string, key: string): Promise<string> { + const isNode = typeof process !== 'undefined' && process.versions != null && process.versions.node != null; + try { + const [ivBase64, encryptedBase64] = encryptedText.split(':'); + if (!ivBase64 || !encryptedBase64) { + throw new Error('Invalid encrypted format'); + } + if (isNode) { + // Node.js + // eslint-disable-next-line @typescript-eslint/no-var-requires + const crypto = require('crypto'); + const iv = Buffer.from(ivBase64, 'base64'); + const encryptedBuffer = Buffer.from(encryptedBase64, 'base64'); + const derivedKey = crypto.createHash('sha256').update(key).digest(); + const decipher = crypto.createDecipheriv('aes-256-cbc', derivedKey, iv); + let decrypted = decipher.update(encryptedBuffer); + decrypted = Buffer.concat([decrypted, decipher.final()]); + return decrypted.toString('utf8'); + } else { + if (crypto.subtle) { + // Browsers with Web Crypto API, in secure contexts (HTTPS) + const iv = Uint8Array.from(atob(ivBase64), (c) => c.charCodeAt(0)); + const encryptedData = Uint8Array.from(atob(encryptedBase64), (c) => c.charCodeAt(0)); + const encoder = new TextEncoder(); + const keyData = encoder.encode(key); + const hashBuffer = await crypto.subtle.digest('SHA-256', keyData); + const cryptoKey = await crypto.subtle.importKey('raw', hashBuffer, { name: 'AES-CBC' }, false, ['decrypt']); + const decryptedBuffer = await crypto.subtle.decrypt({ name: 'AES-CBC', iv }, cryptoKey, encryptedData); + const decoder = new TextDecoder(); + return decoder.decode(decryptedBuffer); + } else { + // Browsers without Web Crypto API, or insecure contexts (HTTP), fallback to CryptoJS + // let decrypt: typeof CryptoJS.AES.decrypt; + // let utf8: typeof CryptoJS.enc.Utf8; + // try { + // const [aes, encUtf8] = await Promise.all([import('crypto-js/aes'), import('crypto-js/enc-utf8')]); + // decrypt = aes.decrypt; + // utf8 = encUtf8.default; + // } catch (error) { + // console.error('Load "crypto-js/aes" error:', error); + // throw error; + // } + // try { + // const decrypted = decrypt(encryptedText, key); + // return decrypted.toString(utf8); + // } catch (error) { + // console.error('Decryption error:', error); + // throw error; + // } + return decryptWithCryptoJS(encryptedText, key); + } + } + } catch (error) { + console.error('Decryption error:', error); + return ''; + } +} + +export default decryptAES; diff --git a/src/utils/crypto/decryptWithCryptoJS.ts b/src/utils/crypto/decryptWithCryptoJS.ts new file mode 100644 index 0000000..d464512 --- /dev/null +++ b/src/utils/crypto/decryptWithCryptoJS.ts @@ -0,0 +1,47 @@ +async function decryptWithCryptoJS(encryptedText: string, key: string) { + const [ivBase64, encryptedBase64] = encryptedText.split(':'); + + const [ + { default: Base64 }, + { default: Utf8 }, + { + default: { + mode: { CBC }, + lib: { CipherParams }, + }, + }, + { default: Pkcs7 }, + { default: SHA256 }, + { + default: { decrypt }, + }, + /* v8 ignore start -- Vite browser-mode CJS/ESM interop branches (unreachable) */ + ] = await Promise.all([ + import('crypto-js/enc-base64.js'), + import('crypto-js/enc-utf8.js'), + import('crypto-js/core.js'), + import('crypto-js/pad-pkcs7.js'), + import('crypto-js/sha256.js'), + import('crypto-js/aes.js'), + ]); + /* v8 ignore stop */ + + try { + // Convert base64 strings to WordArray objects + const iv = Base64.parse(ivBase64); + const cipherText = Base64.parse(encryptedBase64); + // Derive key using SHA-256 (matching native implementation) + const derivedKey = SHA256(key); + const decrypted = decrypt(CipherParams.create({ ciphertext: cipherText }), derivedKey, { + iv, + mode: CBC, + padding: Pkcs7, + }); + return decrypted.toString(Utf8); + } catch (error) { + console.error('Decryption error:', error); + throw error; + } +} + +export default decryptWithCryptoJS; diff --git a/src/utils/crypto/encryptAES.ts b/src/utils/crypto/encryptAES.ts new file mode 100644 index 0000000..05bfb7d --- /dev/null +++ b/src/utils/crypto/encryptAES.ts @@ -0,0 +1,64 @@ +import encryptWithCryptoJS from './encryptWithCryptoJS'; + +/** + * **EN**: General AES encryption function - supports both Node.js and browser environments + * + * **CN**: 通用 AES 加密函数 - 同时支持 Node.js 和浏览器环境 + * + * @param {string} text The text to be encrypted | 要加密的文本 + * @param {string} key The encryption key | 加密密钥 + * + * @returns {Promise<string>} The encrypted text | 加密后的文本 + */ +async function encryptAES(text: string, key: string): Promise<string> { + const isNode = typeof process !== 'undefined' && process.versions != null && process.versions.node != null; + + if (isNode) { + // Node.js + // eslint-disable-next-line @typescript-eslint/no-var-requires + const crypto = require('crypto'); + const derivedKey = crypto.createHash('sha256').update(key).digest(); + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv('aes-256-cbc', derivedKey, iv); + let encrypted = cipher.update(text, 'utf8', 'base64'); + encrypted += cipher.final('base64'); + return `${iv.toString('base64')}:${encrypted}`; + } else { + if (crypto.subtle) { + // Browsers + try { + const iv = crypto.getRandomValues(new Uint8Array(16)); + const encoder = new TextEncoder(); + const keyData = encoder.encode(key); + const hashBuffer = await crypto.subtle.digest('SHA-256', keyData); + const cryptoKey = await crypto.subtle.importKey('raw', hashBuffer, { name: 'AES-CBC' }, false, ['encrypt']); + const textBytes = encoder.encode(text); + const encryptedBuffer = await crypto.subtle.encrypt({ name: 'AES-CBC', iv }, cryptoKey, textBytes); + const ivBase64 = btoa(String.fromCharCode(...iv)); + const encryptedBase64 = btoa(String.fromCharCode(...new Uint8Array(encryptedBuffer))); + return `${ivBase64}:${encryptedBase64}`; + } catch (error) { + console.error('Encryption error:', error); + throw error; + } + } else { + // let encrypt: typeof CryptoJS.AES.encrypt; + // try { + // const aes = await import('crypto-js/aes'); + // encrypt = aes.encrypt; + // } catch (error) { + // console.error('Load "crypto-js/aes" error:', error); + // throw error; + // } + // try { + // return encrypt(text, key).toString(); + // } catch (error) { + // console.error('Encryption error:', error); + // throw error; + // } + return encryptWithCryptoJS(text, key); + } + } +} + +export default encryptAES; diff --git a/src/utils/crypto/encryptWithCryptoJS.ts b/src/utils/crypto/encryptWithCryptoJS.ts new file mode 100644 index 0000000..ff827ce --- /dev/null +++ b/src/utils/crypto/encryptWithCryptoJS.ts @@ -0,0 +1,47 @@ +async function encryptWithCryptoJS(text: string, key: string) { + const [ + { + default: { + mode: { CBC }, + lib: { WordArray }, + }, + }, + { default: Pkcs7 }, + { default: Base64 }, + { default: Utf8 }, + { default: SHA256 }, + { + default: { encrypt }, + }, + /* v8 ignore start -- Vite browser-mode CJS/ESM interop branches (unreachable) */ + ] = await Promise.all([ + import('crypto-js/core.js'), + import('crypto-js/pad-pkcs7.js'), + import('crypto-js/enc-base64.js'), + import('crypto-js/enc-utf8.js'), + import('crypto-js/sha256.js'), + import('crypto-js/aes.js'), + ]); + /* v8 ignore stop */ + + try { + // Convert text and key to WordArray objects + const wordArray = Utf8.parse(text); + const keyArray = SHA256(key); + const iv = WordArray.random(16); + + const encryptedBase64 = Base64.stringify( + encrypt(wordArray, keyArray, { + iv, + mode: CBC, + padding: Pkcs7, + }).ciphertext, + ); + return `${Base64.stringify(iv)}:${encryptedBase64}`; + } catch (error) { + console.error('Encryption error:', error); + throw error; + } +} + +export default encryptWithCryptoJS; diff --git a/src/utils/crypto/index.ts b/src/utils/crypto/index.ts new file mode 100644 index 0000000..b170647 --- /dev/null +++ b/src/utils/crypto/index.ts @@ -0,0 +1,6 @@ +export { default as advancedEncrypt } from './advancedEncrypt'; +export { default as advancedDecrypt } from './advancedDecrypt'; +export { default as encryptAES } from './encryptAES'; +export { default as decryptAES } from './decryptAES'; +export { default as encryptWithCryptoJS } from './encryptWithCryptoJS'; +export { default as decryptWithCryptoJS } from './decryptWithCryptoJS'; diff --git a/src/utils/math/index.ts b/src/utils/math/index.ts new file mode 100644 index 0000000..b51daf3 --- /dev/null +++ b/src/utils/math/index.ts @@ -0,0 +1 @@ +export { default as random } from './random'; diff --git a/src/utils/math.ts b/src/utils/math/random.ts similarity index 96% rename from src/utils/math.ts rename to src/utils/math/random.ts index 2b95685..7e613de 100644 --- a/src/utils/math.ts +++ b/src/utils/math/random.ts @@ -5,7 +5,7 @@ * * @returns The generated random number | 生成的随机数 */ -export function random(): number; +function random(): number; /** * **EN**: Generate a random integer within a specified range (inclusive on both ends) * @@ -16,8 +16,8 @@ export function random(): number; * * @returns The generated random integer | 生成的随机整数 */ -export function random(min: number, max: number): number; -export function random(min?: number, max?: number): number { +function random(min: number, max: number): number; +function random(min?: number, max?: number): number { if (min == null && max == null) { return randomLikeMath(); } @@ -130,3 +130,5 @@ function randomLikeMath(): number { return x / 9007199254740992; // 2^53 => [0,1) } + +export default random; diff --git a/src/utils/stream.ts b/src/utils/stream/StreamTimeSlicerClass.ts similarity index 97% rename from src/utils/stream.ts rename to src/utils/stream/StreamTimeSlicerClass.ts index db07fb0..cca51a4 100644 --- a/src/utils/stream.ts +++ b/src/utils/stream/StreamTimeSlicerClass.ts @@ -2,7 +2,7 @@ * - **EN:** A stream time slicer for splitting audio streams into fixed time segments * - **CN:** 一个数据流时间切片器,用于将音频流分成固定时间段 */ -export class StreamTimeSlicerClass implements StreamTimeSlicer { +class StreamTimeSlicerClass implements StreamTimeSlicer { public sliceMode: StreamTimeSlicerOptions['sliceMode']; public value: StreamTimeSlicerOptions['value']; private readonly onSlice: (channels: Float32Array[], sliceDurationMs: number) => void; @@ -90,6 +90,7 @@ export class StreamTimeSlicerClass implements StreamTimeSlicer { return this.startTs == null ? 0 : this.now() - this.startTs; } } + export interface StreamTimeSlicerOptions { /** * - **EN:** Mode of slicing @@ -149,3 +150,5 @@ export interface StreamTimeSlicer */ duration: () => number; } + +export default StreamTimeSlicerClass; diff --git a/src/utils/stream/index.ts b/src/utils/stream/index.ts new file mode 100644 index 0000000..98a2770 --- /dev/null +++ b/src/utils/stream/index.ts @@ -0,0 +1,2 @@ +export { default as StreamTimeSlicerClass } from './StreamTimeSlicerClass'; +export type { StreamTimeSlicerOptions, StreamTimeSlicer } from './StreamTimeSlicerClass'; diff --git a/src/utils/string/index.ts b/src/utils/string/index.ts new file mode 100644 index 0000000..fddac49 --- /dev/null +++ b/src/utils/string/index.ts @@ -0,0 +1,2 @@ +export { default as randomChars } from './randomChars'; +export { default as readTextAnyEncoding } from './readTextAnyEncoding'; diff --git a/src/utils/string/randomChars.ts b/src/utils/string/randomChars.ts new file mode 100644 index 0000000..6221809 --- /dev/null +++ b/src/utils/string/randomChars.ts @@ -0,0 +1,16 @@ +/** + * - EN: Generate a random string of alphanumeric characters. + * - CN: 生成一个随机的字母数字字符串。 + * + * @param length Length of the random string | 随机字符串的长度 + */ +function randomChars(length: number): string { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let result = ''; + for (let i = 0; i < length; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; +} + +export default randomChars; diff --git a/src/utils/string.ts b/src/utils/string/readTextAnyEncoding.ts similarity index 78% rename from src/utils/string.ts rename to src/utils/string/readTextAnyEncoding.ts index 9ac5bea..cb07f12 100644 --- a/src/utils/string.ts +++ b/src/utils/string/readTextAnyEncoding.ts @@ -1,25 +1,10 @@ -/** - * - EN: Generate a random string of alphanumeric characters. - * - CN: 生成一个随机的字母数字字符串。 - * - * @param length Length of the random string | 随机字符串的长度 - */ -export function randomChars(length: number): string { - const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; - let result = ''; - for (let i = 0; i < length; i++) { - result += chars.charAt(Math.floor(Math.random() * chars.length)); - } - return result; -} - /** * - EN: Read text from a Blob or Uint8Array with automatic encoding detection. * - CN: 从 Blob 或 Uint8Array 中读取文本,并自动检测编码。 * * @param blob Blob or Uint8Array | Blob 或 Uint8Array */ -export async function readTextAnyEncoding(blob: Blob | ArrayBuffer | Uint8Array | undefined): Promise<string> { +async function readTextAnyEncoding(blob: Blob | ArrayBuffer | Uint8Array | undefined): Promise<string> { if (!blob) return ''; let bytes: Uint8Array | undefined; if (blob instanceof Blob) { @@ -104,3 +89,5 @@ function detectTextEncoding(bytes: Uint8Array): string { return 'gb18030'; } + +export default readTextAnyEncoding; From 3a63ed97d1c65e904bb61817c247595aeb7e3c5e Mon Sep 17 00:00:00 2001 From: Fengbao Li <shijistar@gmail.com> Date: Fri, 7 Aug 2026 10:46:52 +0800 Subject: [PATCH 29/39] docs: update changelog for version 2.2.0 with new features, bug fixes, and improvements --- CHANGELOG.md | 28 ++++++++++++++++++++++++++++ package.json | 2 +- src/hooks/useMovable.ts | 12 ------------ 3 files changed, 29 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 415cdfd..8e47cb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,34 @@ # Changelog +## 2.2.0 + +2026-8-7 + +### Features + +#### Components + +- ✨ `ConfigProvider`: added the `easy-full-height-table-v6` global CSS class for full-height antd `v6` tables. + +#### Hooks + +- ✨ Enhanced `useMovable` with viewport support: added `viewPortRef` to constrain dragging within a parent container. Added an `onMove` callback fired on every position change. `containerRef` is deprecated in favor of `movableDomRef`. +- ✨ `useSplitter`: switched splitter interaction from mouse events to pointer events with pointer capture. + +#### Validators + +- ✨ `useValidators`: the `name` / `strongName` validators now allow spaces. + +### Bug Fixes + +- 🐞 `FloatDrawer`: switched resize handling from mouse events to pointer events for better touch/pen support; fixed the toggle button positioning offset (`0` → `1`). + +### Misc + +- 🛠️ Refactored `utils` into modular implementations: `base64`, `crypto`, `math`, `color`, `stream`, and `string` are now split into per-function modules. The public API is unchanged (re-exported via `index.ts`). +- 🛠️ Improved JSDoc across components/hooks with `@default` annotations and consistent bilingual formatting. + ## 2.1.0 2026-8-3 diff --git a/package.json b/package.json index 0370791..b000618 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tiny-codes/react-easy", - "version": "2.1.0", + "version": "2.2.0", "description": "Simplify React and AntDesign development with practical components and hooks", "keywords": [ "react", diff --git a/src/hooks/useMovable.ts b/src/hooks/useMovable.ts index 826d61e..d5657f9 100644 --- a/src/hooks/useMovable.ts +++ b/src/hooks/useMovable.ts @@ -100,11 +100,9 @@ const useMovable = (props: UseMovableProps) => { // Set the selector for elements that do not trigger dragging if (ignoreSelectors && target.closest(ignoreSelectors.join(','))) return; - const viewPortSize = getViewPortSize(); const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); dragOffsetRef.current = { x: e.clientX - rect.left, y: e.clientY - rect.top }; draggingRef.current = true; - console.log(e.clientX, e.clientY, viewPortSize, rect, dragOffsetRef.current, e.currentTarget); try { (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId); } catch { @@ -167,16 +165,6 @@ const useMovable = (props: UseMovableProps) => { const clampedLeft = Math.min(Math.max(0, newLeft), boundLeft); const clampedTop = Math.min(Math.max(0, newTop), boundTop); const pos = { left: clampedLeft, top: clampedTop }; - console.log( - { clientX: e.clientX, clientY: e.clientY }, - viewPortSize, - { elWidth, elHeight }, - { boundLeft, boundTop }, - dragOffsetRef.current, - { newLeft, newTop }, - pos, - e.target, - ); setPosition(pos); if (movableDomRef.current) { From 89826014a12e00f82a84c4d2ffb525e19a9d2f0e Mon Sep 17 00:00:00 2001 From: Fengbao Li <shijistar@gmail.com> Date: Fri, 7 Aug 2026 11:00:08 +0800 Subject: [PATCH 30/39] test(FloatDrawer): replace mouse events with pointer events in dragResize tests --- test/components/FloatDrawer.test.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/test/components/FloatDrawer.test.tsx b/test/components/FloatDrawer.test.tsx index 820ef90..9070d58 100644 --- a/test/components/FloatDrawer.test.tsx +++ b/test/components/FloatDrawer.test.tsx @@ -64,15 +64,15 @@ function renderDrawer(props: Parameters<typeof FloatDrawer>[0]) { // then mouseup. The drag listeners are attached in an effect after // setIsDragging(true), so we wait for that before dispatching moves. async function dragResize(handle: Element, from: { x: number; y: number }, to: { x: number; y: number }) { - fireEvent.mouseDown(handle, { clientX: from.x, clientY: from.y }); + fireEvent.pointerDown(handle, { clientX: from.x, clientY: from.y }); await waitFor(() => { expect(document.body.className).toBe(document.body.className); // effect flush point }); await act(async () => { - fireEvent.mouseMove(window, { clientX: to.x, clientY: to.y }); + fireEvent.pointerMove(window, { clientX: to.x, clientY: to.y }); }); await act(async () => { - fireEvent.mouseUp(window); + fireEvent.pointerUp(window); }); } @@ -322,12 +322,12 @@ describe('FloatDrawer', () => { it('marks handle as dragging while resizing', async () => { const { container } = renderDrawer({ defaultSize: 300 }); const handle = container.querySelector('.easy-float-drawer-resize-handle')!; - fireEvent.mouseDown(handle, { clientX: 100, clientY: 10 }); + fireEvent.pointerDown(handle, { clientX: 100, clientY: 10 }); await waitFor(() => { expect(handle.classList.contains('easy-float-drawer-resize-handle-dragging')).toBe(true); }); await act(async () => { - fireEvent.mouseUp(window); + fireEvent.pointerUp(window); }); expect(handle.classList.contains('easy-float-drawer-resize-handle-dragging')).toBe(false); }); @@ -339,18 +339,18 @@ describe('FloatDrawer', () => { // guard takes the false branch and no size update happens. const { container } = renderDrawer({}); const handle = container.querySelector('.easy-float-drawer-resize-handle')!; - fireEvent.mouseDown(handle, { clientX: 100, clientY: 10 }); + fireEvent.pointerDown(handle, { clientX: 100, clientY: 10 }); await waitFor(() => { expect(handle.classList.contains('easy-float-drawer-resize-handle-dragging')).toBe(true); }); await act(async () => { - fireEvent.mouseMove(window, { clientX: 150, clientY: 10 }); + fireEvent.pointerMove(window, { clientX: 150, clientY: 10 }); }); // Guard false branch: size stays 0 (jsdom measurement), width remains 0px. const drawer = container.querySelector('.easy-float-drawer-drawer') as HTMLElement; expect(drawer.style.width).toBe('0px'); await act(async () => { - fireEvent.mouseUp(window); + fireEvent.pointerUp(window); }); }); From 184dd8304c539bfbde3b7c78d633856dc534fa3d Mon Sep 17 00:00:00 2001 From: Fengbao Li <shijistar@gmail.com> Date: Fri, 7 Aug 2026 11:37:48 +0800 Subject: [PATCH 31/39] fix(storybook): migrate antd v5 deprecated props to v6 APIs - Replace Modal maskClosable with mask.closable in ConfirmAction/DeleteConfirmAction/ModalAction stories - Replace Modal destroyOnClose with destroyOnHidden in ModalAction story - Replace Modal autoFocusButton arg/control with focusable.autoFocusButton in ConfirmAction/DeleteConfirmAction stories --- .../stories/components/ConfirmAction/index.stories.tsx | 10 ++++------ .../components/DeleteConfirmAction/index.stories.tsx | 10 ++++------ .../stories/components/ModalAction/index.stories.tsx | 5 ++--- 3 files changed, 10 insertions(+), 15 deletions(-) diff --git a/.storybook/stories/components/ConfirmAction/index.stories.tsx b/.storybook/stories/components/ConfirmAction/index.stories.tsx index bb1be3f..94e53cd 100644 --- a/.storybook/stories/components/ConfirmAction/index.stories.tsx +++ b/.storybook/stories/components/ConfirmAction/index.stories.tsx @@ -49,10 +49,9 @@ const meta: Meta<ConfirmActionStoryArgs> = { danger: false, width: 416, closable: true, - mask: true, - maskClosable: false, + mask: { closable: false }, keyboard: false, - autoFocusButton: undefined, + focusable: { autoFocusButton: undefined }, onOk: fn(), onCancel: fn(), afterOpenChange: fn(), @@ -75,9 +74,8 @@ const meta: Meta<ConfirmActionStoryArgs> = { triggerProps: { description: storyT('storybook.stories.ConfirmAction.argTypes.triggerProps.description'), }, - autoFocusButton: { - control: 'select', - options: ['ok', 'cancel'], + focusable: { + control: 'object', description: storyT('storybook.stories.ConfirmAction.argTypes.autoFocusButton.description'), table: { defaultValue: { summary: 'undefined' } }, }, diff --git a/.storybook/stories/components/DeleteConfirmAction/index.stories.tsx b/.storybook/stories/components/DeleteConfirmAction/index.stories.tsx index 76e095c..8bd9bab 100644 --- a/.storybook/stories/components/DeleteConfirmAction/index.stories.tsx +++ b/.storybook/stories/components/DeleteConfirmAction/index.stories.tsx @@ -48,10 +48,9 @@ const meta: Meta<DeleteConfirmActionStoryArgs> = { danger: false, width: 416, closable: true, - mask: true, - maskClosable: false, + mask: { closable: false }, keyboard: false, - autoFocusButton: undefined, + focusable: { autoFocusButton: undefined }, onOk: fn(), onCancel: fn(), afterOpenChange: fn(), @@ -65,9 +64,8 @@ const meta: Meta<DeleteConfirmActionStoryArgs> = { description: storyT('storybook.stories.DeleteConfirmAction.argTypes.triggerType.description'), table: { defaultValue: { summary: '"Button"' } }, }, - autoFocusButton: { - control: 'select', - options: ['ok', 'cancel'], + focusable: { + control: 'object', description: storyT('storybook.stories.DeleteConfirmAction.argTypes.autoFocusButton.description'), table: { defaultValue: { summary: 'undefined' } }, }, diff --git a/.storybook/stories/components/ModalAction/index.stories.tsx b/.storybook/stories/components/ModalAction/index.stories.tsx index 7a44fc6..71b2c9c 100644 --- a/.storybook/stories/components/ModalAction/index.stories.tsx +++ b/.storybook/stories/components/ModalAction/index.stories.tsx @@ -76,9 +76,8 @@ const meta: Meta<ModalActionStoryArgs> = { }, width: 520, closable: true, - mask: true, - maskClosable: false, - destroyOnClose: true, + mask: { closable: false }, + destroyOnHidden: true, keyboard: false, onOk: fn(), onCancel: fn(), From d916cb4cd4207496a1236463e1cf1760ce1d4e4a Mon Sep 17 00:00:00 2001 From: Fengbao Li <shijistar@gmail.com> Date: Fri, 7 Aug 2026 12:37:50 +0800 Subject: [PATCH 32/39] fix(ModalAction): improve destroyOnClose logic --- src/components/ConfirmAction/index.tsx | 2 +- src/components/ModalAction/index.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/ConfirmAction/index.tsx b/src/components/ConfirmAction/index.tsx index 8628d00..7d5204e 100644 --- a/src/components/ConfirmAction/index.tsx +++ b/src/components/ConfirmAction/index.tsx @@ -319,7 +319,7 @@ export const genRenderer = ( } }, ...restProps, - }); + } satisfies ModalFuncProps); setConfirmApi(api); return api; }); diff --git a/src/components/ModalAction/index.tsx b/src/components/ModalAction/index.tsx index 24b53c6..2d28bb0 100644 --- a/src/components/ModalAction/index.tsx +++ b/src/components/ModalAction/index.tsx @@ -242,7 +242,7 @@ export const genModalActionRenderer = (defaultProps: Partial<ModalActionProps<an const [form, setForm] = useState<FormInstance<FormData>>(); const formRef = useRefValue(form); const onBeforeOpenRef = useRefValue(onBeforeOpen); - const destroyOnCloseRef = useRefValue(destroyOnClose || destroyOnHidden); + const destroyOnCloseRef = useRefValue(destroyOnHidden ?? destroyOnClose); const openListenerRef = useRef<ModalProps['afterOpenChange']>(undefined); const beforeOpenResultRef = useRef<unknown>(undefined); From ca93b0babc10fa699ae4e5ce8bb9c7cdf4eeb2a5 Mon Sep 17 00:00:00 2001 From: Fengbao Li <shijistar@gmail.com> Date: Fri, 7 Aug 2026 12:59:43 +0800 Subject: [PATCH 33/39] fix(ModalAction): ensure default values for destroyOnClose and destroyOnHidden props --- src/components/ModalAction/index.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/ModalAction/index.tsx b/src/components/ModalAction/index.tsx index 2d28bb0..0a27224 100644 --- a/src/components/ModalAction/index.tsx +++ b/src/components/ModalAction/index.tsx @@ -216,8 +216,8 @@ export const genModalActionRenderer = (defaultProps: Partial<ModalActionProps<an triggerEvent = 'onClick' as Event, triggerProps, open: openInProps, - destroyOnClose = true, - destroyOnHidden = true, + destroyOnClose, + destroyOnHidden, maskClosable = false, mask, okButtonProps, @@ -339,8 +339,8 @@ export const genModalActionRenderer = (defaultProps: Partial<ModalActionProps<an <Modal open={open} confirmLoading={isSaving} - destroyOnClose={destroyOnClose} - destroyOnHidden={destroyOnHidden} + destroyOnClose={destroyOnClose ?? true} + destroyOnHidden={destroyOnHidden ?? true} maskClosable={maskClosable} mask={ typeof mask === 'boolean' From 6d2a2e3c8dfd116740ad7f556704b31d61d76fdc Mon Sep 17 00:00:00 2001 From: Fengbao Li <shijistar@gmail.com> Date: Fri, 7 Aug 2026 13:32:11 +0800 Subject: [PATCH 34/39] fix(ModalAction): set default value for destroyOnCloseRef to true --- src/components/ModalAction/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ModalAction/index.tsx b/src/components/ModalAction/index.tsx index 0a27224..94fa2cd 100644 --- a/src/components/ModalAction/index.tsx +++ b/src/components/ModalAction/index.tsx @@ -242,7 +242,7 @@ export const genModalActionRenderer = (defaultProps: Partial<ModalActionProps<an const [form, setForm] = useState<FormInstance<FormData>>(); const formRef = useRefValue(form); const onBeforeOpenRef = useRefValue(onBeforeOpen); - const destroyOnCloseRef = useRefValue(destroyOnHidden ?? destroyOnClose); + const destroyOnCloseRef = useRefValue(destroyOnHidden ?? destroyOnClose ?? true); const openListenerRef = useRef<ModalProps['afterOpenChange']>(undefined); const beforeOpenResultRef = useRef<unknown>(undefined); From be7ed28631efadb77b5ce2517b84786c776767f6 Mon Sep 17 00:00:00 2001 From: Fengbao Li <shijistar@gmail.com> Date: Fri, 7 Aug 2026 13:34:42 +0800 Subject: [PATCH 35/39] fix(randomChars): use custom random function for character selection --- src/utils/string/randomChars.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/utils/string/randomChars.ts b/src/utils/string/randomChars.ts index 6221809..d70d37e 100644 --- a/src/utils/string/randomChars.ts +++ b/src/utils/string/randomChars.ts @@ -1,3 +1,5 @@ +import { random } from '../math'; + /** * - EN: Generate a random string of alphanumeric characters. * - CN: 生成一个随机的字母数字字符串。 @@ -8,7 +10,7 @@ function randomChars(length: number): string { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; let result = ''; for (let i = 0; i < length; i++) { - result += chars.charAt(Math.floor(Math.random() * chars.length)); + result += chars.charAt(Math.floor(random() * chars.length)); } return result; } From 074a19c2d113481e40063b1b592951e36186297e Mon Sep 17 00:00:00 2001 From: Fengbao Li <shijistar@gmail.com> Date: Fri, 7 Aug 2026 13:49:47 +0800 Subject: [PATCH 36/39] docs(changelog): update changelog --- CHANGELOG.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e47cb0..f583192 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,10 +16,11 @@ - ✨ Enhanced `useMovable` with viewport support: added `viewPortRef` to constrain dragging within a parent container. Added an `onMove` callback fired on every position change. `containerRef` is deprecated in favor of `movableDomRef`. - ✨ `useSplitter`: switched splitter interaction from mouse events to pointer events with pointer capture. +- ✨ `useValidators`: the `name` / `strongName` validators now allow spaces. -#### Validators +#### Utils -- ✨ `useValidators`: the `name` / `strongName` validators now allow spaces. +- ✨ `randomChars`: uses the custom `random` utils from `utils/math` instead of `Math.random`. ### Bug Fixes From e8e1619767892c0bc3acccdfd1485500dff5c28c Mon Sep 17 00:00:00 2001 From: Fengbao Li <shijistar@gmail.com> Date: Fri, 7 Aug 2026 13:56:09 +0800 Subject: [PATCH 37/39] fix(useSplitter): replace mouse events with pointer events for better handling --- src/hooks/useSplitter.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/hooks/useSplitter.tsx b/src/hooks/useSplitter.tsx index ef64d37..209b32d 100644 --- a/src/hooks/useSplitter.tsx +++ b/src/hooks/useSplitter.tsx @@ -128,7 +128,7 @@ const useSplitter = (props: UseSplitterProps) => { useEffect(() => { if (!dragging) return; - const onMove = (e: MouseEvent) => { + const onMove = (e: PointerEvent) => { const el = container; if (!el) return; const rect = el.getBoundingClientRect(); @@ -149,12 +149,12 @@ const useSplitter = (props: UseSplitterProps) => { e.preventDefault(); }; const onUp = () => setDragging(false); - window.addEventListener('mousemove', onMove); - window.addEventListener('mouseup', onUp, { once: true }); + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp, { once: true }); return () => { - window.removeEventListener('mousemove', onMove); - window.removeEventListener('mouseup', onUp); + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); }; }, [container, dragging, direction, minRatioRef, maxRatioRef]); From 8893b24a3889da984aa7f4fa0e5f49a840fe19ce Mon Sep 17 00:00:00 2001 From: Fengbao Li <shijistar@gmail.com> Date: Fri, 7 Aug 2026 14:16:04 +0800 Subject: [PATCH 38/39] test: align useSplitter/string tests with pointer events and custom random - useSplitter.test: replace mouseMove/mouseUp with pointerMove/pointerUp to match the pointer-event listeners introduced in e8e1619 - string.test: mock the math random() module instead of Math.random since randomChars now uses the secure custom random() (be7ed28) --- test/hooks/useSplitter.test.tsx | 12 ++++++------ test/utils/string.test.ts | 9 +++++++-- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/test/hooks/useSplitter.test.tsx b/test/hooks/useSplitter.test.tsx index dcb68fb..427636a 100644 --- a/test/hooks/useSplitter.test.tsx +++ b/test/hooks/useSplitter.test.tsx @@ -97,7 +97,7 @@ describe('useSplitter', () => { expect(separator.style.getPropertyValue('--splitter-width')).toBe('4px'); expect(screen.getByTestId('direction').textContent).toBe('vertical'); - fireEvent.mouseMove(window, { clientX: 10, clientY: 10 }); + fireEvent.pointerMove(window, { clientX: 10, clientY: 10 }); expect(screen.getByTestId('percent').textContent).toBe('0.25'); fireEvent.pointerEnter(separator); @@ -115,7 +115,7 @@ describe('useSplitter', () => { expect(separator.className).toContain('easy-splitter-dragging'); expect(separator.className).toContain('dragging-extra'); - fireEvent.mouseMove(window, { clientX: 150, clientY: 10 }); + fireEvent.pointerMove(window, { clientX: 150, clientY: 10 }); await waitFor(() => { expect(screen.getByTestId('percent').textContent).toBe('0.6'); @@ -123,7 +123,7 @@ describe('useSplitter', () => { }); expect(onChange).toHaveBeenLastCalledWith(0.6); - fireEvent.mouseUp(window); + fireEvent.pointerUp(window); await waitFor(() => { expect(screen.getByTestId('dragging').textContent).toBe('false'); }); @@ -153,7 +153,7 @@ describe('useSplitter', () => { expect(separator.getAttribute('aria-orientation')).toBe('horizontal'); fireEvent.pointerDown(separator, { pointerId: 2 }); - fireEvent.mouseMove(window, { clientX: 0, clientY: 400 }); + fireEvent.pointerMove(window, { clientX: 0, clientY: 400 }); await waitFor(() => { expect(screen.getByTestId('percent').textContent).toBe('0.7'); @@ -220,7 +220,7 @@ describe('useSplitter', () => { }); act(() => { - fireEvent.mouseMove(window, { clientX: 50, clientY: 50 }); + fireEvent.pointerMove(window, { clientX: 50, clientY: 50 }); }); await waitFor(() => { @@ -229,7 +229,7 @@ describe('useSplitter', () => { }); act(() => { - fireEvent.mouseUp(window); + fireEvent.pointerUp(window); }); const zeroWidthView = renderHook(() => useSplitter({ splitterWidth: 0 }), { diff --git a/test/utils/string.test.ts b/test/utils/string.test.ts index 6182bd0..87fa599 100644 --- a/test/utils/string.test.ts +++ b/test/utils/string.test.ts @@ -1,10 +1,15 @@ import { describe, expect, it, vi } from 'vitest'; +import { random } from '../../src/utils/math'; import { randomChars, readTextAnyEncoding } from '../../src/utils/string'; +vi.mock('../../src/utils/math', async (importOriginal) => { + const actual = await importOriginal<typeof import('../../src/utils/math')>(); + return { ...actual, random: vi.fn() }; +}); + describe('string utils', () => { it('generates deterministic random strings and handles zero length', () => { - const randomSpy = vi.spyOn(Math, 'random'); - randomSpy.mockReturnValueOnce(0).mockReturnValueOnce(0.999999); + vi.mocked(random).mockReturnValueOnce(0).mockReturnValueOnce(0.999999); expect(randomChars(2)).toBe('A9'); expect(randomChars(0)).toBe(''); From 0972011cf0807f8690e92f1592334dacadb6786f Mon Sep 17 00:00:00 2001 From: Fengbao Li <shijistar@gmail.com> Date: Fri, 7 Aug 2026 15:22:02 +0800 Subject: [PATCH 39/39] test(utils/string): import math utils types for mock module --- test/utils/string.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/utils/string.test.ts b/test/utils/string.test.ts index 87fa599..806c76c 100644 --- a/test/utils/string.test.ts +++ b/test/utils/string.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it, vi } from 'vitest'; +import type * as mathUtils from '../../src/utils/math'; import { random } from '../../src/utils/math'; import { randomChars, readTextAnyEncoding } from '../../src/utils/string'; vi.mock('../../src/utils/math', async (importOriginal) => { - const actual = await importOriginal<typeof import('../../src/utils/math')>(); + const actual = await importOriginal<typeof mathUtils>(); return { ...actual, random: vi.fn() }; });