diff --git a/README.md b/README.md
index 4610889..87ad683 100644
--- a/README.md
+++ b/README.md
@@ -111,8 +111,10 @@ stash-ui/
| `Segmented` | 视图形态切换 | `options` `value` `onChange` `size` `block` |
| `Tabs` | 页内分区切换 | `items` `activeKey` `onChange` `variant` |
| `Menu` | 侧栏两级菜单 | `items` `activeKey` `onSelect` |
+| `Caret` | 折叠箭头(6px 折角) | `open` `size` |
| `Breadcrumb` | 路径导航 | `items` `separator` |
| `Table` | 只读数据表格 | `columns` `rows` `rowKey` `size` `emptyText` |
+| `SortableList` | 可拖拽排序的条目列表 | `items` `activeKey` `onSelect` `onReorder` `disabled` |
| `Tag` | 分类 / 标签 / 状态(浅底 / 实底) | `tone` `variant` `shape` `icon` `dot` |
| `ItemBadge` | 状态角标:置顶 / 付费 / HOT | `tone` |
| `Badge` | 数字角标 | `count` `tone` `max` |
diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md
index ee0f1d3..e893c50 100644
--- a/docs/COMPONENTS.md
+++ b/docs/COMPONENTS.md
@@ -31,6 +31,7 @@
| `Segmented` | L2 | ✅ | 视图形态切换 | `options` `value` `onChange` `size` `block` |
| `Tabs` | L2 | ✅ | 页内分区切换 | `items` `activeKey` `onChange` `variant` |
| `Menu` | L2 | ✅ | 侧栏两级菜单(可折叠动画) | `items` `activeKey` `onSelect` `itemHeight` |
+| `Caret` | L1 | ✅ | 折叠箭头(6px 折角,收起/展开) | `open` `size` |
| `Breadcrumb` | L3 | ✅ | 路径导航 | `items` `separator` |
| `Brand` | L2 | ✅ | 品牌标识:logo / 首字标记 + 名称 | `name` `mark` `logo` `href` `size` `tone` |
| `SearchField` | L2 | ✅ | 搜索框(语义版 Input,对外只给字符串) | `value` `onChange` `variant` `size` `hotkey` |
@@ -45,6 +46,7 @@
| `Grid` | L3 | ✅ | 卡片列表网格 | `columns` `tabletColumns` `mobileColumns` `gap` `as` |
| `Space` | L3 | ✅ | 一维间距容器 | `direction` `gap` `wrap` `align` `justify` |
| `Table` | L3 | ✅ | 只读数据表格 | `columns` `rows` `rowKey` `size` `emptyText` |
+| `SortableList` | L3 | ✅ | 可拖拽排序的条目列表 | `items` `activeKey` `onSelect` `onReorder` `disabled` |
| `Notice` | L4 | ✅ | 说明 / 错误提示条 | `tone` `title` `action` |
| `Empty` | L4 | ✅ | 空态 | `text` `action` |
| `Result` | L4 | ✅ | 整块结果反馈 | `status` `title` `description` `actions` |
@@ -686,6 +688,24 @@
- **规则**:开关的无障碍名称必须有值,label 不是字符串时请显式传 `switchLabel`。
- **展示页**:`#switch`
+### 3.48 Caret ✅
+
+- **用途**:折叠箭头——一个 6px 的 CSS 折角,表示「可展开 / 已展开」。
+- **不该用在哪**:带文字的完整按钮(用 `Button` / `TextButton`);方向指示(用 `Icon`)。
+- **解剖**:单个 `span`,left + bottom 两道边框旋转成折角,`aria-hidden`。
+- **Props**:`open`(默认 `false`,收起朝下 / 展开朝上)、`size`(折角边长,默认 6)。
+- **规则**:纯装饰,无障碍状态由外层按钮的 `aria-expanded` 承担;`Menu` 与 `Select` 内部就用的它。
+- **展示页**:`#menu`
+
+### 3.49 SortableList ✅
+
+- **用途**:一列可拖拽排序的条目(卡片列表、菜单排序、优先级调整)。
+- **不该用在哪**:需要键盘完成排序的场景(本组件只做鼠标拖拽,需在外配上移 / 下移按钮);只读列表(用 `Menu` 或自己渲染)。
+- **解剖**:容器 + 逐条 `button`(拖拽手柄 / 文案 / 右侧 meta),拖动时 `dragging`、经过时 `over`、选中时 `active`。
+- **Props**:`items: { key, label, meta? }[]`、`activeKey`、`onSelect`、`onReorder(keys)`、`disabled`、`emptyText`。
+- **规则**:顺序没变不触发 `onReorder`;向下拖插到目标之后(保证能拖到末尾);`disabled` 期间禁止拖动,仍可点击。
+- **展示页**:`#sortable`
+
---
## 4. 组合模式(不进库,但必须照这个拼)
@@ -792,3 +812,4 @@
| 2026-11-01 | 新增 `EntryRow` / `EntryTile`:把应用里两套手写内容卡(`.app-card` / `.ces-card`)收进库。两者共用同一套点击语义(无修饰键接管、按住修饰键保留默认)与悬停反馈;`EntryTile` 由 `Card padding="none" hoverable` + `Cover` 拼成。兼容性:纯新增 |
| 2026-11-01 | `Select` 的下拉箭头从 `arrow_down_line` 换成和 `Menu` 一致的 CSS 折角(6px、收起朝下 / 展开朝上)。影响面:所有 `Select` 的外观,交互不变 |
| 2026-11-01 | 新增 `TextButton`(文字按钮:`tone` 主色 / 危险色、可选图标)与 `ToggleRow`(左标签 + 右开关的开关行,支持 `hint`);`Table` 新增 `size: 'md' \| 'sm'` 紧凑档。目的是把后台配置页里手写的 `.group-action` / `.sidebar-toggle-row` / `
` 收进库。兼容性:纯新增,`Table` 默认行为不变 |
+| 2026-11-01 | 新增 `Caret`(6px 折角,`Menu` 与 `Select` 内部改为复用它,画法只剩一份)与 `SortableList`(可拖拽排序列表,`onReorder` 回传新顺序)。兼容性:纯新增;`Menu` / `Select` 的箭头外观不变 |
diff --git a/site/App.tsx b/site/App.tsx
index 9509497..3f6828f 100644
--- a/site/App.tsx
+++ b/site/App.tsx
@@ -16,6 +16,7 @@ import { Layouts } from './sections/Layouts'
import { SiteChrome } from './sections/SiteChrome'
import { Marks } from './sections/Marks'
import { MenuSection } from './sections/MenuSection'
+import { Sortables } from './sections/Sortables'
import { Modals } from './sections/Modals'
import { Overview } from './sections/Overview'
import { PageLayout } from './sections/PageLayout'
@@ -83,6 +84,7 @@ const NAV = [
{ id: 'breadcrumb', label: '面包屑 Breadcrumb' },
{ id: 'tabs', label: '标签页 Tabs' },
{ id: 'menu', label: '菜单 Menu' },
+ { id: 'sortable', label: '排序列表 SortableList' },
],
},
{
@@ -208,6 +210,7 @@ export function App() {
+
diff --git a/site/sections/MenuSection.tsx b/site/sections/MenuSection.tsx
index 27b3a1f..94b7c46 100644
--- a/site/sections/MenuSection.tsx
+++ b/site/sections/MenuSection.tsx
@@ -1,5 +1,5 @@
import { useState } from 'react'
-import { Card, Icon, Menu, Text } from '../../src'
+import { Card, Caret, Icon, Menu, Space, Text } from '../../src'
import { Demo } from '../components/Demo'
import { PropsTable } from '../components/PropsTable'
import { Section } from '../components/Section'
@@ -79,6 +79,28 @@ export function MenuSection() {
+
+`}
+ >
+
+
+
+
+ 收起
+
+
+
+
+
+ 展开
+
+
+
+
+
+
+
)
}
diff --git a/site/sections/Sortables.tsx b/site/sections/Sortables.tsx
new file mode 100644
index 0000000..d4df9c5
--- /dev/null
+++ b/site/sections/Sortables.tsx
@@ -0,0 +1,88 @@
+import { useState } from 'react'
+import { SortableList, Text } from '../../src'
+import { Demo } from '../components/Demo'
+import { PropsTable } from '../components/PropsTable'
+import { Section } from '../components/Section'
+
+const INITIAL = [
+ { key: 'pin', label: '常用推荐', meta: 12 },
+ { key: 'movie', label: '推荐电影', meta: 12 },
+ { key: 'ces', label: 'CES 现场报道', meta: 12 },
+ { key: 'tools', label: '开发工具', meta: 5 },
+]
+
+export function Sortables() {
+ const [items, setItems] = useState(INITIAL)
+ const [activeKey, setActiveKey] = useState('pin')
+
+ const reorder = (keys: string[]) => {
+ setItems((current) =>
+ keys
+ .map((key) => current.find((item) => item.key === key))
+ .filter((item): item is (typeof INITIAL)[number] => item !== undefined),
+ )
+ }
+
+ return (
+
+ setItems(reorder(keys))}
+/>`}
+ >
+
+
+
+
+
+ {}} onReorder={() => {}} emptyText="还没有卡片" />`}
+ >
+
+ undefined}
+ onReorder={() => undefined}
+ emptyText="还没有卡片"
+ />
+
+
+
+ void', required: true, desc: '点击选中' },
+ { name: 'onReorder', type: '(keys: string[]) => void', required: true, desc: '拖拽后的完整顺序' },
+ { name: 'activeKey', type: 'string', desc: '当前选中项' },
+ { name: 'disabled', type: 'boolean', defaultValue: 'false', desc: '排序保存中,禁止再次拖动' },
+ { name: 'emptyText', type: 'ReactNode', desc: '空态文案,不传则不渲染' },
+ ]}
+ />
+
+
+ 说明:拖拽只在条目之间生效;松手时若顺序没变不会触发 onReorder。
+
+
+ )
+}
diff --git a/src/components/Caret.module.css b/src/components/Caret.module.css
new file mode 100644
index 0000000..2c49e5f
--- /dev/null
+++ b/src/components/Caret.module.css
@@ -0,0 +1,25 @@
+/* 折角箭头:左 + 下两条边旋转而成,收起朝下、展开朝上 */
+.root {
+ display: inline-block;
+ flex-shrink: 0;
+ width: 6px;
+ height: 6px;
+ border-left: 1px solid currentColor;
+ border-bottom: 1px solid currentColor;
+ opacity: 0.4;
+ transform: rotate(315deg);
+ transition:
+ transform var(--su-duration-base) var(--su-ease-in-out),
+ opacity var(--su-duration-base) var(--su-ease-in-out);
+}
+
+.open {
+ opacity: 0.7;
+ transform: rotate(135deg);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .root {
+ transition: none;
+ }
+}
diff --git a/src/components/Caret.tsx b/src/components/Caret.tsx
new file mode 100644
index 0000000..bb150e7
--- /dev/null
+++ b/src/components/Caret.tsx
@@ -0,0 +1,27 @@
+import styles from './Caret.module.css'
+
+/**
+ * 折叠箭头:一个 6px 的 CSS 折角,表示「可展开 / 已展开」。
+ *
+ * Menu、Select、可折叠表格行里的箭头都用它,保证画法一致;
+ * 纯装饰,`aria-hidden`,无障碍状态由外层按钮的 `aria-expanded` 承担。
+ */
+export type CaretProps = {
+ /** 展开状态:收起朝下、展开朝上 */
+ open?: boolean
+ /** 尺寸,默认 6px */
+ size?: number
+ className?: string
+}
+
+export function Caret({ open = false, size, className }: CaretProps) {
+ return (
+
+ )
+}
diff --git a/src/components/Menu.module.css b/src/components/Menu.module.css
index bbc5ec2..7cc3b32 100644
--- a/src/components/Menu.module.css
+++ b/src/components/Menu.module.css
@@ -64,30 +64,16 @@
white-space: nowrap;
}
-/* 折叠箭头用两道边框画(6×6 V 形),和参考站一致 */
+/* 折叠箭头交给 Caret 组件画,这里只留间距与 hover */
.caret {
flex-shrink: 0;
- width: 6px;
- height: 6px;
margin-right: 2px;
- border-left: 1px solid currentColor;
- border-bottom: 1px solid currentColor;
- opacity: 0.4;
- transform: rotate(315deg);
- transition:
- transform var(--su-duration-base) var(--su-ease-in-out),
- opacity var(--su-duration-base) var(--su-ease-in-out);
}
.item:hover .caret {
opacity: 0.7;
}
-.caretOpen {
- opacity: 0.7;
- transform: rotate(135deg);
-}
-
/* 子菜单展开:grid-template-rows 0fr → 1fr 做高度动画,不猜 max-height */
.submenuWrap {
display: grid;
diff --git a/src/components/Menu.tsx b/src/components/Menu.tsx
index 42b6f7a..37114ed 100644
--- a/src/components/Menu.tsx
+++ b/src/components/Menu.tsx
@@ -1,4 +1,5 @@
import { useState, type CSSProperties, type ReactNode } from 'react'
+import { Caret } from './Caret'
import styles from './Menu.module.css'
/**
@@ -71,10 +72,7 @@ export function Menu({
)}
{item.label}
-
+
) : (
{open && (
diff --git a/src/components/SortableList.module.css b/src/components/SortableList.module.css
new file mode 100644
index 0000000..d02496d
--- /dev/null
+++ b/src/components/SortableList.module.css
@@ -0,0 +1,82 @@
+.root {
+ display: flex;
+ flex-direction: column;
+ gap: var(--su-space-1);
+}
+
+.item {
+ display: flex;
+ align-items: center;
+ gap: var(--su-space-2);
+ width: 100%;
+ padding: var(--su-space-2) var(--su-space-3);
+ border: none;
+ border-radius: var(--su-radius-sm);
+ background-color: transparent;
+ color: var(--su-text-main);
+ font-size: var(--su-font-size-md);
+ text-align: left;
+ cursor: pointer;
+ transition: background-color var(--su-duration-fast) var(--su-ease-out);
+}
+
+.item:hover {
+ background-color: var(--su-bg-card);
+}
+
+.item:focus-visible {
+ outline: var(--su-focus-ring);
+ outline-offset: var(--su-focus-ring-offset);
+}
+
+.active {
+ background-color: var(--su-bg-card);
+ color: var(--su-color-primary);
+ font-weight: var(--su-font-weight-strong);
+}
+
+.dragging {
+ opacity: 0.45;
+}
+
+.over {
+ outline: 1px dashed var(--su-color-primary);
+ outline-offset: -1px;
+}
+
+.handle {
+ flex: none;
+ color: var(--su-text-placeholder);
+ font-size: var(--su-font-size-sm);
+ line-height: 1;
+ letter-spacing: -2px;
+ cursor: grab;
+}
+
+.label {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.meta {
+ flex: none;
+ color: var(--su-text-muted);
+ font-size: var(--su-font-size-sm);
+ font-weight: var(--su-font-weight-normal);
+}
+
+.empty {
+ margin: 0;
+ padding: var(--su-space-2) var(--su-space-3);
+ color: var(--su-text-muted);
+ font-size: var(--su-font-size-sm);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .item {
+ transition: none;
+ }
+}
diff --git a/src/components/SortableList.tsx b/src/components/SortableList.tsx
new file mode 100644
index 0000000..c258a7a
--- /dev/null
+++ b/src/components/SortableList.tsx
@@ -0,0 +1,117 @@
+import { useState, type ReactNode } from 'react'
+import styles from './SortableList.module.css'
+
+/**
+ * 可拖拽排序的列表:每行一个条目,拖动改变顺序,点击选中。
+ *
+ * 数据无关:条目由页面给(key / label / meta),顺序变化通过 `onReorder`
+ * 把新的 key 数组交回页面,由页面决定怎么保存。
+ * 键盘可达性由「选中 + 上移/下移按钮」这类配套交互负责——本组件只提供鼠标拖拽,
+ * 需要键盘排序时请在外面另配上移/下移控件。
+ */
+export type SortableItem = {
+ key: string
+ label: ReactNode
+ /** 右侧的次要信息,例如条数 */
+ meta?: ReactNode
+}
+
+export type SortableListProps = {
+ items: SortableItem[]
+ /** 当前选中项 */
+ activeKey?: string
+ onSelect: (key: string) => void
+ /** 拖拽结束后的新顺序(完整的 key 数组) */
+ onReorder: (keys: string[]) => void
+ /** 排序保存中:期间禁止再次拖动 */
+ disabled?: boolean
+ /** 列表为空时的提示,不传则不渲染空态 */
+ emptyText?: ReactNode
+ className?: string
+}
+
+export function SortableList({
+ items,
+ activeKey,
+ onSelect,
+ onReorder,
+ disabled = false,
+ emptyText,
+ className,
+}: SortableListProps) {
+ const [dragKey, setDragKey] = useState(null)
+ const [overKey, setOverKey] = useState(null)
+
+ const drop = (targetKey: string) => {
+ const from = dragKey
+ setDragKey(null)
+ setOverKey(null)
+ if (from === null || from === targetKey) return
+
+ const keys = items.map((item) => item.key)
+ const fromIndex = keys.indexOf(from)
+ const targetIndex = keys.indexOf(targetKey)
+ if (fromIndex < 0 || targetIndex < 0) return
+
+ const next = keys.filter((key) => key !== from)
+ const targetAfter = next.indexOf(targetKey)
+ // 向下拖插到目标之后(这样才能拖到列表末尾),向上拖插到目标之前
+ const insertAt = fromIndex < targetIndex ? targetAfter + 1 : targetAfter
+ next.splice(insertAt, 0, from)
+
+ if (next.every((key, index) => key === keys[index])) return
+ onReorder(next)
+ }
+
+ return (
+
+ {items.map((item) => {
+ const active = item.key === activeKey
+ const dragging = item.key === dragKey
+ const over = item.key === overKey && dragKey !== null && dragKey !== item.key
+
+ return (
+
+ )
+ })}
+
+ {items.length === 0 && emptyText !== undefined && (
+
{emptyText}
+ )}
+
+ )
+}
diff --git a/src/index.ts b/src/index.ts
index fbf162d..f4a41be 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -49,6 +49,12 @@ export type { TextButtonProps, TextButtonTone } from './components/TextButton'
export { ToggleRow } from './components/ToggleRow'
export type { ToggleRowProps } from './components/ToggleRow'
+export { Caret } from './components/Caret'
+export type { CaretProps } from './components/Caret'
+
+export { SortableList } from './components/SortableList'
+export type { SortableListProps, SortableItem } from './components/SortableList'
+
export { Cover } from './components/Cover'
export type { CoverProps, CoverRatio } from './components/Cover'