feat(ui): 新增 Caret / SortableList,箭头画法收成一份
- Caret:6px CSS 折角(open 切朝向,size 可调),Menu 与 Select 改为复用它, 两处各自的画法删掉,全库只剩一份 - SortableList:可拖拽排序的条目列表(items / activeKey / onSelect / onReorder), 拖拽位置计算从应用里挪进组件,顺序没变不回调 - 展示页:菜单补「折叠箭头」,新增 #sortable 一节并挂到导航 - COMPONENTS.md 契约 3.48 / 3.49、组件地图、README 清单、变更记录同步
This commit is contained in:
@@ -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` |
|
||||
|
||||
@@ -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>` 收进库。兼容性:纯新增,`Table` 默认行为不变 |
|
||||
| 2026-11-01 | 新增 `Caret`(6px 折角,`Menu` 与 `Select` 内部改为复用它,画法只剩一份)与 `SortableList`(可拖拽排序列表,`onReorder` 回传新顺序)。兼容性:纯新增;`Menu` / `Select` 的箭头外观不变 |
|
||||
|
||||
@@ -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() {
|
||||
<Marks />
|
||||
<Avatars />
|
||||
<Tables />
|
||||
<Sortables />
|
||||
<Results />
|
||||
<Progresses />
|
||||
<StepsSection />
|
||||
|
||||
@@ -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() {
|
||||
</Card>
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="折叠箭头"
|
||||
desc="Caret 是菜单、下拉、可折叠表格行共用的 6px 折角:收起朝下、展开朝上,纯装饰,无障碍状态由外层按钮的 aria-expanded 承担。"
|
||||
code={`<Caret />
|
||||
<Caret open />`}
|
||||
>
|
||||
<Space gap="lg">
|
||||
<Space gap="sm" align="center">
|
||||
<Caret />
|
||||
<Text size="sm" tone="muted">
|
||||
收起
|
||||
</Text>
|
||||
</Space>
|
||||
<Space gap="sm" align="center">
|
||||
<Caret open />
|
||||
<Text size="sm" tone="muted">
|
||||
展开
|
||||
</Text>
|
||||
</Space>
|
||||
</Space>
|
||||
</Demo>
|
||||
|
||||
<PropsTable
|
||||
rows={[
|
||||
{ name: 'items', type: 'MenuItem[]', required: true, desc: '{ key, label, icon?, children? }' },
|
||||
@@ -88,6 +110,13 @@ export function MenuSection() {
|
||||
{ name: 'itemHeight', type: 'number', defaultValue: '36', desc: '一级菜单行高(px)' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<PropsTable
|
||||
rows={[
|
||||
{ name: 'open', type: 'boolean', defaultValue: 'false', desc: '展开状态(决定箭头朝向)' },
|
||||
{ name: 'size', type: 'number', defaultValue: '6', desc: '折角边长(px)' },
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Section
|
||||
id="sortable"
|
||||
title="排序列表"
|
||||
api="SortableList"
|
||||
desc="一列可拖拽排序的条目:拖动改顺序、点击选中,顺序变化通过 onReorder 交回页面。只做鼠标拖拽,需要键盘排序时在外面配上移/下移按钮。"
|
||||
>
|
||||
<Demo
|
||||
title="基础用法"
|
||||
desc="拖住左侧手柄上下移动,松手后把新的 key 顺序交给 onReorder;点击选中项会高亮。"
|
||||
layout="column"
|
||||
code={`const [items, setItems] = useState(INITIAL)
|
||||
|
||||
<SortableList
|
||||
items={items}
|
||||
activeKey={activeKey}
|
||||
onSelect={setActiveKey}
|
||||
onReorder={(keys) => setItems(reorder(keys))}
|
||||
/>`}
|
||||
>
|
||||
<div style={{ width: 280 }}>
|
||||
<SortableList
|
||||
items={items}
|
||||
activeKey={activeKey}
|
||||
onSelect={setActiveKey}
|
||||
onReorder={reorder}
|
||||
/>
|
||||
</div>
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="空态"
|
||||
desc="items 为空时渲染 emptyText;不传就不显示空态。"
|
||||
layout="column"
|
||||
code={`<SortableList items={[]} onSelect={() => {}} onReorder={() => {}} emptyText="还没有卡片" />`}
|
||||
>
|
||||
<div style={{ width: 280 }}>
|
||||
<SortableList
|
||||
items={[]}
|
||||
onSelect={() => undefined}
|
||||
onReorder={() => undefined}
|
||||
emptyText="还没有卡片"
|
||||
/>
|
||||
</div>
|
||||
</Demo>
|
||||
|
||||
<PropsTable
|
||||
rows={[
|
||||
{ name: 'items', type: 'SortableItem[]', required: true, desc: '{ key, label, meta? }' },
|
||||
{ name: 'onSelect', type: '(key: string) => 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: '空态文案,不传则不渲染' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Text size="sm" tone="muted">
|
||||
说明:拖拽只在条目之间生效;松手时若顺序没变不会触发 onReorder。
|
||||
</Text>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<span
|
||||
className={[styles.root, open ? styles.open : undefined, className]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={size === undefined ? undefined : { width: size, height: size }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
</span>
|
||||
)}
|
||||
<span className={styles.label}>{item.label}</span>
|
||||
<span
|
||||
className={`${styles.caret}${open ? ` ${styles.caretOpen}` : ''}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Caret open={open} className={styles.caret} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
|
||||
@@ -51,30 +51,16 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 折角箭头:与 Menu 的 caret 同一套画法,收起朝下、展开朝上 */
|
||||
/* 折角箭头交给 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);
|
||||
}
|
||||
|
||||
.trigger:hover .caret {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.triggerOpen .caret {
|
||||
opacity: 0.7;
|
||||
transform: rotate(135deg);
|
||||
}
|
||||
|
||||
.list {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { Caret } from './Caret'
|
||||
import { Icon } from './Icon'
|
||||
import styles from './Select.module.css'
|
||||
|
||||
@@ -105,7 +106,7 @@ export function Select({
|
||||
<span className={selected === undefined ? styles.placeholder : styles.value}>
|
||||
{selected?.label ?? placeholder}
|
||||
</span>
|
||||
<span className={styles.caret} aria-hidden="true" />
|
||||
<Caret open={open} className={styles.caret} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<string | null>(null)
|
||||
const [overKey, setOverKey] = useState<string | null>(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 (
|
||||
<div className={[styles.root, className].filter(Boolean).join(' ')}>
|
||||
{items.map((item) => {
|
||||
const active = item.key === activeKey
|
||||
const dragging = item.key === dragKey
|
||||
const over = item.key === overKey && dragKey !== null && dragKey !== item.key
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
draggable={!disabled}
|
||||
aria-current={active ? 'true' : undefined}
|
||||
className={[styles.item, active ? styles.active : undefined, dragging ? styles.dragging : undefined, over ? styles.over : undefined]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onClick={() => onSelect(item.key)}
|
||||
onDragStart={(event) => {
|
||||
setDragKey(item.key)
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
event.dataTransfer.setData('text/plain', item.key)
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
if (disabled) return
|
||||
event.preventDefault()
|
||||
if (overKey !== item.key) setOverKey(item.key)
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault()
|
||||
if (disabled) return
|
||||
drop(item.key)
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
setDragKey(null)
|
||||
setOverKey(null)
|
||||
}}
|
||||
>
|
||||
<span className={styles.handle} aria-hidden="true">
|
||||
⋮⋮
|
||||
</span>
|
||||
<span className={styles.label}>{item.label}</span>
|
||||
{item.meta !== undefined && <span className={styles.meta}>{item.meta}</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
{items.length === 0 && emptyText !== undefined && (
|
||||
<p className={styles.empty}>{emptyText}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user