文章准备就绪
'use client'
import { useState } from 'react'
import { Clock, FilePen, Send } from 'lucide-react'
import { DropdownMenuItem, SplitButton, Stack, Text } from '@hina-ui/react'
export default function Demo() {
const [result, setResult] = useState('文章准备就绪')
return (
<Stack align="center" gap="sm">
<SplitButton
label="文章操作"
menuLabel="其他发布方式"
icon={<Send />}
onClick={() => setResult('已执行:立即发布')}
renderContent={() => (
<>
<DropdownMenuItem icon={<FilePen />} onSelect={() => setResult('已执行:保存草稿')}>
保存草稿
</DropdownMenuItem>
<DropdownMenuItem icon={<Clock />} onSelect={() => setResult('已执行:定时发布')}>
定时发布
</DropdownMenuItem>
</>
)}
>
立即发布
</SplitButton>
<Text role="status" size="sm" tone="muted">
{result}
</Text>
</Stack>
)
}
用法
import { SplitButton, DropdownMenuItem } from '@hina-ui/react'
主按钮点击调用 onClick,尾部箭头打开菜单。将附加操作放进 renderContent,通过 DropdownMenuItem 的 onSelect 处理选择。适合“立即发布 / 保存草稿”“导出 / 选择格式”等有明确默认操作的场景。
<SplitButton
menuLabel="其他保存方式"
onClick={save}
renderContent={() => <DropdownMenuItem onSelect={saveCopy}>另存副本</DropdownMenuItem>}
>
保存
</SplitButton>
如果几项操作同样常用,用 ButtonGroup;只有菜单而没有默认操作,用 DropdownMenu。菜单选择不会自动改变主按钮,是否记住所选操作由调用方决定。
示例
外观与色调
两侧共享 variant 和 tone,连接处不重复圆角。实底、浅底和无底色形态带有分隔线,描边形态共用一条边框。
每种外观都保留独立的主操作和菜单
'use client'
import { useState } from 'react'
import { DropdownMenuItem, Flex, SplitButton, Stack, Text } from '@hina-ui/react'
const variants = ['solid', 'soft', 'outline', 'ghost'] as const
export default function Demo() {
const [result, setResult] = useState('每种外观都保留独立的主操作和菜单')
return (
<Stack align="center" gap="lg">
<Flex wrap justify="center" gap="lg">
{variants.map(variant => (
<SplitButton
key={variant}
variant={variant}
menuLabel={`${variant} 的更多操作`}
onClick={() => setResult(`${variant}:已保存`)}
renderContent={() => (
<DropdownMenuItem onSelect={() => setResult(`${variant}:另存副本`)}>
另存副本
</DropdownMenuItem>
)}
>
{variant}
</SplitButton>
))}
</Flex>
<Flex wrap justify="center" gap="lg">
<SplitButton
tone="neutral"
menuLabel="下载方式"
onClick={() => setResult('已执行:下载')}
renderContent={() => (
<DropdownMenuItem onSelect={() => setResult('已执行:复制链接')}>
复制链接
</DropdownMenuItem>
)}
>
下载
</SplitButton>
<SplitButton
tone="danger"
variant="soft"
menuLabel="归档方式"
onClick={() => setResult('已执行:归档')}
renderContent={() => (
<DropdownMenuItem onSelect={() => setResult('已执行:移至回收站')}>
移至回收站
</DropdownMenuItem>
)}
>
归档
</SplitButton>
</Flex>
<Text role="status" size="sm" tone="muted">
{result}
</Text>
</Stack>
)
}
尺寸与圆角
size 同时控制主按钮和菜单按钮的高度,pill 仅改变整组外侧的圆角。拼接的两侧均不使用按压缩放。
菜单按钮始终与主操作等高
'use client'
import { useState } from 'react'
import { DropdownMenuItem, Flex, SplitButton, Stack, Text } from '@hina-ui/react'
const sizes = ['sm', 'md', 'lg'] as const
export default function Demo() {
const [result, setResult] = useState('菜单按钮始终与主操作等高')
return (
<Stack align="center">
<Flex wrap align="center" justify="center" gap="lg">
{sizes.map(size => (
<SplitButton
key={size}
size={size}
menuLabel={`${size} 的更多操作`}
onClick={() => setResult(`${size}:已保存`)}
renderContent={() => (
<DropdownMenuItem onSelect={() => setResult(`${size}:另存副本`)}>
另存副本
</DropdownMenuItem>
)}
>
保存
</SplitButton>
))}
<SplitButton
pill
variant="outline"
menuLabel="圆角按钮的更多操作"
onClick={() => setResult('已执行:保存')}
renderContent={() => (
<DropdownMenuItem onSelect={() => setResult('已执行:另存副本')}>
另存副本
</DropdownMenuItem>
)}
>
圆角按钮
</SplitButton>
</Flex>
<Text role="status" size="sm" tone="muted">
{result}
</Text>
</Stack>
)
}
加载与禁用
loading 会显示加载指示、禁用两侧并关闭已打开的菜单。disabled 禁用整组;primaryDisabled 和 menuDisabled 可以单独禁用一侧,例如不能发布时仍可保存草稿。
可以分别禁用主操作和菜单
'use client'
import { useState } from 'react'
import { DropdownMenuItem, FormField, SplitButton, Stack, Switch, Text } from '@hina-ui/react'
export default function Demo() {
const [loading, setLoading] = useState(false)
const [disabled, setDisabled] = useState(false)
const [primaryDisabled, setPrimaryDisabled] = useState(false)
const [menuDisabled, setMenuDisabled] = useState(false)
const [result, setResult] = useState('可以分别禁用主操作和菜单')
return (
<Stack className="w-full max-w-xs" gap="lg">
<Stack gap="sm">
<FormField label="加载中" orientation="horizontal">
<Switch checked={loading} onCheckedChange={setLoading} />
</FormField>
<FormField label="全部禁用" orientation="horizontal">
<Switch checked={disabled} onCheckedChange={setDisabled} />
</FormField>
<FormField label="禁用主操作" orientation="horizontal">
<Switch checked={primaryDisabled} onCheckedChange={setPrimaryDisabled} />
</FormField>
<FormField label="禁用菜单" orientation="horizontal">
<Switch checked={menuDisabled} onCheckedChange={setMenuDisabled} />
</FormField>
</Stack>
<Stack align="center" gap="sm">
<SplitButton
loading={loading}
disabled={disabled}
primaryDisabled={primaryDisabled}
menuDisabled={menuDisabled}
menuLabel="保存方式"
onClick={() => setResult('已执行:保存')}
renderContent={() => (
<DropdownMenuItem onSelect={() => setResult('已执行:另存副本')}>
另存副本
</DropdownMenuItem>
)}
>
保存修改
</SplitButton>
<Text role="status" size="sm" tone="muted">
{result}
</Text>
</Stack>
</Stack>
)
}
记住所选格式
菜单直接使用现有的 DropdownMenu 子组件,支持分组、单选、勾选项和子菜单。本例由应用持有导出格式,选择后更新主按钮的文字和操作参数。
选择格式后,主操作使用新的格式
'use client'
import { useState } from 'react'
import { Download } from 'lucide-react'
import {
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
SplitButton,
Stack,
Text,
} from '@hina-ui/react'
export default function Demo() {
const [format, setFormat] = useState('CSV')
const [result, setResult] = useState('选择格式后,主操作使用新的格式')
return (
<Stack align="center" gap="sm">
<SplitButton
variant="outline"
menuLabel="导出格式"
icon={<Download />}
onClick={() => setResult(`已执行:导出 ${format}`)}
renderContent={() => (
<>
<DropdownMenuLabel>文件格式</DropdownMenuLabel>
<DropdownMenuRadioGroup value={format} onValueChange={setFormat}>
<DropdownMenuRadioItem value="CSV">CSV</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="JSON">JSON</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="XLSX">Excel (.xlsx)</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</>
)}
>
{`导出 ${format}`}
</SplitButton>
<Text role="status" size="sm" tone="muted">
{result}
</Text>
</Stack>
)
}
占满宽度
block 让主按钮填满剩余宽度,箭头仍保持方形;较长的默认文案会截断。className 与 style 都作用于组容器。
主操作伸展,菜单按钮保持方形
'use client'
import { useState } from 'react'
import { Download } from 'lucide-react'
import { DropdownMenuItem, SplitButton, Stack, Text } from '@hina-ui/react'
export default function Demo() {
const [result, setResult] = useState('主操作伸展,菜单按钮保持方形')
return (
<Stack className="w-full max-w-xs" gap="sm">
<SplitButton
block
menuLabel="报表的其他操作"
icon={<Download />}
onClick={() => setResult('已执行:导出完整报表')}
renderContent={() => (
<>
<DropdownMenuItem onSelect={() => setResult('已执行:仅导出选中项')}>
仅导出选中项
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setResult('已执行:复制报表链接')}>
复制报表链接
</DropdownMenuItem>
</>
)}
>
导出本月所有项目的完整运行报表
</SplitButton>
<Text role="status" size="sm" tone="muted">
{result}
</Text>
</Stack>
)
}
表单提交
type="submit" 仅作用于主按钮。菜单触发器始终为 type="button",打开菜单和选择菜单项都不会意外提交表单。name、value、form 等原生属性透传到主按钮。
本例模拟异步发布。发布经过 Form 校验,保存草稿由菜单单独处理,可以保留未完成的标题。
'use client'
import { useState } from 'react'
import * as v from 'valibot'
import { DropdownMenuItem, Form, FormField, Input, SplitButton, Text } from '@hina-ui/react'
const schema = v.object({
title: v.pipe(v.string(), v.trim(), v.minLength(2, '标题至少需要 2 个字')),
})
export default function Demo() {
const [values, setValues] = useState({ title: '' })
const [result, setResult] = useState('')
async function publish(data: unknown) {
await new Promise(resolve => setTimeout(resolve, 800))
setResult(`已发布:${(data as { title: string }).title}`)
}
function saveDraft() {
setResult(`已保存草稿:${values.title.trim() || '未命名文章'}`)
}
return (
<Form values={values} rules={schema} className="w-full max-w-sm" onSubmit={publish}>
{({ submitting }) => (
<>
<FormField
name="title"
label="文章标题"
description="发布前校验标题,保存草稿可以保留未完成内容。"
required
>
<Input
value={values.title}
onValueChange={title => setValues({ ...values, title })}
placeholder="填写标题"
/>
</FormField>
<SplitButton
type="submit"
loading={submitting}
menuLabel="其他保存方式"
className="self-start"
renderContent={() => <DropdownMenuItem onSelect={saveDraft}>保存草稿</DropdownMenuItem>}
>
发布文章
</SplitButton>
{result && (
<Text role="status" size="sm" tone="muted">
{result}
</Text>
)}
</>
)}
</Form>
)
}
从右向左排列
dir="rtl" 同时控制按钮顺序、连接圆角和菜单方向。菜单对齐整组按钮的逻辑末端。
菜单按钮随书写方向排列
'use client'
import { useState } from 'react'
import { DropdownMenuItem, SplitButton, Stack, Text } from '@hina-ui/react'
export default function Demo() {
const [result, setResult] = useState('菜单按钮随书写方向排列')
return (
<Stack align="center" gap="sm">
<SplitButton
dir="rtl"
menuLabel="خيارات النشر"
onClick={() => setResult('已执行:发布')}
renderContent={() => (
<>
<DropdownMenuItem onSelect={() => setResult('已执行:保存草稿')}>
حفظ المسودة
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setResult('已执行:定时发布')}>
جدولة النشر
</DropdownMenuItem>
</>
)}
>
نشر المقال
</SplitButton>
<Text role="status" size="sm" tone="muted">
{result}
</Text>
</Stack>
)
}
主操作作为链接
as 只改变主按钮。应用内导航传入 next/link 等路由组件,href 等属性透传到该组件;菜单触发器仍是普通按钮。组件和 renderContent 都无法从 Server Component 传入,这类组合需要放在 'use client' 模块中。
'use client'
import NextLink from 'next/link'
import { useRouter } from 'next/navigation'
import { DropdownMenuItem, SplitButton } from '@hina-ui/react'
export function ButtonDocs() {
const router = useRouter()
return (
<SplitButton
as={NextLink}
href="/components/button"
menuLabel="相关文档"
renderContent={() => (
<DropdownMenuItem onSelect={() => router.push('/components/button-group')}>
查看 ButtonGroup
</DropdownMenuItem>
)}
>
查看 Button
</SplitButton>
)
}
行为与无障碍
Tab分别访问主按钮与菜单按钮。主按钮按Enter或空格执行操作,按ArrowDown打开菜单;链接形态遵循链接的键盘行为。- 菜单按钮按
Enter、空格或ArrowDown打开菜单,键盘打开时聚焦首个可用项。菜单沿用DropdownMenu的方向键、首尾跳转和字符搜索。 Escape关闭菜单并将焦点返回菜单按钮。点选普通菜单项后同样关闭;需要保留菜单时在该项的onSelect中调用event.preventDefault()。menuLabel应描述附加操作,默认使用当前语言的“更多操作”。label为整组提供可选的无障碍名称。icon和trailing只属于主操作;菜单箭头使用DisclosureIcon,随打开状态变化。
API
Props
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
variant | 'solid' | 'soft' | 'outline' | 'ghost' | 'solid' | 两侧共用的外观 |
tone | 'accent' | 'neutral' | 'danger' | 'accent' | 两侧共用的色调 |
size | 'sm' | 'md' | 'lg' | 'md' | 按钮尺寸 |
block | boolean | false | 占满容器宽度 |
pill | boolean | false | 整组外侧使用全圆角 |
ripple | boolean | true | 启用涟漪反馈 |
loading | boolean | false | 显示加载指示并禁用两侧 |
disabled | boolean | false | 禁用两侧 |
primaryDisabled | boolean | false | 仅禁用主操作 |
menuDisabled | boolean | false | 仅禁用菜单 |
as | string | Component | 'button' | 主按钮的元素或组件 |
type | 'button' | 'submit' | 'reset' | 'button' | 主按钮类型 |
label | string | — | 按钮组的无障碍名称 |
menuLabel | string | 当前语言的“更多操作” | 菜单按钮及菜单的无障碍名称 |
modal | boolean | true | 菜单打开时限制外部交互 |
dir | 'ltr' | 'rtl' | 继承 | 按钮和菜单的书写方向 |
side | 'top' | 'right' | 'bottom' | 'left' | 'bottom' | 菜单的首选方向 |
align | 'start' | 'center' | 'end' | 'end' | 菜单相对于整组的对齐方式 |
sideOffset | number | 8 | 菜单与按钮组的间距 |
className | string | — | 组容器的类名 |
style | StyleValue | — | 组容器的样式 |
menuClass | string | — | 菜单内容的类名 |
其他属性和事件处理函数(例如 id、aria-label、href、target、onKeyDown)透传到主按钮。
受控状态
| 模型 | 类型 | 默认值 | 说明 |
|---|---|---|---|
open / onOpenChange | boolean | false | 菜单打开状态;加载或禁用菜单时关闭 |
回调
| 回调 | 参数 | 说明 |
|---|---|---|
onClick | MouseEvent | 主操作点击;菜单操作不会调用它 |
onOpenChange | boolean | 菜单打开状态变化 |
内容属性
| 属性 | 参数 | 说明 |
|---|---|---|
children | — | 主按钮文案 |
icon | — | 主按钮前置图标 |
trailing | — | 主按钮尾部内容 |
renderContent | { close: () => void } | 菜单内容,通常由 DropdownMenuItem 等子组件组成 |
Ref
| 方法 | 类型 | 说明 |
|---|---|---|
focus | () => void | 聚焦可用的主按钮 |
openMenu | () => Promise<void> | 聚焦菜单触发器并打开菜单;菜单不可用时无操作 |
closeMenu | () => void | 关闭菜单 |