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:
2026-11-01 22:07:31 +08:00
parent cc35400f67
commit 5eb1506650
14 changed files with 407 additions and 36 deletions
+2
View File
@@ -111,8 +111,10 @@ stash-ui/
| `Segmented` | 视图形态切换 | `options` `value` `onChange` `size` `block` | | `Segmented` | 视图形态切换 | `options` `value` `onChange` `size` `block` |
| `Tabs` | 页内分区切换 | `items` `activeKey` `onChange` `variant` | | `Tabs` | 页内分区切换 | `items` `activeKey` `onChange` `variant` |
| `Menu` | 侧栏两级菜单 | `items` `activeKey` `onSelect` | | `Menu` | 侧栏两级菜单 | `items` `activeKey` `onSelect` |
| `Caret` | 折叠箭头(6px 折角) | `open` `size` |
| `Breadcrumb` | 路径导航 | `items` `separator` | | `Breadcrumb` | 路径导航 | `items` `separator` |
| `Table` | 只读数据表格 | `columns` `rows` `rowKey` `size` `emptyText` | | `Table` | 只读数据表格 | `columns` `rows` `rowKey` `size` `emptyText` |
| `SortableList` | 可拖拽排序的条目列表 | `items` `activeKey` `onSelect` `onReorder` `disabled` |
| `Tag` | 分类 / 标签 / 状态(浅底 / 实底) | `tone` `variant` `shape` `icon` `dot` | | `Tag` | 分类 / 标签 / 状态(浅底 / 实底) | `tone` `variant` `shape` `icon` `dot` |
| `ItemBadge` | 状态角标:置顶 / 付费 / HOT | `tone` | | `ItemBadge` | 状态角标:置顶 / 付费 / HOT | `tone` |
| `Badge` | 数字角标 | `count` `tone` `max` | | `Badge` | 数字角标 | `count` `tone` `max` |
+21
View File
@@ -31,6 +31,7 @@
| `Segmented` | L2 | ✅ | 视图形态切换 | `options` `value` `onChange` `size` `block` | | `Segmented` | L2 | ✅ | 视图形态切换 | `options` `value` `onChange` `size` `block` |
| `Tabs` | L2 | ✅ | 页内分区切换 | `items` `activeKey` `onChange` `variant` | | `Tabs` | L2 | ✅ | 页内分区切换 | `items` `activeKey` `onChange` `variant` |
| `Menu` | L2 | ✅ | 侧栏两级菜单(可折叠动画) | `items` `activeKey` `onSelect` `itemHeight` | | `Menu` | L2 | ✅ | 侧栏两级菜单(可折叠动画) | `items` `activeKey` `onSelect` `itemHeight` |
| `Caret` | L1 | ✅ | 折叠箭头(6px 折角,收起/展开) | `open` `size` |
| `Breadcrumb` | L3 | ✅ | 路径导航 | `items` `separator` | | `Breadcrumb` | L3 | ✅ | 路径导航 | `items` `separator` |
| `Brand` | L2 | ✅ | 品牌标识:logo / 首字标记 + 名称 | `name` `mark` `logo` `href` `size` `tone` | | `Brand` | L2 | ✅ | 品牌标识:logo / 首字标记 + 名称 | `name` `mark` `logo` `href` `size` `tone` |
| `SearchField` | L2 | ✅ | 搜索框(语义版 Input,对外只给字符串) | `value` `onChange` `variant` `size` `hotkey` | | `SearchField` | L2 | ✅ | 搜索框(语义版 Input,对外只给字符串) | `value` `onChange` `variant` `size` `hotkey` |
@@ -45,6 +46,7 @@
| `Grid` | L3 | ✅ | 卡片列表网格 | `columns` `tabletColumns` `mobileColumns` `gap` `as` | | `Grid` | L3 | ✅ | 卡片列表网格 | `columns` `tabletColumns` `mobileColumns` `gap` `as` |
| `Space` | L3 | ✅ | 一维间距容器 | `direction` `gap` `wrap` `align` `justify` | | `Space` | L3 | ✅ | 一维间距容器 | `direction` `gap` `wrap` `align` `justify` |
| `Table` | L3 | ✅ | 只读数据表格 | `columns` `rows` `rowKey` `size` `emptyText` | | `Table` | L3 | ✅ | 只读数据表格 | `columns` `rows` `rowKey` `size` `emptyText` |
| `SortableList` | L3 | ✅ | 可拖拽排序的条目列表 | `items` `activeKey` `onSelect` `onReorder` `disabled` |
| `Notice` | L4 | ✅ | 说明 / 错误提示条 | `tone` `title` `action` | | `Notice` | L4 | ✅ | 说明 / 错误提示条 | `tone` `title` `action` |
| `Empty` | L4 | ✅ | 空态 | `text` `action` | | `Empty` | L4 | ✅ | 空态 | `text` `action` |
| `Result` | L4 | ✅ | 整块结果反馈 | `status` `title` `description` `actions` | | `Result` | L4 | ✅ | 整块结果反馈 | `status` `title` `description` `actions` |
@@ -686,6 +688,24 @@
- **规则**:开关的无障碍名称必须有值,label 不是字符串时请显式传 `switchLabel`。 - **规则**:开关的无障碍名称必须有值,label 不是字符串时请显式传 `switchLabel`。
- **展示页**:`#switch` - **展示页**:`#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. 组合模式(不进库,但必须照这个拼) ## 4. 组合模式(不进库,但必须照这个拼)
@@ -792,3 +812,4 @@
| 2026-11-01 | 新增 `EntryRow` / `EntryTile`:把应用里两套手写内容卡(`.app-card` / `.ces-card`)收进库。两者共用同一套点击语义(无修饰键接管、按住修饰键保留默认)与悬停反馈;`EntryTile` 由 `Card padding="none" hoverable` + `Cover` 拼成。兼容性:纯新增 | | 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 | `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 | 新增 `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` 的箭头外观不变 |
+3
View File
@@ -16,6 +16,7 @@ import { Layouts } from './sections/Layouts'
import { SiteChrome } from './sections/SiteChrome' import { SiteChrome } from './sections/SiteChrome'
import { Marks } from './sections/Marks' import { Marks } from './sections/Marks'
import { MenuSection } from './sections/MenuSection' import { MenuSection } from './sections/MenuSection'
import { Sortables } from './sections/Sortables'
import { Modals } from './sections/Modals' import { Modals } from './sections/Modals'
import { Overview } from './sections/Overview' import { Overview } from './sections/Overview'
import { PageLayout } from './sections/PageLayout' import { PageLayout } from './sections/PageLayout'
@@ -83,6 +84,7 @@ const NAV = [
{ id: 'breadcrumb', label: '面包屑 Breadcrumb' }, { id: 'breadcrumb', label: '面包屑 Breadcrumb' },
{ id: 'tabs', label: '标签页 Tabs' }, { id: 'tabs', label: '标签页 Tabs' },
{ id: 'menu', label: '菜单 Menu' }, { id: 'menu', label: '菜单 Menu' },
{ id: 'sortable', label: '排序列表 SortableList' },
], ],
}, },
{ {
@@ -208,6 +210,7 @@ export function App() {
<Marks /> <Marks />
<Avatars /> <Avatars />
<Tables /> <Tables />
<Sortables />
<Results /> <Results />
<Progresses /> <Progresses />
<StepsSection /> <StepsSection />
+30 -1
View File
@@ -1,5 +1,5 @@
import { useState } from 'react' 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 { Demo } from '../components/Demo'
import { PropsTable } from '../components/PropsTable' import { PropsTable } from '../components/PropsTable'
import { Section } from '../components/Section' import { Section } from '../components/Section'
@@ -79,6 +79,28 @@ export function MenuSection() {
</Card> </Card>
</Demo> </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 <PropsTable
rows={[ rows={[
{ name: 'items', type: 'MenuItem[]', required: true, desc: '{ key, label, icon?, children? }' }, { 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)' }, { 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> </Section>
) )
} }
+88
View File
@@ -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>
)
}
+25
View File
@@ -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;
}
}
+27
View File
@@ -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"
/>
)
}
+1 -15
View File
@@ -64,30 +64,16 @@
white-space: nowrap; white-space: nowrap;
} }
/* 折叠箭头用两道边框画(6×6 V 形),和参考站一致 */ /* 折叠箭头交给 Caret 组件画,这里只留间距与 hover */
.caret { .caret {
flex-shrink: 0; flex-shrink: 0;
width: 6px;
height: 6px;
margin-right: 2px; 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 { .item:hover .caret {
opacity: 0.7; opacity: 0.7;
} }
.caretOpen {
opacity: 0.7;
transform: rotate(135deg);
}
/* 子菜单展开:grid-template-rows 0fr → 1fr 做高度动画,不猜 max-height */ /* 子菜单展开:grid-template-rows 0fr → 1fr 做高度动画,不猜 max-height */
.submenuWrap { .submenuWrap {
display: grid; display: grid;
+2 -4
View File
@@ -1,4 +1,5 @@
import { useState, type CSSProperties, type ReactNode } from 'react' import { useState, type CSSProperties, type ReactNode } from 'react'
import { Caret } from './Caret'
import styles from './Menu.module.css' import styles from './Menu.module.css'
/** /**
@@ -71,10 +72,7 @@ export function Menu({
</span> </span>
)} )}
<span className={styles.label}>{item.label}</span> <span className={styles.label}>{item.label}</span>
<span <Caret open={open} className={styles.caret} />
className={`${styles.caret}${open ? ` ${styles.caretOpen}` : ''}`}
aria-hidden="true"
/>
</button> </button>
) : ( ) : (
<button <button
+1 -15
View File
@@ -51,30 +51,16 @@
white-space: nowrap; white-space: nowrap;
} }
/* 折角箭头:与 Menu 的 caret 同一套画法,收起朝下、展开朝上 */ /* 折角箭头交给 Caret 组件画,这里只留间距与 hover */
.caret { .caret {
flex-shrink: 0; flex-shrink: 0;
width: 6px;
height: 6px;
margin-right: 2px; 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 { .trigger:hover .caret {
opacity: 0.7; opacity: 0.7;
} }
.triggerOpen .caret {
opacity: 0.7;
transform: rotate(135deg);
}
.list { .list {
position: absolute; position: absolute;
top: calc(100% + 4px); top: calc(100% + 4px);
+2 -1
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState, type ReactNode } from 'react' import { useEffect, useRef, useState, type ReactNode } from 'react'
import { Caret } from './Caret'
import { Icon } from './Icon' import { Icon } from './Icon'
import styles from './Select.module.css' import styles from './Select.module.css'
@@ -105,7 +106,7 @@ export function Select({
<span className={selected === undefined ? styles.placeholder : styles.value}> <span className={selected === undefined ? styles.placeholder : styles.value}>
{selected?.label ?? placeholder} {selected?.label ?? placeholder}
</span> </span>
<span className={styles.caret} aria-hidden="true" /> <Caret open={open} className={styles.caret} />
</button> </button>
{open && ( {open && (
+82
View File
@@ -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;
}
}
+117
View File
@@ -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>
)
}
+6
View File
@@ -49,6 +49,12 @@ export type { TextButtonProps, TextButtonTone } from './components/TextButton'
export { ToggleRow } from './components/ToggleRow' export { ToggleRow } from './components/ToggleRow'
export type { ToggleRowProps } 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 { Cover } from './components/Cover'
export type { CoverProps, CoverRatio } from './components/Cover' export type { CoverProps, CoverRatio } from './components/Cover'